fix: UX audit improvements - dark theme, structured errors, heartbeat, health check
Phase 1 - UI Theme Consistency: - Chat messages now use consistent dark theme (removed jarring white bg) - Plan cards restyled with rounded borders, fills, colored status badges - Timeline entries use humanist sans-serif instead of monospace - Error messages now structured (icon + title + detail + action link) - Input area unified with dark theme cohesion Phase 2 - UX Flow: - Added contextual placeholder text per agent mode in textarea - Added visual mode indicator badge (Chat/Planning/Writing) - Simplified welcome screen (single 'Continue' + collapsible history) - Added slash command/mention discovery hint in empty input - Added write confirmation when editor has existing content - Added 30s streaming heartbeat (reassurance when model is slow) Phase 3 - Error Handling: - Added DB table health check on sidebar init - Improved 'no API key' error with settings link - Shows in-chat warning when provider fallback triggers - Auto-fallback to registry fallback model on unavailability - isLoading always resets via try/finally pattern
This commit is contained in:
@@ -37,21 +37,28 @@
|
||||
const cleanMessage = String(rawMessage || fallback).replace(/^API error:\s*/i, '').trim();
|
||||
const lowerMessage = cleanMessage.toLowerCase();
|
||||
|
||||
// Returns structured object { title, detail, actionUrl, actionLabel }
|
||||
const structured = (title, detail, actionUrl, actionLabel) => {
|
||||
return { title, detail, actionUrl: actionUrl || '', actionLabel: actionLabel || '' };
|
||||
};
|
||||
|
||||
if (
|
||||
lowerMessage.includes('no allowed providers are available')
|
||||
|| (lowerMessage.includes('allowed providers') && lowerMessage.includes('selected model'))
|
||||
) {
|
||||
const routedProvider = settings?.openrouter_provider_slug && settings.openrouter_provider_slug !== 'auto'
|
||||
? ` Current pinned provider: ${settings.openrouter_provider_slug}.`
|
||||
? ` Pinned: ${settings.openrouter_provider_slug}.`
|
||||
: '';
|
||||
|
||||
return 'The selected model is not available from the current OpenRouter provider routing settings.'
|
||||
+ routedProvider
|
||||
+ ' Open Settings -> WP Agentic Writer -> Models -> OpenRouter Provider Routing, then either choose a provider that supports this model, turn off "Only use this provider", enable fallback providers, or select a model from the pinned BYOK provider.';
|
||||
return structured(
|
||||
'Model unavailable from current provider',
|
||||
`The pinned provider routing doesn't support this model.${routedProvider} Change provider routing or select a compatible model.`,
|
||||
settings?.settings_url || '',
|
||||
'Open Settings'
|
||||
);
|
||||
}
|
||||
|
||||
if (cleanMessage.includes('429') || lowerMessage.includes('rate limit')) {
|
||||
return 'Rate limit exceeded. Please wait a moment and try again.';
|
||||
return structured('Rate limit exceeded', 'The AI provider is throttling requests. Wait a moment and try again.');
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -59,18 +66,37 @@
|
||||
|| lowerMessage.includes('operation timed out')
|
||||
|| lowerMessage.includes('timed out after')
|
||||
) {
|
||||
return 'The selected model/provider started the planning request but did not finish before the 2-minute timeout. Try a faster planning model, reduce the outline/article length, or switch OpenRouter Provider Routing back to Auto/fallback-capable routing before retrying.';
|
||||
return structured(
|
||||
'Request timed out',
|
||||
'The model took too long to respond. Try a faster model, reduce content length, or check your provider routing.',
|
||||
settings?.settings_url || '',
|
||||
'Open Settings'
|
||||
);
|
||||
}
|
||||
|
||||
if (cleanMessage.startsWith('HTTP 401') || lowerMessage.includes('unauthorized')) {
|
||||
return 'The AI provider rejected the API key. Please check the OpenRouter/API key settings and try again.';
|
||||
return structured(
|
||||
'API key rejected',
|
||||
'The provider rejected your API key. Check your key in settings.',
|
||||
settings?.settings_url || '',
|
||||
'Open Settings'
|
||||
);
|
||||
}
|
||||
|
||||
if (cleanMessage.startsWith('HTTP 402') || lowerMessage.includes('insufficient credits')) {
|
||||
return 'The AI provider says the account has insufficient credits or quota. Please check your provider billing/BYOK setup.';
|
||||
return structured('Insufficient credits', 'Your provider account has no remaining credits or quota.');
|
||||
}
|
||||
|
||||
return `Error: ${cleanMessage || fallback}`;
|
||||
if (lowerMessage.includes('api key is not configured') || lowerMessage.includes('no_api_key')) {
|
||||
return structured(
|
||||
'API key not configured',
|
||||
'Add your OpenRouter API key in plugin settings to start using AI features.',
|
||||
settings?.settings_url || '',
|
||||
'Configure API Key'
|
||||
);
|
||||
}
|
||||
|
||||
return structured(cleanMessage || fallback, '');
|
||||
};
|
||||
|
||||
// Tab state
|
||||
@@ -1857,10 +1883,29 @@
|
||||
let streamBuffer = '';
|
||||
let fullContent = '';
|
||||
let streamError = null;
|
||||
let lastDataTime = Date.now();
|
||||
let heartbeatShown = false;
|
||||
|
||||
// Heartbeat: show reassurance if no data for 30s
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
if (Date.now() - lastDataTime > 30000 && !heartbeatShown) {
|
||||
heartbeatShown = true;
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'system',
|
||||
type: 'timeline',
|
||||
status: 'active',
|
||||
message: '⏳ Still waiting for response — the model is processing...',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
lastDataTime = Date.now();
|
||||
heartbeatShown = false;
|
||||
|
||||
streamBuffer += decoder.decode(value, { stream: true });
|
||||
const lines = streamBuffer.split('\n');
|
||||
@@ -1901,6 +1946,15 @@
|
||||
addFocusKeywordSuggestions(suggestions);
|
||||
}
|
||||
}
|
||||
} else if (data.type === 'provider' && data.fallback_used) {
|
||||
// Show in-chat provider fallback warning
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'system',
|
||||
type: 'timeline',
|
||||
status: 'active',
|
||||
message: `⚠️ ${data.selectedProvider || 'Selected provider'} unavailable — using ${data.provider || 'fallback'}`,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
} catch (e) {
|
||||
wpawLog.error('Failed to parse retry streaming data:', line, e);
|
||||
@@ -1911,6 +1965,9 @@
|
||||
throw streamError;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearInterval(heartbeatInterval);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = formatAiErrorMessage(error, 'Failed to chat');
|
||||
setMessages(prev => [...prev, {
|
||||
@@ -2549,6 +2606,24 @@
|
||||
}
|
||||
|
||||
const plan = currentPlanRef.current;
|
||||
|
||||
// Confirmation: warn if editor already has content blocks
|
||||
const existingBlocks = select('core/block-editor').getBlocks();
|
||||
const hasExistingContent = existingBlocks.some(b =>
|
||||
b.name !== 'core/paragraph' || (b.attributes?.content && b.attributes.content.trim().length > 0)
|
||||
);
|
||||
if (hasExistingContent && !options.skipConfirm) {
|
||||
const pendingSections = Array.isArray(plan?.sections)
|
||||
? plan.sections.filter((section) => section.status !== 'done').length
|
||||
: 0;
|
||||
const confirmed = window.confirm(
|
||||
`This will write ${pendingSections} sections into the editor. Existing content will be preserved below the new content.\n\nContinue?`
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setAgentMode('writing');
|
||||
const pendingCount = Array.isArray(plan?.sections)
|
||||
? plan.sections.filter((section) => section.status !== 'done').length
|
||||
@@ -5583,23 +5658,32 @@
|
||||
|
||||
// Render Welcome Screen (chatty, friendly)
|
||||
const renderWelcomeScreen = () => {
|
||||
const recentSession = availableSessions.length > 0 ? availableSessions[0] : null;
|
||||
|
||||
return wp.element.createElement('div', { className: 'wpaw-welcome-screen' },
|
||||
wp.element.createElement('div', { className: 'wpaw-welcome-content' },
|
||||
wp.element.createElement('span', {
|
||||
className: 'wpaw-welcome-icon',
|
||||
dangerouslySetInnerHTML: { __html: '<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M12 5a3 3 0 1 0-5.997.125a4 4 0 0 0-2.526 5.77a4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M9 13a4.5 4.5 0 0 0 3-4M6.003 5.125A3 3 0 0 0 6.401 6.5m-2.924 4.396a4 4 0 0 1 .585-.396M6 18a4 4 0 0 1-1.967-.516M12 13h4m-4 5h6a2 2 0 0 1 2 2v1M12 8h8m-4 0V5a2 2 0 0 1 2-2"/><circle cx="16" cy="13" r=".5"/><circle cx="18" cy="3" r=".5"/><circle cx="20" cy="21" r=".5"/><circle cx="20" cy="8" r=".5"/></g></svg>' }
|
||||
}),
|
||||
wp.element.createElement('h2', { className: 'wpaw-welcome-title' }, 'Welcome to Agentic Writer'),
|
||||
wp.element.createElement('p', { className: 'wpaw-welcome-subtitle' }, "What's your concern today?"),
|
||||
availableSessions.length > 0 && wp.element.createElement('div', {
|
||||
className: 'wpaw-existing-sessions',
|
||||
style: { marginBottom: '16px' }
|
||||
wp.element.createElement('h2', { className: 'wpaw-welcome-title' }, 'Agentic Writer'),
|
||||
wp.element.createElement('p', { className: 'wpaw-welcome-subtitle' }, "What are we writing today?"),
|
||||
// Show single "Continue last conversation" button if available
|
||||
recentSession && wp.element.createElement('button', {
|
||||
className: 'wpaw-welcome-pill',
|
||||
style: { width: '100%', marginBottom: '12px' },
|
||||
disabled: isSessionActionLoading,
|
||||
onClick: () => openSessionById(recentSession.session_id || '')
|
||||
}, `↩ Continue: ${getSessionDisplayTitle(recentSession, 0)}`),
|
||||
// Show older sessions in collapsible
|
||||
availableSessions.length > 1 && wp.element.createElement('details', {
|
||||
style: { marginBottom: '12px', width: '100%' }
|
||||
},
|
||||
wp.element.createElement('div', {
|
||||
style: { fontSize: '12px', opacity: 0.8, marginBottom: '8px' }
|
||||
}, 'Continue a previous conversation'),
|
||||
wp.element.createElement('div', { className: 'wpaw-session-list' },
|
||||
...availableSessions.map((session, idx) =>
|
||||
wp.element.createElement('summary', {
|
||||
style: { fontSize: '12px', color: '#8b95a5', cursor: 'pointer', marginBottom: '8px' }
|
||||
}, `${availableSessions.length - 1} more session${availableSessions.length > 2 ? 's' : ''}`),
|
||||
wp.element.createElement('div', { className: 'wpaw-session-list' },
|
||||
...availableSessions.slice(1).map((session, idx) =>
|
||||
wp.element.createElement('div', {
|
||||
key: session.session_id || idx,
|
||||
className: 'wpaw-welcome-pill',
|
||||
@@ -5624,14 +5708,9 @@
|
||||
textAlign: 'left',
|
||||
cursor: isSessionActionLoading ? 'wait' : 'pointer'
|
||||
},
|
||||
onClick: () => {
|
||||
openSessionById(session.session_id || '');
|
||||
}
|
||||
onClick: () => openSessionById(session.session_id || '')
|
||||
},
|
||||
wp.element.createElement('div', null, getSessionDisplayTitle(session, idx)),
|
||||
// wp.element.createElement('div', { style: { opacity: 0.55, fontSize: '10px', marginTop: '2px' } },
|
||||
// getSessionDebugMeta(session)
|
||||
// ),
|
||||
wp.element.createElement('div', null, getSessionDisplayTitle(session, idx + 1)),
|
||||
wp.element.createElement('div', { style: { opacity: 0.7, fontSize: '11px' } },
|
||||
`${Number(session?.message_count ?? (Array.isArray(session?.messages) ? session.messages.length : 0))} msgs`
|
||||
)
|
||||
@@ -5652,19 +5731,13 @@
|
||||
}, '×')
|
||||
)
|
||||
)
|
||||
),
|
||||
wp.element.createElement('button', {
|
||||
className: 'wpaw-welcome-pill',
|
||||
style: { width: '100%' },
|
||||
disabled: isSessionActionLoading,
|
||||
onClick: startNewConversation
|
||||
}, '+ Start New Conversation')
|
||||
)
|
||||
),
|
||||
// Focus keyword input
|
||||
wp.element.createElement('input', {
|
||||
type: 'text',
|
||||
className: 'wpaw-welcome-input',
|
||||
placeholder: 'Your focus keyword (optional)',
|
||||
placeholder: 'Focus keyword (optional)',
|
||||
value: welcomeKeywordInput,
|
||||
onChange: (e) => setWelcomeKeywordInput(e.target.value),
|
||||
onKeyDown: (e) => {
|
||||
@@ -5682,14 +5755,14 @@
|
||||
wp.element.createElement('button', {
|
||||
className: 'wpaw-welcome-pill' + (welcomeStartMode === 'planning' ? ' active' : ''),
|
||||
onClick: () => setWelcomeStartMode('planning')
|
||||
}, '📝 Make an Outline')
|
||||
}, '📝 Create Outline')
|
||||
),
|
||||
// Start button
|
||||
wp.element.createElement(Button, {
|
||||
isPrimary: true,
|
||||
onClick: handleWelcomeStart,
|
||||
className: 'wpaw-welcome-start-btn'
|
||||
}, 'Start')
|
||||
}, 'Start Writing')
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -6615,15 +6688,30 @@
|
||||
retryLastGeneration();
|
||||
};
|
||||
|
||||
// Support structured error objects { title, detail, actionUrl, actionLabel }
|
||||
const errContent = message.content;
|
||||
const isStructured = errContent && typeof errContent === 'object' && errContent.title;
|
||||
|
||||
return wp.element.createElement('div', {
|
||||
key: `error-${index}`,
|
||||
className: 'wpaw-ai-item wpaw-message wpaw-message-error',
|
||||
},
|
||||
wp.element.createElement('div', { className: 'wpaw-message-content' }, renderMessageContent(message.content, true)),
|
||||
isStructured
|
||||
? wp.element.createElement('div', null,
|
||||
wp.element.createElement('div', { className: 'wpaw-error-title' }, '⚠ ', errContent.title),
|
||||
errContent.detail && wp.element.createElement('div', { className: 'wpaw-error-detail' }, errContent.detail),
|
||||
errContent.actionUrl && wp.element.createElement('a', {
|
||||
href: errContent.actionUrl,
|
||||
target: '_blank',
|
||||
rel: 'noopener',
|
||||
style: { display: 'inline-block', marginTop: '8px', fontSize: '12px', color: '#fca5a5', textDecoration: 'underline' }
|
||||
}, errContent.actionLabel || 'Open Settings')
|
||||
)
|
||||
: wp.element.createElement('div', { className: 'wpaw-message-content' }, renderMessageContent(errContent, true)),
|
||||
message.canRetry && wp.element.createElement(Button, {
|
||||
isSecondary: true,
|
||||
onClick: handleRetry,
|
||||
}, 'Retry')
|
||||
}, '↻ Retry')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7024,6 +7112,19 @@
|
||||
isRefinementLocked && wp.element.createElement('div', { className: 'wpaw-refinement-lock-banner' },
|
||||
`Refining in progress — editing is temporarily locked. You can still scroll and review changes live (${refiningBlockIds.length} target block(s)).`
|
||||
),
|
||||
// Health Check Warnings
|
||||
wpAgenticWriter.health && !wpAgenticWriter.health.ok && wpAgenticWriter.health.issues.map((issue, idx) =>
|
||||
wp.element.createElement('div', { key: `health-${idx}`, className: 'wpaw-health-notice' },
|
||||
'⚠️ ',
|
||||
issue.message,
|
||||
issue.actionUrl && wp.element.createElement('a', {
|
||||
href: issue.actionUrl,
|
||||
target: '_blank',
|
||||
rel: 'noopener',
|
||||
style: { marginLeft: '8px' }
|
||||
}, issue.actionLabel || 'Fix')
|
||||
)
|
||||
),
|
||||
// Welcome Screen (first time)
|
||||
showWelcome && !isEditorLocked && renderWelcomeScreen(),
|
||||
// Writing Mode Empty State
|
||||
@@ -7037,8 +7138,20 @@
|
||||
),
|
||||
// Context Indicator (moved above textarea) - hide when showing empty state or welcome
|
||||
!showWelcome && !shouldShowWritingEmptyState() && renderContextIndicator(),
|
||||
// Mode Badge
|
||||
!showWelcome && !shouldShowWritingEmptyState() && wp.element.createElement('div', {
|
||||
className: `wpaw-mode-badge mode-${agentMode}`
|
||||
},
|
||||
agentMode === 'chat' ? '💬' : agentMode === 'planning' ? '📝' : '✍️',
|
||||
agentMode === 'chat' ? 'Chat Mode' : agentMode === 'planning' ? 'Planning Mode' : 'Writing Mode'
|
||||
),
|
||||
// Command Input Area - hide when showing empty state or welcome
|
||||
!showWelcome && !shouldShowWritingEmptyState() && wp.element.createElement('div', { className: 'wpaw-command-area', style: { position: 'relative' } },
|
||||
// Slash command hint when input is empty
|
||||
!input && !isLoading && wp.element.createElement('div', { className: 'wpaw-input-hint' },
|
||||
'Type ', wp.element.createElement('kbd', null, '/'), ' for commands or ',
|
||||
wp.element.createElement('kbd', null, '@'), ' to mention a block'
|
||||
),
|
||||
// Removed Toolbar from Top
|
||||
wp.element.createElement('div', {
|
||||
className: 'wpaw-command-input-wrapper' + (isTextareaExpanded ? ' expanded' : '')
|
||||
@@ -7051,8 +7164,10 @@
|
||||
onKeyDown: handleKeyDown,
|
||||
rows: isTextareaExpanded ? 20 : 3,
|
||||
placeholder: agentMode === 'planning'
|
||||
? 'Describe what you want to write about...'
|
||||
: 'Ask me anything about your content...'
|
||||
? 'Describe your article topic...'
|
||||
: agentMode === 'writing'
|
||||
? 'Refine content — use @block to target specific sections...'
|
||||
: 'Ask anything about your content, or type / for commands...'
|
||||
})
|
||||
),
|
||||
showMentionAutocomplete && mentionOptions.length > 0 && wp.element.createElement('div', {
|
||||
|
||||
Reference in New Issue
Block a user