feat: add troubleshooting settings with open app data and reset workspace

Add a Troubleshooting section under a Support nav group in Settings:

- Open App Data Folder: reveals the user-data directory in the OS file
  explorer via shell.openPath.
- Reset Local Workspace: destructive action that deletes workspace.json
  and scratchpad contents, clears the in-memory cache, and reseeds
  defaults. Auth secrets are preserved. Requires two-step confirmation.

New IPC channels, ElectronApi methods, and preload bridge added for
openAppDataFolder and resetLocalWorkspace. Existing electron mock in
tests updated to include shell.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-25 21:50:16 +01:00
co-authored by Copilot
parent 5123f9163c
commit 2e3175934d
8 changed files with 168 additions and 5 deletions
+25 -2
View File
@@ -1,7 +1,8 @@
import { EventEmitter } from 'node:events';
import { basename } from 'node:path';
import { rm } from 'node:fs/promises';
import { basename, dirname } from 'node:path';
import { dialog } from 'electron';
import { dialog, shell } from 'electron';
import type {
AgentActivityEvent,
@@ -159,6 +160,28 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
void this.secretStore;
}
async openAppDataFolder(): Promise<void> {
const appDataPath = dirname(this.workspaceRepository.filePath);
await shell.openPath(appDataPath);
}
async resetLocalWorkspace(): Promise<WorkspaceState> {
await this.sidecar.dispose();
try {
await rm(this.workspaceRepository.filePath, { force: true });
await rm(this.workspaceRepository.scratchpadPath, { recursive: true, force: true });
} catch (error) {
console.error('[aryx reset]', error);
}
this.workspace = undefined;
this.sidecarCapabilities = undefined;
this.didScheduleInitialProjectGitRefresh = false;
return this.loadWorkspace();
}
async addProject(): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const result = await dialog.showOpenDialog({
+2
View File
@@ -96,6 +96,8 @@ export function registerIpcHandlers(window: BrowserWindow, service: EryxAppServi
ipcMain.handle(ipcChannels.selectProject, (_event, projectId?: string) => service.selectProject(projectId));
ipcMain.handle(ipcChannels.selectPattern, (_event, patternId?: string) => service.selectPattern(patternId));
ipcMain.handle(ipcChannels.selectSession, (_event, sessionId?: string) => service.selectSession(sessionId));
ipcMain.handle(ipcChannels.openAppDataFolder, () => service.openAppDataFolder());
ipcMain.handle(ipcChannels.resetLocalWorkspace, () => service.resetLocalWorkspace());
service.on('workspace-updated', (workspace) => {
window.webContents.send(ipcChannels.workspaceUpdated, workspace);
+3 -1
View File
@@ -34,7 +34,9 @@ const api: ElectronApi = {
selectProject: (projectId) => ipcRenderer.invoke(ipcChannels.selectProject, projectId),
selectPattern: (patternId) => ipcRenderer.invoke(ipcChannels.selectPattern, patternId),
selectSession: (sessionId) => ipcRenderer.invoke(ipcChannels.selectSession, sessionId),
onWorkspaceUpdated: (listener) => {
openAppDataFolder: () => ipcRenderer.invoke(ipcChannels.openAppDataFolder),
resetLocalWorkspace: () => ipcRenderer.invoke(ipcChannels.resetLocalWorkspace),
onWorkspaceUpdated:(listener) => {
const handler = (_event: Electron.IpcRendererEvent, workspace: Awaited<ReturnType<ElectronApi['loadWorkspace']>>) =>
listener(workspace);
+4
View File
@@ -306,6 +306,10 @@ export default function App() {
await api.savePattern({ pattern });
}}
onSetTheme={(theme) => void api.setTheme(theme)}
onOpenAppDataFolder={() => void api.openAppDataFolder()}
onResetLocalWorkspace={async () => {
await api.resetLocalWorkspace();
}}
patterns={workspace.patterns}
sidecarCapabilities={sidecarCapabilities}
theme={workspace.settings.theme}
+127 -2
View File
@@ -1,5 +1,5 @@
import { useState, type ReactNode } from 'react';
import { ChevronLeft, ChevronRight, Code, Cpu, Palette, Plus, Server, Workflow } from 'lucide-react';
import { ChevronLeft, ChevronRight, Code, Cpu, FolderOpen, Palette, Plus, Server, TriangleAlert, Workflow, Wrench } from 'lucide-react';
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
import { PatternEditor } from '@renderer/components/PatternEditor';
@@ -36,9 +36,11 @@ interface SettingsPanelProps {
onDeleteLspProfile: (profileId: string) => Promise<void>;
onNewLspProfile: () => LspProfileDefinition;
onSetTheme: (theme: AppearanceTheme) => void;
onOpenAppDataFolder: () => void;
onResetLocalWorkspace: () => Promise<void>;
}
type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles';
type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
interface NavItem {
id: SettingsSection;
@@ -77,6 +79,12 @@ const navGroups: NavGroup[] = [
{ id: 'lsp-profiles', label: 'LSP Profiles', icon: <Code className="size-3.5" /> },
],
},
{
label: 'Support',
items: [
{ id: 'troubleshooting', label: 'Troubleshooting', icon: <Wrench className="size-3.5" /> },
],
},
];
function modeBadgeClasses(pattern: PatternDefinition) {
@@ -103,6 +111,8 @@ export function SettingsPanel({
onDeleteLspProfile,
onNewLspProfile,
onSetTheme,
onOpenAppDataFolder,
onResetLocalWorkspace,
}: SettingsPanelProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('appearance');
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
@@ -268,6 +278,12 @@ export function SettingsPanel({
profiles={toolingSettings.lspProfiles}
/>
)}
{activeSection === 'troubleshooting' && (
<TroubleshootingSection
onOpenAppDataFolder={onOpenAppDataFolder}
onResetLocalWorkspace={onResetLocalWorkspace}
/>
)}
</div>
</div>
</div>
@@ -562,3 +578,112 @@ function EmptyState({ children }: { children: ReactNode }) {
</div>
);
}
function TroubleshootingSection({
onOpenAppDataFolder,
onResetLocalWorkspace,
}: {
onOpenAppDataFolder: () => void;
onResetLocalWorkspace: () => Promise<void>;
}) {
const [isResetting, setIsResetting] = useState(false);
const [confirmingReset, setConfirmingReset] = useState(false);
async function handleReset() {
setIsResetting(true);
try {
await onResetLocalWorkspace();
} finally {
setIsResetting(false);
setConfirmingReset(false);
}
}
return (
<div>
<SectionHeader
description="Diagnose issues and manage local application data"
title="Troubleshooting"
/>
<div className="space-y-2">
<TroubleshootingAction
description="Reveal the folder where Aryx stores workspace data, scratchpad files, and configuration."
icon={<FolderOpen className="size-4" />}
label="Open App Data Folder"
onClick={onOpenAppDataFolder}
/>
</div>
<div className="mt-8 rounded-xl border border-red-500/20 bg-red-500/5 p-5">
<div className="flex items-start gap-3">
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-red-400" />
<div className="min-w-0 flex-1">
<h4 className="text-[13px] font-semibold text-red-300">Reset Local Workspace</h4>
<p className="mt-1 text-[12px] leading-relaxed text-zinc-400">
Restore Aryx to its initial state. This permanently removes all sessions, custom patterns,
MCP server definitions, LSP profiles, and scratchpad contents. Your GitHub Copilot sign-in
is not affected.
</p>
{!confirmingReset ? (
<button
className="mt-3 rounded-lg border border-red-500/30 bg-red-500/10 px-3.5 py-1.5 text-[13px] font-medium text-red-300 transition hover:border-red-500/50 hover:bg-red-500/20"
onClick={() => setConfirmingReset(true)}
type="button"
>
Reset workspace
</button>
) : (
<div className="mt-3 flex items-center gap-2">
<button
className="rounded-lg bg-red-600 px-3.5 py-1.5 text-[13px] font-medium text-white transition hover:bg-red-500 disabled:opacity-50"
disabled={isResetting}
onClick={() => void handleReset()}
type="button"
>
{isResetting ? 'Resetting…' : 'Confirm reset'}
</button>
<button
className="rounded-lg border border-[var(--color-border)] px-3.5 py-1.5 text-[13px] font-medium text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
disabled={isResetting}
onClick={() => setConfirmingReset(false)}
type="button"
>
Cancel
</button>
</div>
)}
</div>
</div>
</div>
</div>
);
}
function TroubleshootingAction({
icon,
label,
description,
onClick,
}: {
icon: ReactNode;
label: string;
description: string;
onClick: () => void;
}) {
return (
<button
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition hover:border-zinc-800 hover:bg-zinc-900"
onClick={onClick}
type="button"
>
<span className="text-zinc-500 transition group-hover:text-zinc-300">{icon}</span>
<div className="min-w-0 flex-1">
<span className="text-[13px] font-medium text-zinc-200">{label}</span>
<p className="mt-0.5 text-[12px] text-zinc-500">{description}</p>
</div>
<ChevronRight className="size-4 text-zinc-700 transition group-hover:text-zinc-500" />
</button>
);
}
+2
View File
@@ -27,6 +27,8 @@ export const ipcChannels = {
selectProject: 'selection:project',
selectPattern: 'selection:pattern',
selectSession: 'selection:session',
openAppDataFolder: 'troubleshooting:open-app-data-folder',
resetLocalWorkspace: 'troubleshooting:reset-local-workspace',
workspaceUpdated: 'workspace:updated',
sessionEvent: 'sessions:event',
} as const;
+2
View File
@@ -108,6 +108,8 @@ export interface ElectronApi {
selectSession(sessionId?: string): Promise<WorkspaceState>;
setPatternFavorite(input: SetPatternFavoriteInput): Promise<WorkspaceState>;
setTheme(theme: AppearanceTheme): Promise<WorkspaceState>;
openAppDataFolder(): Promise<void>;
resetLocalWorkspace(): Promise<WorkspaceState>;
onWorkspaceUpdated(listener: (workspace: WorkspaceState) => void): () => void;
onSessionEvent(listener: (event: SessionEventRecord) => void): () => void;
}
+3
View File
@@ -18,6 +18,9 @@ mock.module('electron', () => ({
dialog: {
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
},
shell: {
openPath: async () => '',
},
}));
mock.module('keytar', () => ({