[eric] help: real Help panel off the pill (ask starts a chat, bug/feature open prefilled GitHub issues, docs link)

This commit is contained in:
ciregenz
2026-07-24 12:53:53 -07:00
parent 1441c52b3a
commit 5c87b8aafc
2 changed files with 239 additions and 44 deletions
@@ -0,0 +1,182 @@
import React, { useCallback, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { AnimatePresence, motion } from 'framer-motion';
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
import BugReportOutlinedIcon from '@mui/icons-material/BugReportOutlined';
import LightbulbOutlinedIcon from '@mui/icons-material/LightbulbOutlined';
import MenuBookOutlinedIcon from '@mui/icons-material/MenuBookOutlined';
import ArrowOutwardRoundedIcon from '@mui/icons-material/ArrowOutwardRounded';
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import ArrowUpwardRoundedIcon from '@mui/icons-material/ArrowUpwardRounded';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { createDraftSession, launchAndSendFirstMessage, type AgentConfig } from '@/shared/state/agentsSlice';
import { getLastDashboardId } from '@/shared/lastDashboardId';
const REPO_ISSUES_URL = 'https://github.com/openswarm-ai/openswarm/issues/new';
const DOCS_URL = 'https://docs.openswarm.com';
function openExternal(url: string): void {
const api = (window as unknown as { openswarm?: { openExternal?: (u: string) => void } }).openswarm;
if (api?.openExternal) api.openExternal(url);
else window.open(url, '_blank');
}
type Pane = 'root' | 'bug' | 'idea';
// The Help panel: Linear/Raycast-style popover off the Help pill. Ask leads (starts a real chat with
// the question), then report-a-bug / request-a-feature (open a PREFILLED GitHub issue: version + OS
// attached in the body, no secret and no backend needed, and the user can watch the thread for team
// replies), then docs. Closes on outside click or Esc via the parent.
const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const dispatch = useAppDispatch();
const model = useAppSelector((s) => s.settings.data.default_model);
const appVersion = useAppSelector((s) => s.update.appVersion);
const [pane, setPane] = useState<Pane>('root');
const [ask, setAsk] = useState('');
const [reportText, setReportText] = useState('');
const startChat = useCallback((prompt: string): void => {
const p = prompt.trim();
if (!p) return;
const dashboardId = getLastDashboardId() ?? undefined;
const config: AgentConfig = { name: 'Help', model, mode: 'agent', dashboard_id: dashboardId };
const draftId = dispatch(createDraftSession({ mode: 'agent', model, dashboardId: dashboardId ?? '', setActive: true })).payload.draftId;
void dispatch(launchAndSendFirstMessage({ draftId, config, prompt: p, mode: 'agent', model, expand: true }));
onClose();
}, [dispatch, model, onClose]);
const fileOnGitHub = useCallback((kind: 'bug' | 'idea'): void => {
const body = [
reportText.trim(),
'',
'---',
`App version: ${appVersion ?? 'dev'}`,
`Platform: ${navigator.platform}`,
].join('\n');
const params = new URLSearchParams({
title: reportText.trim().split('\n')[0].slice(0, 80) || (kind === 'bug' ? 'Bug report' : 'Feature request'),
labels: kind === 'bug' ? 'bug' : 'enhancement',
body,
});
openExternal(`${REPO_ISSUES_URL}?${params.toString()}`);
onClose();
}, [reportText, appVersion, onClose]);
const rowSx = {
display: 'flex', alignItems: 'center', gap: 1.25, width: '100%',
px: 1.25, py: 1, borderRadius: '9px', border: 'none', background: 'transparent',
color: 'rgba(255,255,255,0.9)', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left' as const,
'&:hover': { background: 'rgba(255,255,255,0.07)' },
};
const iconSx = { fontSize: 17, color: 'rgba(255,255,255,0.65)', flexShrink: 0 };
const fieldSx = {
width: '100%', boxSizing: 'border-box' as const, resize: 'none' as const,
background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.12)', borderRadius: '10px',
color: 'rgba(255,255,255,0.92)', fontFamily: 'inherit', fontSize: '0.8125rem', lineHeight: 1.5,
padding: '9px 11px', outline: 'none',
};
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: -8, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.98 }}
transition={{ type: 'spring', stiffness: 420, damping: 30 }}
style={{
position: 'absolute', top: 'calc(100% + 8px)', right: 0, width: 316, zIndex: 1500,
background: 'rgba(22,17,26,0.94)',
backdropFilter: 'blur(24px) saturate(150%)', WebkitBackdropFilter: 'blur(24px) saturate(150%)',
border: '1px solid rgba(255,255,255,0.1)', borderRadius: '14px',
boxShadow: '0 22px 60px -18px rgba(0,0,0,0.6)', overflow: 'hidden',
cursor: 'default',
}}
onClick={(e) => e.stopPropagation()}
>
{pane === 'root' ? (
<Box sx={{ p: 1 }}>
<Box sx={{ px: 1.25, pt: 0.75, pb: 0.5 }}>
<Typography sx={{ fontSize: '0.875rem', fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}>How can we help?</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mx: 1, my: 0.75, px: 1.25, py: 0.75, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.12)', borderRadius: '10px' }}>
<AutoAwesomeRoundedIcon sx={{ fontSize: 15, color: 'rgba(255,255,255,0.5)' }} />
<Box
component="input"
value={ask}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAsk(e.target.value)}
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') startChat(ask); e.stopPropagation(); }}
placeholder="Ask OpenSwarm anything..."
sx={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', color: 'rgba(255,255,255,0.92)', fontFamily: 'inherit', fontSize: '0.8125rem', '&::placeholder': { color: 'rgba(255,255,255,0.4)' } }}
/>
{ask.trim() && (
<Box component="button" onClick={() => startChat(ask)} sx={{ display: 'flex', border: 'none', background: 'transparent', color: 'rgba(255,255,255,0.8)', cursor: 'pointer', p: 0 }}>
<ArrowUpwardRoundedIcon sx={{ fontSize: 16 }} />
</Box>
)}
</Box>
<Box component="button" onClick={() => { setReportText(''); setPane('bug'); }} sx={rowSx}>
<BugReportOutlinedIcon sx={iconSx} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500 }}>Report a bug</Typography>
<Typography sx={{ fontSize: '0.6875rem', color: 'rgba(255,255,255,0.45)' }}>Version attached automatically</Typography>
</Box>
<ChevronRightRoundedIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.4)' }} />
</Box>
<Box component="button" onClick={() => { setReportText(''); setPane('idea'); }} sx={rowSx}>
<LightbulbOutlinedIcon sx={iconSx} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500 }}>Request a feature</Typography>
<Typography sx={{ fontSize: '0.6875rem', color: 'rgba(255,255,255,0.45)' }}>Tell us what's missing</Typography>
</Box>
<ChevronRightRoundedIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.4)' }} />
</Box>
<Box component="button" onClick={() => { openExternal(DOCS_URL); onClose(); }} sx={rowSx}>
<MenuBookOutlinedIcon sx={iconSx} />
<Typography sx={{ flex: 1, fontSize: '0.8125rem', fontWeight: 500 }}>Docs &amp; shortcuts</Typography>
<ArrowOutwardRoundedIcon sx={{ fontSize: 14, color: 'rgba(255,255,255,0.4)' }} />
</Box>
</Box>
) : (
<Box sx={{ p: 1.5 }}>
<Box component="button" onClick={() => setPane('root')} sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, border: 'none', background: 'transparent', color: 'rgba(255,255,255,0.55)', fontSize: '0.75rem', cursor: 'pointer', fontFamily: 'inherit', p: 0, mb: 1 }}>
<ArrowBackRoundedIcon sx={{ fontSize: 14 }} /> {pane === 'bug' ? 'Report a bug' : 'Request a feature'}
</Box>
<Box
component="textarea"
autoFocus
rows={4}
value={reportText}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setReportText(e.target.value)}
onKeyDown={(e: React.KeyboardEvent) => e.stopPropagation()}
placeholder={pane === 'bug' ? 'What went wrong?' : "What's missing?"}
sx={fieldSx}
/>
<Typography sx={{ fontSize: '0.6875rem', color: 'rgba(255,255,255,0.45)', mt: 0.75 }}>
Opens a prefilled GitHub issue; watch it for replies from the team.
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1.25 }}>
<Box component="button" onClick={onClose} sx={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,0.55)', fontSize: '0.8125rem', cursor: 'pointer', fontFamily: 'inherit', px: 1, py: 0.5 }}>Cancel</Box>
<Box
component="button"
onClick={() => fileOnGitHub(pane === 'bug' ? 'bug' : 'idea')}
disabled={!reportText.trim()}
sx={{
border: 'none', borderRadius: '9px', px: 1.5, py: 0.6, fontFamily: 'inherit',
fontSize: '0.8125rem', fontWeight: 600, cursor: reportText.trim() ? 'pointer' : 'default',
background: reportText.trim() ? 'rgba(255,255,255,0.92)' : 'rgba(255,255,255,0.15)',
color: reportText.trim() ? '#1c1b19' : 'rgba(255,255,255,0.4)',
}}
>
Continue on GitHub
</Box>
</Box>
</Box>
)}
</motion.div>
</AnimatePresence>
);
};
export default HelpPanel;
@@ -1,63 +1,76 @@
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import CircularProgress from '@mui/material/CircularProgress';
import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined';
import MicIcon from '@mui/icons-material/Mic';
import { useAppDispatch } from '@/shared/hooks';
import { addBrowserCard } from '@/shared/state/dashboardLayoutSlice';
import { useVoice } from '@/shared/voice/VoiceDictationContext';
import HelpPanel from './HelpPanel';
const HELP_URL = 'https://openswarm.com';
/** Top-right desktop pill: Help opens the docs; the mic dictates (local whisper) into the focused field. */
/** Top-right desktop pill: Help opens the help panel (ask, report a bug, docs); the mic dictates (local whisper) into the focused field. */
function HelpPill(): React.ReactElement {
const dispatch = useAppDispatch();
const { state, pct, pressStart, pressEnd } = useVoice();
const [helpOpen, setHelpOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const recording = state === 'recording';
const transcribing = state === 'transcribing';
const preparing = state === 'preparing';
const busy = transcribing || preparing;
// Outside click / Esc closes the panel; listeners only live while it's open.
useEffect(() => {
if (!helpOpen) return undefined;
const onDown = (e: MouseEvent): void => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setHelpOpen(false);
};
const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') setHelpOpen(false); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); };
}, [helpOpen]);
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
height: 30,
pl: 1.5,
pr: 1,
borderRadius: 999,
background: recording ? 'rgba(150,30,40,0.72)' : 'rgba(22,12,34,0.66)',
backdropFilter: 'blur(20px) saturate(160%)',
WebkitBackdropFilter: 'blur(20px) saturate(160%)',
boxShadow: '0 6px 20px rgba(0,0,0,0.3)',
cursor: 'pointer',
userSelect: 'none',
transition: 'background 0.2s ease',
}}
onClick={() => dispatch(addBrowserCard({ url: HELP_URL }))}
>
<Typography sx={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.72)', fontWeight: 500 }}>
{recording ? 'Listening' : transcribing ? 'Transcribing' : preparing ? `Preparing ${pct}%` : 'Help'}
</Typography>
<Tooltip title={recording ? 'Stop dictation' : preparing ? 'Downloading voice model' : 'Dictate (Cmd+Shift+D)'} placement="bottom" arrow>
<Box
sx={{ display: 'flex', alignItems: 'center', color: recording ? '#fff' : 'rgba(255,255,255,0.55)' }}
onPointerDown={(e) => { e.stopPropagation(); if (!busy) pressStart(); }}
onPointerUp={(e) => { e.stopPropagation(); pressEnd(); }}
onPointerLeave={() => pressEnd()}
onClick={(e) => e.stopPropagation()}
>
{busy
? <CircularProgress size={13} thickness={5} sx={{ color: 'rgba(255,255,255,0.7)' }} />
: recording
? <MicIcon sx={{ fontSize: 16 }} />
: <MicNoneOutlinedIcon sx={{ fontSize: 15 }} />}
</Box>
</Tooltip>
<Box ref={rootRef} sx={{ position: 'relative' }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
height: 30,
pl: 1.5,
pr: 1,
borderRadius: 999,
background: recording ? 'rgba(150,30,40,0.72)' : 'rgba(22,12,34,0.66)',
backdropFilter: 'blur(20px) saturate(160%)',
WebkitBackdropFilter: 'blur(20px) saturate(160%)',
boxShadow: '0 6px 20px rgba(0,0,0,0.3)',
cursor: 'pointer',
userSelect: 'none',
transition: 'background 0.2s ease',
}}
onClick={() => setHelpOpen((v) => !v)}
>
<Typography sx={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.72)', fontWeight: 500 }}>
{recording ? 'Listening' : transcribing ? 'Transcribing' : preparing ? `Preparing ${pct}%` : 'Help'}
</Typography>
<Tooltip title={recording ? 'Stop dictation' : preparing ? 'Downloading voice model' : 'Dictate (F5)'} placement="bottom" arrow>
<Box
sx={{ display: 'flex', alignItems: 'center', color: recording ? '#fff' : 'rgba(255,255,255,0.55)' }}
onPointerDown={(e) => { e.stopPropagation(); if (!busy) pressStart(); }}
onPointerUp={(e) => { e.stopPropagation(); pressEnd(); }}
onPointerLeave={() => pressEnd()}
onClick={(e) => e.stopPropagation()}
>
{busy
? <CircularProgress size={13} thickness={5} sx={{ color: 'rgba(255,255,255,0.7)' }} />
: recording
? <MicIcon sx={{ fontSize: 16 }} />
: <MicNoneOutlinedIcon sx={{ fontSize: 15 }} />}
</Box>
</Tooltip>
</Box>
{helpOpen && <HelpPanel onClose={() => setHelpOpen(false)} />}
</Box>
);
}