mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-08 02:37:45 +02:00
[eric] help: full panel (Discord, what's new, attachments) + local diagnostics bundle revealed for the GitHub issue
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
"""Diagnostic bundle for bug reports: one folder a user can drag into a GitHub issue.
|
||||
|
||||
Everything is assembled LOCALLY and only revealed in the file manager; nothing uploads
|
||||
anywhere by itself. Contents are deliberately allowlisted (identity, versions, feature
|
||||
booleans, provider KINDS, counts, log tail) so no secret or API key can ever ride along.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator, List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.config.paths import DATA_ROOT, SESSIONS_DIR
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def help_lifespan() -> AsyncIterator[None]:
|
||||
yield
|
||||
|
||||
|
||||
help_app = SubApp("help", help_lifespan)
|
||||
|
||||
DIAG_DIR = os.path.join(DATA_ROOT, "diagnostics")
|
||||
LOG_TAIL_LINES = 200
|
||||
MAX_ATTACHMENTS = 6
|
||||
MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024
|
||||
# Key-shaped strings never belong in a shareable report, even from free-text log lines.
|
||||
P_SECRET_RE = re.compile(r"(sk-[A-Za-z0-9\-]{8,}|Bearer\s+\S+|api[_-]?key[\"']?\s*[:=]\s*\S+)", re.IGNORECASE)
|
||||
|
||||
|
||||
class BundleAttachment(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
name: str
|
||||
data_b64: str
|
||||
|
||||
|
||||
class BundleRequest(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
kind: str = "bug"
|
||||
description: str = ""
|
||||
attachments: List[BundleAttachment] = Field(default_factory=list)
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_safe_name(name: str) -> str:
|
||||
base = os.path.basename(name or "attachment")
|
||||
return re.sub(r"[^A-Za-z0-9._-]", "_", base)[:80] or "attachment"
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_scrub(text: str) -> str:
|
||||
return P_SECRET_RE.sub("[redacted]", text)
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_log_tail() -> str:
|
||||
"""Last lines of the backend log when packaged (Electron writes backend.log next to the data
|
||||
root); dev runs log to the terminal, so a missing file just yields an honest note."""
|
||||
candidates = [
|
||||
os.path.join(os.path.dirname(DATA_ROOT), "backend.log"),
|
||||
os.path.join(DATA_ROOT, "backend.log"),
|
||||
]
|
||||
for p in candidates:
|
||||
try:
|
||||
if os.path.isfile(p):
|
||||
with open(p, "r", errors="replace") as fh:
|
||||
lines = fh.readlines()[-LOG_TAIL_LINES:]
|
||||
return p_scrub("".join(lines))
|
||||
except Exception:
|
||||
continue
|
||||
return "(no backend.log found; dev runs log to the terminal)"
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_count_dir(path: str) -> int:
|
||||
try:
|
||||
return len(os.listdir(path))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_build_report(req: BundleRequest) -> str:
|
||||
from backend.apps.settings.store import load_settings
|
||||
|
||||
s = load_settings()
|
||||
provider_kinds: List[str] = []
|
||||
if getattr(s, "anthropic_api_key", None):
|
||||
provider_kinds.append("anthropic-key")
|
||||
if getattr(s, "openai_api_key", None):
|
||||
provider_kinds.append("openai-key")
|
||||
if getattr(s, "free_trial_token", None):
|
||||
provider_kinds.append("free-trial")
|
||||
facts = {
|
||||
"kind": req.kind,
|
||||
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"app_version": os.environ.get("OPENSWARM_APP_VERSION", "dev"),
|
||||
"platform": f"{platform.system()} {platform.release()} ({platform.machine()})",
|
||||
"python": sys.version.split()[0],
|
||||
"packaged": os.environ.get("OPENSWARM_PACKAGED") == "1",
|
||||
"installation_id": getattr(s, "installation_id", None),
|
||||
"user_email": getattr(s, "user_email", None),
|
||||
"signin_method": getattr(s, "signin_method", None),
|
||||
"default_model": getattr(s, "default_model", None),
|
||||
"connection_mode": getattr(s, "connection_mode", None),
|
||||
"provider_kinds": provider_kinds,
|
||||
"session_count": p_count_dir(SESSIONS_DIR),
|
||||
"theme": getattr(s, "theme", None),
|
||||
}
|
||||
lines = [
|
||||
f"# OpenSwarm {('bug report' if req.kind == 'bug' else 'feature request')}",
|
||||
"",
|
||||
"## What the user reported",
|
||||
req.description.strip() or "(no description)",
|
||||
"",
|
||||
"## Environment",
|
||||
"```json",
|
||||
json.dumps(facts, indent=2, default=str),
|
||||
"```",
|
||||
"",
|
||||
"## Recent backend log",
|
||||
"```",
|
||||
p_log_tail(),
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@help_app.router.post("/bundle")
|
||||
@typechecked
|
||||
async def build_bundle(body: BundleRequest) -> dict:
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
folder = os.path.join(DIAG_DIR, f"report-{stamp}")
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
report_path = os.path.join(folder, "diagnostic-report.md")
|
||||
with open(report_path, "w") as fh:
|
||||
fh.write(p_build_report(body))
|
||||
saved: List[str] = []
|
||||
for att in body.attachments[:MAX_ATTACHMENTS]:
|
||||
try:
|
||||
raw = base64.b64decode(att.data_b64)
|
||||
if len(raw) > MAX_ATTACHMENT_BYTES:
|
||||
continue
|
||||
dest = os.path.join(folder, p_safe_name(att.name))
|
||||
with open(dest, "wb") as fh:
|
||||
fh.write(raw)
|
||||
saved.append(os.path.basename(dest))
|
||||
except Exception:
|
||||
continue
|
||||
return {"folder": folder, "report": report_path, "attachments": saved}
|
||||
+2
-1
@@ -44,6 +44,7 @@ from backend.apps.auth.router import auth
|
||||
from backend.apps.web.web import web
|
||||
from backend.apps.onboarding.onboarding import onboarding
|
||||
from backend.apps.voice.polish import voice
|
||||
from backend.apps.help.bundle import help_app
|
||||
from backend.apps.agents.proxy.anthropic_proxy import anthropic_proxy
|
||||
from backend.apps.agents.core.openai_passthrough import openai_passthrough
|
||||
from backend.apps.workflows.workflows import workflows
|
||||
@@ -51,7 +52,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
import json
|
||||
|
||||
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, onboarding, voice, anthropic_proxy, workflows, openai_passthrough])
|
||||
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, onboarding, voice, help_app, anthropic_proxy, workflows, openai_passthrough])
|
||||
app = main_app.app
|
||||
|
||||
# Generate per-install auth token BEFORE we bind the HTTP port. By the time any request lands, the token file exists. See backend/auth.py.
|
||||
|
||||
@@ -2974,6 +2974,19 @@ ipcMain.handle('get-webview-preload-path', () => {
|
||||
return `file://${path.join(__dirname, 'webview-preload.js')}`;
|
||||
});
|
||||
|
||||
// Reveal a diagnostics bundle in the file manager so the user can drag it into a GitHub issue.
|
||||
// Scoped HARD to the backend's diagnostics dir: this must never become an arbitrary-path opener.
|
||||
ipcMain.handle('help:reveal-bundle', (event, folderPath) => {
|
||||
try {
|
||||
const p = path.resolve(String(folderPath || ''));
|
||||
if (!p.includes(`${path.sep}diagnostics${path.sep}`) || !fs.existsSync(p)) return { ok: false };
|
||||
shell.showItemInFolder(p);
|
||||
return { ok: true };
|
||||
} catch (_) {
|
||||
return { ok: false };
|
||||
}
|
||||
});
|
||||
|
||||
// Wipe ONLY the browser-card partition (cookies/cache/localStorage/IndexedDB), never the app's defaultSession. Surfaced as Settings -> Data & Privacy -> Clear browsing data.
|
||||
ipcMain.handle('browser:clear-data', async () => {
|
||||
const ses = session.fromPartition(BROWSER_PARTITION);
|
||||
|
||||
@@ -73,6 +73,8 @@ contextBridge.exposeInMainWorld('openswarm', {
|
||||
ipcRenderer.on('voice:toggle', listener);
|
||||
return () => ipcRenderer.removeListener('voice:toggle', listener);
|
||||
},
|
||||
// Reveal a diagnostics folder in Finder/Explorer (path validated in main; diagnostics dir only).
|
||||
revealBundle: (folderPath) => ipcRenderer.invoke('help:reveal-bundle', folderPath),
|
||||
// Main-process hold relay (before-input-event): fires down/up for the combo regardless of DOM focus.
|
||||
onVoiceHold: (onDown, onUp) => {
|
||||
const down = () => onDown();
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
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 ForumOutlinedIcon from '@mui/icons-material/ForumOutlined';
|
||||
import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded';
|
||||
import ArrowOutwardRoundedIcon from '@mui/icons-material/ArrowOutwardRounded';
|
||||
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
|
||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||
@@ -13,9 +16,17 @@ 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';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const REPO_ISSUES_URL = 'https://github.com/openswarm-ai/openswarm/issues/new';
|
||||
const DOCS_URL = 'https://docs.openswarm.com';
|
||||
const DISCORD_URL = 'https://discord.com/channels/1486442924391796896/1486442927554170892';
|
||||
|
||||
const WHATS_NEW: Array<{ text: string }> = [
|
||||
{ text: 'Dictation lands where your cursor is, with AI cleanup' },
|
||||
{ text: 'Sign in keeps your setup tied to your account' },
|
||||
{ text: 'Text size setting + a cleaner canvas composer' },
|
||||
];
|
||||
|
||||
function openExternal(url: string): void {
|
||||
const api = (window as unknown as { openswarm?: { openExternal?: (u: string) => void } }).openswarm;
|
||||
@@ -23,12 +34,21 @@ function openExternal(url: string): void {
|
||||
else window.open(url, '_blank');
|
||||
}
|
||||
|
||||
async function fileToB64(f: File): Promise<string> {
|
||||
const buf = await f.arrayBuffer();
|
||||
let bin = '';
|
||||
const bytes = new Uint8Array(buf);
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
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.
|
||||
// The Help panel: Linear/Raycast-style popover off the Help pill. Ask leads (starts a real chat),
|
||||
// then report-a-bug / request-a-feature: the local backend assembles a diagnostics bundle (redacted
|
||||
// env facts + log tail + the user's screenshots) into one folder, we reveal it in the file manager,
|
||||
// and open a PREFILLED GitHub issue for them to drag the folder's files into. Nothing uploads by
|
||||
// itself and no secret can ride along (the bundle is allowlisted server-side). Then community + docs.
|
||||
const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const model = useAppSelector((s) => s.settings.data.default_model);
|
||||
@@ -36,6 +56,9 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
const [pane, setPane] = useState<Pane>('root');
|
||||
const [ask, setAsk] = useState('');
|
||||
const [reportText, setReportText] = useState('');
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [sending, setSending] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const startChat = useCallback((prompt: string): void => {
|
||||
const p = prompt.trim();
|
||||
@@ -47,22 +70,46 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
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 submitReport = useCallback(async (kind: 'bug' | 'idea'): Promise<void> => {
|
||||
if (sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
// 1. Local diagnostics bundle (report.md + the user's files), revealed for drag-into-the-issue.
|
||||
const attachments = await Promise.all(files.slice(0, 6).map(async (f) => ({ name: f.name, data_b64: await fileToB64(f) })));
|
||||
const res = await fetch(`${API_BASE}/help/bundle`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind, description: reportText, attachments }),
|
||||
});
|
||||
let folder: string | null = null;
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { folder?: string };
|
||||
folder = data.folder ?? null;
|
||||
}
|
||||
// 2. Prefilled GitHub issue; the body points at the revealed bundle so nothing gets lost.
|
||||
const bodyLines = [
|
||||
reportText.trim(),
|
||||
'',
|
||||
'---',
|
||||
`App version: ${appVersion ?? 'dev'}`,
|
||||
`Platform: ${navigator.platform}`,
|
||||
folder ? 'Diagnostics: drag the files from the folder OpenSwarm just revealed into this issue.' : '',
|
||||
].filter(Boolean);
|
||||
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: bodyLines.join('\n'),
|
||||
});
|
||||
openExternal(`${REPO_ISSUES_URL}?${params.toString()}`);
|
||||
if (folder) {
|
||||
const api = (window as unknown as { openswarm?: { revealBundle?: (p: string) => Promise<unknown> } }).openswarm;
|
||||
void api?.revealBundle?.(folder);
|
||||
}
|
||||
onClose();
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [sending, files, reportText, appVersion, onClose]);
|
||||
|
||||
const rowSx = {
|
||||
display: 'flex', alignItems: 'center', gap: 1.25, width: '100%',
|
||||
@@ -86,7 +133,7 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
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,
|
||||
position: 'absolute', top: 'calc(100% + 8px)', right: 0, width: 324, 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',
|
||||
@@ -116,15 +163,15 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box component="button" onClick={() => { setReportText(''); setPane('bug'); }} sx={rowSx}>
|
||||
<Box component="button" onClick={() => { setReportText(''); setFiles([]); 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>
|
||||
<Typography sx={{ fontSize: '0.6875rem', color: 'rgba(255,255,255,0.45)' }}>Diagnostics packaged automatically</Typography>
|
||||
</Box>
|
||||
<ChevronRightRoundedIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.4)' }} />
|
||||
</Box>
|
||||
<Box component="button" onClick={() => { setReportText(''); setPane('idea'); }} sx={rowSx}>
|
||||
<Box component="button" onClick={() => { setReportText(''); setFiles([]); setPane('idea'); }} sx={rowSx}>
|
||||
<LightbulbOutlinedIcon sx={iconSx} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500 }}>Request a feature</Typography>
|
||||
@@ -132,11 +179,31 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
</Box>
|
||||
<ChevronRightRoundedIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.4)' }} />
|
||||
</Box>
|
||||
<Box component="button" onClick={() => { openExternal(DISCORD_URL); onClose(); }} sx={rowSx}>
|
||||
<ForumOutlinedIcon sx={iconSx} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500 }}>Talk to the team</Typography>
|
||||
<Typography sx={{ fontSize: '0.6875rem', color: 'rgba(255,255,255,0.45)' }}>Join the Discord</Typography>
|
||||
</Box>
|
||||
<ArrowOutwardRoundedIcon sx={{ fontSize: 14, 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 & shortcuts</Typography>
|
||||
<ArrowOutwardRoundedIcon sx={{ fontSize: 14, color: 'rgba(255,255,255,0.4)' }} />
|
||||
</Box>
|
||||
<Box sx={{ height: '1px', background: 'rgba(255,255,255,0.09)', mx: 1.25, my: 0.75 }} />
|
||||
<Box sx={{ px: 1.25, pb: 0.75 }}>
|
||||
<Typography sx={{ fontSize: '0.625rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.4)', mb: 0.5 }}>
|
||||
What's new{appVersion ? ` · v${appVersion}` : ''}
|
||||
</Typography>
|
||||
{WHATS_NEW.map((n) => (
|
||||
<Box key={n.text} sx={{ display: 'flex', gap: 0.9, alignItems: 'flex-start', my: 0.4 }}>
|
||||
<Box sx={{ width: 5, height: 5, borderRadius: '50%', background: '#4fdf9f', mt: '6px', flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.6875rem', color: 'rgba(255,255,255,0.65)', lineHeight: 1.45 }}>{n.text}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ p: 1.5 }}>
|
||||
@@ -153,22 +220,44 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
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>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,.txt,.log,.json,.md,.pdf"
|
||||
hidden
|
||||
onChange={(e) => { if (e.target.files) setFiles((prev) => [...prev, ...Array.from(e.target.files!)].slice(0, 6)); e.target.value = ''; }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.5, mt: 0.75 }}>
|
||||
<Box component="button" onClick={() => fileInputRef.current?.click()} sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, border: '1px dashed rgba(255,255,255,0.25)', background: 'transparent', color: 'rgba(255,255,255,0.65)', borderRadius: '8px', px: 1, py: 0.4, fontSize: '0.6875rem', cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<AttachFileRoundedIcon sx={{ fontSize: 12 }} /> Add screenshots or files
|
||||
</Box>
|
||||
{files.map((f, i) => (
|
||||
<Box key={`${f.name}-${i}`} component="button" onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))} title="Remove" sx={{ border: '1px solid rgba(255,255,255,0.16)', background: 'rgba(255,255,255,0.07)', color: 'rgba(255,255,255,0.75)', borderRadius: '8px', px: 0.9, py: 0.4, fontSize: '0.6875rem', cursor: 'pointer', fontFamily: 'inherit', maxWidth: 130, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{f.name}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{pane === 'bug' && (
|
||||
<Typography sx={{ fontSize: '0.6875rem', color: 'rgba(255,255,255,0.45)', mt: 0.75, lineHeight: 1.45 }}>
|
||||
We'll package a diagnostic report (your email, app version, recent activity, no keys or secrets) with your files, reveal it in Finder, and open a prefilled GitHub issue to drop it into.
|
||||
</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()}
|
||||
onClick={() => { void submitReport(pane === 'bug' ? 'bug' : 'idea'); }}
|
||||
disabled={!reportText.trim() || sending}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.75,
|
||||
border: 'none', borderRadius: '9px', px: 1.5, py: 0.6, fontFamily: 'inherit',
|
||||
fontSize: '0.8125rem', fontWeight: 600, cursor: reportText.trim() ? 'pointer' : 'default',
|
||||
fontSize: '0.8125rem', fontWeight: 600, cursor: reportText.trim() && !sending ? '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)',
|
||||
}}
|
||||
>
|
||||
{sending && <CircularProgress size={12} thickness={6} sx={{ color: '#1c1b19' }} />}
|
||||
Continue on GitHub
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user