Compare commits

..
7 Commits
Author SHA1 Message Date
David KayaandCopilot 41289c960b fix: enable update checks in dev mode via forceDevUpdateConfig
Add dev-app-update.yml so electron-updater can check GitHub releases
even when running with bun run dev. Remove the isPackaged guard from
checkForUpdates() so manual clicks always contact the update server.
Automatic periodic checks remain disabled in dev mode.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 10:24:07 +02:00
David KayaandCopilot 33b293271e fix: add up-to-date state so Check for updates gives visible feedback
When electron-updater reports no update available, the service now
transitions to 'up-to-date' instead of reverting silently to 'idle'.
The troubleshooting UI shows a green check icon, 'Up to date' label,
and 'You are running the latest version of Aryx.' description.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 10:20:05 +02:00
David KayaandCopilot cbcf239a0a fix: MCP group toggle styling and server-level approval UX
Two fixes in the auto-approval pill:

1. GroupToggle now uses brand-gradient-bg with glow shadow and matches
   ToggleSwitch sm dimensions, making MCP group toggles visually
   consistent with individual tool toggles.

2. Individual tool toggles now reflect server-level approval state.
   When a server key is approved, all tool rows show as enabled.
   Toggling a single tool OFF in a server-approved group expands the
   server key to individual tool IDs minus the excluded tool, enabling
   the 'approve all → disable one specific tool' workflow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 10:14:13 +02:00
David KayaandCopilot c702cf88e2 fix: approval pill count mismatch with duplicate MCP tool names
When multiple MCP servers expose tools with the same name, the approval
pill showed an incorrect count (e.g. 150/300) even when everything was
approved.  The numerator used a Set<string> to deduplicate by tool ID,
so shared tools were counted once, while the denominator counted each
group occurrence independently.

Extract countApprovedToolsInGroups() into shared/domain/tooling.ts and
switch to per-group counting that mirrors the totalItemCount formula.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 10:07:41 +02:00
David KayaandCopilot 8a4d23c22a feat: add Enable all / Approve all buttons to inline pills
Add bulk action buttons to the Tools and Auto-approval pill popovers:

- Tools pill: sticky header with Enable all / Disable all toggle button
  that enables or disables every MCP server and LSP profile at once
- Approval pill: Approve all / Unapprove all button in the existing
  sticky header next to the Reset button, approving or clearing all
  tool and server approval keys

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 09:49:20 +02:00
David KayaandCopilot 042cec6065 fix: resolve hook permissions to proper categories for approval
When the pre-tool-use hook returns 'ask', the Copilot CLI creates
PermissionRequestHook instead of categorized PermissionRequestRead/
Write/Shell. This caused 'Permission: hook' labels and broke category-
based auto-approval ('Always approve read' wouldn't cover grep/glob).

Add ResolveHookToolCategory mapping in CopilotApprovalCoordinator to
map known tool names (view/grep/glob→read, edit/create→write, etc.)
to their permission categories. Wire into GetFallbackToolName,
BuildPermissionApprovalEvent, and CreateApprovalPolicyOutput so:
- Approval banner shows 'Permission: read' instead of 'Permission: hook'
- 'Always approve' stores the category key, covering all tools in it
- Hook short-circuits when category is already auto-approved
Unknown tools (MCP, custom) keep existing 'hook' behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 09:41:51 +02:00
David KayaandCopilot 39fee48c0b feat: polish troubleshooting page and fix timeline icons
- Add 'Built with ❤ by Dávid Kaya' attribution footer to the
  troubleshooting settings page, matching the website footer
- Add 'Check for updates' action to troubleshooting, wired to the
  existing auto-updater IPC with live status feedback (checking,
  available, downloading, downloaded, error states)
- Fix timeline event icons: increase node circle from 15px to 18px,
  shrink icons from 14px to 10px for proper padding, and force white
  icon color on running-state gradient background to eliminate the
  purple-circle overlay clash

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 09:04:26 +02:00
14 changed files with 685 additions and 98 deletions
+4
View File
@@ -0,0 +1,4 @@
provider: github
owner: davidkaya
repo: aryx
releaseType: release
@@ -21,6 +21,24 @@ internal sealed class CopilotApprovalCoordinator
private const string HookPermissionKind = "hook";
private const string ToolCallingActivityType = "tool-calling";
private static readonly Dictionary<string, string> HookToolCategories = new(StringComparer.OrdinalIgnoreCase)
{
["view"] = ReadPermissionKind,
["glob"] = ReadPermissionKind,
["grep"] = ReadPermissionKind,
["lsp"] = ReadPermissionKind,
["edit"] = WritePermissionKind,
["create"] = WritePermissionKind,
["powershell"] = ShellPermissionKind,
["read_powershell"] = ShellPermissionKind,
["write_powershell"] = ShellPermissionKind,
["stop_powershell"] = ShellPermissionKind,
["list_powershell"] = ShellPermissionKind,
["web_fetch"] = UrlPermissionKind,
["web_search"] = UrlPermissionKind,
["store_memory"] = MemoryPermissionKind,
};
private readonly ConcurrentDictionary<string, PendingApprovalRequest> _pendingApprovals = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _requestApprovedTools = new(StringComparer.Ordinal);
@@ -140,6 +158,16 @@ internal sealed class CopilotApprovalCoordinator
string permissionKind = string.IsNullOrWhiteSpace(request.Kind)
? "tool access"
: request.Kind.Trim();
if (request is PermissionRequestHook hook)
{
string? resolvedCategory = ResolveHookToolCategory(hook.ToolName);
if (resolvedCategory is not null)
{
permissionKind = resolvedCategory;
}
}
string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
string? sessionId = NormalizeOptionalString(invocation.SessionId);
string? normalizedToolName = NormalizeOptionalString(toolName);
@@ -476,10 +504,22 @@ internal sealed class CopilotApprovalCoordinator
PermissionRequestWrite => WritePermissionKind,
PermissionRequestRead => ReadPermissionKind,
PermissionRequestMemory => StoreMemoryToolName,
PermissionRequestHook hook => ResolveHookToolCategory(hook.ToolName),
_ => null,
};
}
internal static string? ResolveHookToolCategory(string? toolName)
{
string? normalized = NormalizeOptionalString(toolName);
if (normalized is null)
{
return null;
}
return HookToolCategories.TryGetValue(normalized, out string? category) ? category : null;
}
private static bool MatchesAutoApprovedTool(
IReadOnlyList<string> autoApprovedToolNames,
string? toolName,
@@ -248,11 +248,13 @@ internal static class CopilotSessionHooks
};
}
string? autoApprovedToolName = CopilotApprovalCoordinator.ResolveHookToolCategory(toolName) ?? toolName;
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
command.Pattern.ApprovalPolicy,
agentDefinition.Id,
toolName,
toolName);
autoApprovedToolName);
return new PreToolUseHookOutput
{
@@ -151,6 +151,26 @@ public sealed class CopilotSessionHooksTests
Assert.Equal("ask", decision?.PermissionDecision);
}
[Theory]
[InlineData("view", "read")]
[InlineData("grep", "read")]
[InlineData("edit", "write")]
[InlineData("powershell", "shell")]
public async Task Create_PreToolUseAutoAllowsWhenCategoryIsApproved(string toolName, string category)
{
RunTurnCommandDto command = CreateCommandWithAutoApprovedCategory(category);
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
{
ToolName = toolName,
},
null!);
Assert.Equal("allow", decision?.PermissionDecision);
}
[Fact]
public async Task Create_RunsConfiguredNonPreToolHooks()
{
@@ -309,6 +329,45 @@ public sealed class CopilotSessionHooksTests
};
}
private static RunTurnCommandDto CreateCommandWithAutoApprovedCategory(string category)
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
ProjectPath = @"C:\workspace\project",
Pattern = new PatternDefinitionDto
{
Id = "pattern-1",
Name = "Pattern",
Mode = "single",
Availability = "available",
ApprovalPolicy = new ApprovalPolicyDto
{
Rules =
[
new ApprovalCheckpointRuleDto
{
Kind = "tool-call",
AgentIds = ["agent-1"],
},
],
AutoApprovedToolNames = [category],
},
Agents =
[
new PatternAgentDefinitionDto
{
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help.",
},
],
},
};
}
private static HookCommandDefinition CreateHookCommand(string name)
=> new()
{
@@ -1325,6 +1325,155 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("https://example.com", args["url"]);
}
[Theory]
[InlineData("view", "read")]
[InlineData("glob", "read")]
[InlineData("grep", "read")]
[InlineData("lsp", "read")]
[InlineData("edit", "write")]
[InlineData("create", "write")]
[InlineData("powershell", "shell")]
[InlineData("read_powershell", "shell")]
[InlineData("write_powershell", "shell")]
[InlineData("stop_powershell", "shell")]
[InlineData("list_powershell", "shell")]
[InlineData("web_fetch", "url")]
[InlineData("web_search", "url")]
[InlineData("store_memory", "memory")]
public void ResolveHookToolCategory_ReturnsExpectedCategoryForKnownTools(string toolName, string expectedCategory)
{
Assert.Equal(expectedCategory, CopilotApprovalCoordinator.ResolveHookToolCategory(toolName));
}
[Theory]
[InlineData("icm-mcp-get_on_call_schedule")]
[InlineData("custom_tool")]
[InlineData("unknown")]
public void ResolveHookToolCategory_ReturnsNullForUnknownTools(string toolName)
{
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(toolName));
}
[Fact]
public void ResolveHookToolCategory_ReturnsNullForNullOrEmpty()
{
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(null));
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(""));
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(" "));
}
[Fact]
public void TryGetApprovalToolName_ResolvesHookToolToCategory()
{
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestHook
{
Kind = "hook",
ToolName = "view",
ToolArgs = """{"path":"README.md"}""",
},
out string? toolName));
Assert.Equal("view", toolName);
// But the auto-approved name (fallback) resolves to the category
PermissionRequestHook hookRequest = new()
{
Kind = "hook",
ToolName = "view",
ToolArgs = """{"path":"README.md"}""",
};
// Verify GetFallbackToolName returns category via ResolveAutoApprovedToolName path
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
hookRequest,
out _));
}
[Fact]
public void RequiresToolCallApproval_HonorsHookToolCategoryForAutoApproval()
{
ApprovalPolicyDto policy = new()
{
Rules =
[
new ApprovalCheckpointRuleDto
{
Kind = "tool-call",
AgentIds = ["agent-1"],
},
],
AutoApprovedToolNames = ["read"],
};
// "view" is a hook tool that maps to "read" category — should be auto-approved
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(
policy, "agent-1", "view", "read"));
// "grep" also maps to "read"
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(
policy, "agent-1", "grep", "read"));
// "edit" maps to "write" — not auto-approved
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(
policy, "agent-1", "edit", "write"));
}
[Fact]
public void BuildPermissionApprovalEvent_UsesResolvedCategoryForHookPermissionKind()
{
ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent(
new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
},
CreateAgent("agent-1", "Primary"),
new PermissionRequestHook
{
Kind = "hook",
ToolName = "view",
ToolArgs = """{"path":"README.md"}""",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
"approval-1",
"view");
Assert.Equal("view", approvalEvent.ToolName);
Assert.Equal("read", approvalEvent.PermissionKind);
Assert.Contains("read permission", approvalEvent.Detail);
}
[Fact]
public void BuildPermissionApprovalEvent_KeepsHookKindForUnknownHookTools()
{
ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent(
new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
},
CreateAgent("agent-1", "Primary"),
new PermissionRequestHook
{
Kind = "hook",
ToolName = "icm-mcp-get_schedule",
ToolArgs = """{"teamIds":[91982]}""",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
"approval-1",
"icm-mcp-get_schedule");
Assert.Equal("hook", approvalEvent.PermissionKind);
}
[Fact]
public async Task RequestApprovalAsync_RaisesApprovalAndCompletesAfterResolution()
{
+3 -5
View File
@@ -23,6 +23,7 @@ type AutoUpdateListener = (...args: any[]) => void;
interface AutoUpdaterLike {
autoDownload: boolean;
autoInstallOnAppQuit: boolean;
forceDevUpdateConfig: boolean;
on(event: string, listener: AutoUpdateListener): this;
removeListener(event: string, listener: AutoUpdateListener): this;
checkForUpdates(): Promise<unknown>;
@@ -151,7 +152,7 @@ export class AutoUpdateService {
};
private readonly notAvailableListener = () => {
this.publishStatus({ state: 'idle' });
this.publishStatus({ state: 'up-to-date' });
};
private readonly progressListener = (progress: AutoUpdateProgressLike) => {
@@ -180,6 +181,7 @@ export class AutoUpdateService {
this.scheduler = options.scheduler ?? defaultScheduler;
this.updater.autoDownload = true;
this.updater.autoInstallOnAppQuit = false;
this.updater.forceDevUpdateConfig = !options.isPackaged;
this.updater.on('checking-for-update', this.checkingListener);
this.updater.on('update-available', this.availableListener);
@@ -215,10 +217,6 @@ export class AutoUpdateService {
}
async checkForUpdates(): Promise<UpdateStatus> {
if (!this.options.isPackaged) {
return this.getStatus();
}
if (this.pendingCheck) {
return this.pendingCheck;
}
+2 -11
View File
@@ -30,6 +30,7 @@ import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pat
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import { resolveSessionToolingSelection, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
import {
countApprovedToolsInGroups,
groupApprovalToolsByProvider,
listApprovalToolDefinitions,
type RuntimeToolDefinition,
@@ -162,17 +163,7 @@ export function ChatPane({
);
const effectiveAutoApprovedCount = useMemo(() => {
const groups = groupApprovalToolsByProvider(approvalTools, toolingSettings);
const counted = new Set<string>();
for (const group of groups) {
if (group.serverApprovalKey && effectiveAutoApproved.has(group.serverApprovalKey)) {
for (const tool of group.tools) counted.add(tool.id);
} else {
for (const tool of group.tools) {
if (effectiveAutoApproved.has(tool.id)) counted.add(tool.id);
}
}
}
return counted.size;
return countApprovedToolsInGroups(groups, effectiveAutoApproved);
}, [approvalTools, effectiveAutoApproved, toolingSettings]);
const isProbingMcp = (mcpProbingServerIds?.length ?? 0) > 0;
const hasApprovalContent = approvalTools.length > 0 || isProbingMcp;
+26 -9
View File
@@ -51,20 +51,37 @@ const runStatusStyles: Record<SessionRunRecord['status'], { icon: ReactNode; cla
/* ── Event node icon ───────────────────────────────────────── */
function EventIcon({ kind, status }: { kind: RunTimelineEventRecord['kind']; status: RunTimelineEventRecord['status'] }) {
const base = 'size-3.5';
const isRunning = status === 'running';
const base = 'size-2.5';
// Running events use white icons to contrast with the brand-gradient circle
if (isRunning) {
const pulse = 'animate-pulse';
switch (kind) {
case 'thinking':
return <Brain className={`${base} ${pulse} text-white`} />;
case 'approval':
return <AlertTriangle className={`${base} ${pulse} text-white`} />;
case 'message':
return <MessageSquare className={`${base} ${pulse} text-white`} />;
default:
return <Play className={`${base} text-white`} />;
}
}
switch (kind) {
case 'run-started':
return <Play className={`${base} text-[var(--color-text-muted)]`} />;
case 'thinking':
return <Brain className={`${base} ${status === 'running' ? 'text-[var(--color-accent-sky)] animate-pulse' : 'text-[var(--color-text-muted)]'}`} />;
return <Brain className={`${base} text-[var(--color-text-muted)]`} />;
case 'handoff':
return <ArrowRight className={`${base} text-[var(--color-status-warning)]`} />;
case 'tool-call':
return <Wrench className={`${base} text-[var(--color-accent-purple)]`} />;
case 'approval':
return <AlertTriangle className={`${base} ${status === 'running' ? 'text-[var(--color-status-warning)] animate-pulse' : status === 'error' ? 'text-[var(--color-status-error)]' : 'text-[var(--color-status-success)]'}`} />;
return <AlertTriangle className={`${base} ${status === 'error' ? 'text-[var(--color-status-error)]' : 'text-[var(--color-status-success)]'}`} />;
case 'message':
return <MessageSquare className={`${base} ${status === 'running' ? 'text-[var(--color-status-info)] animate-pulse' : status === 'error' ? 'text-[var(--color-status-error)]' : 'text-[var(--color-status-success)]'}`} />;
return <MessageSquare className={`${base} ${status === 'error' ? 'text-[var(--color-status-error)]' : 'text-[var(--color-status-success)]'}`} />;
case 'run-completed':
return <CheckCircle2 className={`${base} text-[var(--color-status-success)]`} />;
case 'run-cancelled':
@@ -95,7 +112,7 @@ function TimelineEventRow({
<div className="relative">
{/* Vertical connector line */}
{!isLast && (
<div className="absolute left-[7px] top-[22px] bottom-0 w-px bg-[var(--color-border)]" />
<div className="absolute left-[9px] top-[22px] bottom-0 w-px bg-[var(--color-border)]" />
)}
<button
@@ -106,7 +123,7 @@ function TimelineEventRow({
>
{/* Node */}
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
<div className={`flex size-[15px] items-center justify-center rounded-full ${event.status === 'running' ? 'brand-gradient-bg' : 'bg-[var(--color-surface-2)]'}`}>
<div className={`flex size-[18px] items-center justify-center rounded-full ${event.status === 'running' ? 'brand-gradient-bg' : 'bg-[var(--color-surface-2)]'}`}>
<EventIcon kind={event.kind} status={event.status} />
</div>
</div>
@@ -179,11 +196,11 @@ function ThinkingGroupRow({
return (
<div className="group relative flex w-full gap-2.5 py-1">
{!isLast && (
<div className="absolute left-[7px] top-[22px] bottom-0 w-px bg-[var(--color-border)]" />
<div className="absolute left-[9px] top-[22px] bottom-0 w-px bg-[var(--color-border)]" />
)}
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
<div className="flex size-[15px] items-center justify-center rounded-full bg-[var(--color-surface-2)]">
<Brain className="size-3.5 text-[var(--color-text-muted)]" />
<div className="flex size-[18px] items-center justify-center rounded-full bg-[var(--color-surface-2)]">
<Brain className="size-2.5 text-[var(--color-text-muted)]" />
</div>
</div>
<div className="min-w-0 flex-1">
+168 -53
View File
@@ -1,5 +1,5 @@
import { useState, type ReactNode } from 'react';
import { ChevronLeft, ChevronRight, Code, Cpu, FolderOpen, Palette, Plus, Server, TriangleAlert, Workflow, Wrench } from 'lucide-react';
import { useEffect, useState, type ReactNode } from 'react';
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, Palette, Plus, RefreshCw, Server, TriangleAlert, Workflow, Wrench } from 'lucide-react';
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
import { PatternEditor } from '@renderer/components/PatternEditor';
@@ -11,6 +11,7 @@ import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
import type { ModelDefinition } from '@shared/domain/models';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { UpdateStatus, UpdateStatusState } from '@shared/contracts/ipc';
import {
normalizeLspProfileDefinition,
normalizeMcpServerDefinition,
@@ -817,6 +818,30 @@ function TroubleshootingSection({
}) {
const [isResetting, setIsResetting] = useState(false);
const [confirmingReset, setConfirmingReset] = useState(false);
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({ state: 'idle' });
const [isCheckingManually, setIsCheckingManually] = useState(false);
useEffect(() => {
const unsubscribe = window.aryxApi.onUpdateStatus((status) => {
setUpdateStatus(status);
if (status.state !== 'checking') setIsCheckingManually(false);
});
return unsubscribe;
}, []);
async function handleCheckForUpdates() {
setIsCheckingManually(true);
try {
const status = await window.aryxApi.checkForUpdates();
setUpdateStatus(status);
} finally {
setIsCheckingManually(false);
}
}
async function handleInstallUpdate() {
await window.aryxApi.installUpdate();
}
async function handleReset() {
setIsResetting(true);
@@ -828,64 +853,154 @@ function TroubleshootingSection({
}
}
const isChecking = isCheckingManually || updateStatus.state === 'checking';
function getUpdateLabel(): string {
switch (updateStatus.state) {
case 'checking':
return 'Checking for updates…';
case 'up-to-date':
return 'Up to date';
case 'available':
return `Update available: v${updateStatus.version ?? 'unknown'}`;
case 'downloading':
return `Downloading update${updateStatus.downloadProgress ? ` (${Math.round(updateStatus.downloadProgress.percent)}%)` : '…'}`;
case 'downloaded':
return `Update ready: v${updateStatus.version ?? 'unknown'}`;
case 'error':
return 'Update check failed';
default:
return 'Check for updates';
}
}
function getUpdateDescription(): string {
switch (updateStatus.state) {
case 'checking':
return 'Contacting the update server…';
case 'up-to-date':
return 'You are running the latest version of Aryx.';
case 'available':
case 'downloading':
return 'A new version is being downloaded and will be installed automatically.';
case 'downloaded':
return 'Restart Aryx to apply the update.';
case 'error':
return updateStatus.error ?? 'Could not reach the update server. Try again later.';
default:
return 'Manually check whether a newer version of Aryx is available.';
}
}
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 className="flex min-h-full flex-col">
<div className="flex-1">
<SectionHeader
description="Diagnose issues and manage local application data"
title="Troubleshooting"
/>
</div>
<div className="mt-8 rounded-xl border border-[var(--color-status-error)]/20 bg-[var(--color-status-error)]/5 p-5">
<div className="flex items-start gap-3">
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-[var(--color-status-error)]" />
<div className="min-w-0 flex-1">
<h4 className="text-[13px] font-semibold text-[var(--color-status-error)]">Reset Local Workspace</h4>
<p className="mt-1 text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
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-[var(--color-status-error)]/30 bg-[var(--color-status-error)]/10 px-3.5 py-1.5 text-[13px] font-medium text-[var(--color-status-error)] transition-all duration-200 hover:border-[var(--color-status-error)]/50 hover:bg-[var(--color-status-error)]/20"
onClick={() => setConfirmingReset(true)}
type="button"
>
Reset workspace
</button>
) : (
<div className="mt-3 flex items-center gap-2">
<button
className="rounded-lg bg-[var(--color-status-error)] px-3.5 py-1.5 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-status-error)] 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-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
disabled={isResetting}
onClick={() => setConfirmingReset(false)}
type="button"
>
Cancel
</button>
<div className="space-y-2">
{/* Check for updates */}
{updateStatus.state === 'downloaded' ? (
<button
className="group flex w-full items-center gap-3 rounded-xl border border-[var(--color-status-success)]/20 bg-[var(--color-status-success)]/5 px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-status-success)]/40 hover:bg-[var(--color-status-success)]/10"
onClick={() => void handleInstallUpdate()}
type="button"
>
<span className="text-[var(--color-status-success)]">
<RefreshCw className="size-4" />
</span>
<div className="min-w-0 flex-1">
<span className="text-[13px] font-medium text-[var(--color-status-success)]">{getUpdateLabel()}</span>
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">{getUpdateDescription()}</p>
</div>
)}
<span className="rounded-lg bg-[var(--color-status-success)]/15 px-2.5 py-1 text-[11px] font-semibold text-[var(--color-status-success)]">
Restart
</span>
</button>
) : (
<button
className={`group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)] ${isChecking ? 'pointer-events-none opacity-70' : ''}`}
disabled={isChecking}
onClick={() => void handleCheckForUpdates()}
type="button"
>
<span className={`transition-all duration-200 ${updateStatus.state === 'up-to-date' ? 'text-[var(--color-status-success)]' : 'text-[var(--color-text-muted)] group-hover:text-[var(--color-text-secondary)]'}`}>
{updateStatus.state === 'up-to-date'
? <CircleCheck className="size-4" />
: <RefreshCw className={`size-4 ${isChecking ? 'animate-spin' : ''}`} />}
</span>
<div className="min-w-0 flex-1">
<span className={`text-[13px] font-medium ${updateStatus.state === 'error' ? 'text-[var(--color-status-error)]' : updateStatus.state === 'up-to-date' ? 'text-[var(--color-status-success)]' : 'text-[var(--color-text-primary)]'}`}>
{getUpdateLabel()}
</span>
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">{getUpdateDescription()}</p>
</div>
<ChevronRight className="size-4 text-[var(--color-text-muted)] transition-all duration-200 group-hover:text-[var(--color-text-muted)]" />
</button>
)}
<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-[var(--color-status-error)]/20 bg-[var(--color-status-error)]/5 p-5">
<div className="flex items-start gap-3">
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-[var(--color-status-error)]" />
<div className="min-w-0 flex-1">
<h4 className="text-[13px] font-semibold text-[var(--color-status-error)]">Reset Local Workspace</h4>
<p className="mt-1 text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
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-[var(--color-status-error)]/30 bg-[var(--color-status-error)]/10 px-3.5 py-1.5 text-[13px] font-medium text-[var(--color-status-error)] transition-all duration-200 hover:border-[var(--color-status-error)]/50 hover:bg-[var(--color-status-error)]/20"
onClick={() => setConfirmingReset(true)}
type="button"
>
Reset workspace
</button>
) : (
<div className="mt-3 flex items-center gap-2">
<button
className="rounded-lg bg-[var(--color-status-error)] px-3.5 py-1.5 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-status-error)] 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-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
disabled={isResetting}
onClick={() => setConfirmingReset(false)}
type="button"
>
Cancel
</button>
</div>
)}
</div>
</div>
</div>
</div>
{/* Attribution footer */}
<div className="mt-12 flex items-center justify-center gap-1.5 pb-2 text-[11px] text-[var(--color-text-muted)]">
<span>Built with</span>
<svg aria-hidden="true" className="size-3 text-[var(--color-status-error)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
<span>by Dávid Kaya</span>
</div>
</div>
);
}
+80 -15
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Loader2, Minus, Search, Sparkles, TerminalSquare } from 'lucide-react';
import { ProviderIcon } from '@renderer/components/ProviderIcons';
@@ -250,7 +250,27 @@ export function InlineToolsPill({
</button>
{open && !disabled && (
<div className="absolute bottom-full left-0 z-40 mb-1.5 w-64 overflow-y-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] py-1 shadow-2xl">
<div className="absolute bottom-full left-0 z-40 mb-1.5 max-h-[28rem] w-64 overflow-y-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-2xl">
{/* Header: enable / disable all */}
<div className="sticky top-0 z-10 border-b border-[var(--color-border)] bg-[var(--color-surface-1)]">
<div className="flex items-center justify-end px-3 py-1.5">
<button
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
onClick={() => {
const allEnabled = enabledCount === totalCount;
onToggle({
enabledMcpServerIds: allEnabled ? [] : mcpServers.map((s) => s.id),
enabledLspProfileIds: allEnabled ? [] : lspProfiles.map((p) => p.id),
});
}}
type="button"
>
{enabledCount === totalCount ? 'Disable all' : 'Enable all'}
</button>
</div>
</div>
<div className="py-1">
{workspaceMcpServers.length > 0 && (
<McpServerGroup
label="Workspace MCP"
@@ -296,6 +316,7 @@ export function InlineToolsPill({
))}
</div>
)}
</div>
</div>
)}
</div>
@@ -397,9 +418,19 @@ export function InlineApprovalPill({
.filter((g) => g.tools.length > 0 || g.label.toLowerCase().includes(searchLower));
}, [groups, searchLower]);
function toggleTool(toolId: string) {
function toggleTool(toolId: string, group: ApprovalToolGroup) {
const next = new Set(effectiveAutoApproved);
if (next.has(toolId)) {
// If the group has server-level approval, expand it to individual tools
// so the user can selectively disable one tool.
if (group.serverApprovalKey && next.has(group.serverApprovalKey)) {
next.delete(group.serverApprovalKey);
for (const tool of group.tools) {
if (tool.id !== toolId) {
next.add(tool.id);
}
}
} else if (next.has(toolId)) {
next.delete(toolId);
} else {
next.add(toolId);
@@ -470,6 +501,31 @@ export function InlineApprovalPill({
return expandedGroups.has(groupId);
}
function isToolEffectivelyApproved(toolId: string, group: ApprovalToolGroup): boolean {
if (effectiveAutoApproved.has(toolId)) return true;
if (group.serverApprovalKey && effectiveAutoApproved.has(group.serverApprovalKey)) return true;
return false;
}
const allApprovedGlobal = effectiveAutoApprovedCount === totalItemCount && totalItemCount > 0;
const approveAll = useCallback(() => {
const next = new Set<string>();
for (const group of groups) {
if (group.serverApprovalKey) {
next.add(group.serverApprovalKey);
}
for (const tool of group.tools) {
next.add(tool.id);
}
}
onUpdate({ autoApprovedToolNames: [...next] });
}, [groups, onUpdate]);
const unapproveAll = useCallback(() => {
onUpdate({ autoApprovedToolNames: [] });
}, [onUpdate]);
return (
<div className="relative" ref={ref}>
<button
@@ -510,16 +566,25 @@ export function InlineApprovalPill({
}`}>
{isOverridden ? 'Session override' : 'Pattern defaults'}
</span>
{isOverridden && (
<span className="ml-auto flex items-center gap-1">
{isOverridden && (
<button
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
onClick={() => onUpdate({})}
type="button"
>
<RotateCcw className="size-2.5" />
Reset
</button>
)}
<button
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
onClick={() => onUpdate({})}
onClick={allApprovedGlobal ? unapproveAll : approveAll}
type="button"
>
<RotateCcw className="size-2.5" />
Reset
{allApprovedGlobal ? 'Unapprove all' : 'Approve all'}
</button>
)}
</span>
</div>
{/* Search */}
@@ -607,9 +672,9 @@ export function InlineApprovalPill({
<div key={tool.id} className={isCollapsible ? 'pl-3' : ''}>
<PopoverToggleRow
detail={detail}
enabled={effectiveAutoApproved.has(tool.id)}
enabled={isToolEffectivelyApproved(tool.id, group)}
label={tool.label}
onToggle={() => toggleTool(tool.id)}
onToggle={() => toggleTool(tool.id, group)}
/>
</div>
);
@@ -642,8 +707,8 @@ function GroupToggle({
return (
<button
aria-pressed={allApproved}
className={`relative inline-flex h-[16px] w-[28px] shrink-0 items-center rounded-full transition-colors ${
allApproved ? 'bg-[var(--color-accent)]' : someApproved ? 'bg-[var(--color-surface-3)]' : 'bg-[var(--color-surface-3)]'
className={`relative inline-flex h-[14px] w-[24px] shrink-0 items-center rounded-full transition-all duration-200 ${
allApproved ? 'brand-gradient-bg shadow-[0_0_8px_rgba(36,92,249,0.3)]' : 'bg-[var(--color-surface-3)]'
}`}
onClick={onToggle}
type="button"
@@ -652,8 +717,8 @@ function GroupToggle({
<Minus className="absolute left-1/2 size-2 -translate-x-1/2 text-[var(--color-text-primary)]" strokeWidth={3} />
) : (
<span
className={`inline-block size-[12px] rounded-full bg-white shadow transition-transform ${
allApproved ? 'translate-x-[13px]' : 'translate-x-[2px]'
className={`inline-block size-[10px] rounded-full bg-white shadow-sm transition-transform ${
allApproved ? 'translate-x-[12px]' : 'translate-x-[2px]'
}`}
/>
)}
+1 -1
View File
@@ -175,7 +175,7 @@ export interface SetTerminalHeightInput {
height?: number;
}
export type UpdateStatusState = 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'error';
export type UpdateStatusState = 'idle' | 'checking' | 'up-to-date' | 'available' | 'downloading' | 'downloaded' | 'error';
export interface UpdateDownloadProgress {
bytesPerSecond: number;
+28
View File
@@ -395,6 +395,34 @@ function resolveApprovalToolGroupKey(
return { id: 'other', label: 'Other', kind: 'mixed' };
}
/**
* Count how many tools are effectively auto-approved across all groups.
*
* This must use per-group counting (not unique-tool-ID deduplication) to stay
* consistent with the total-item formula used in InlineApprovalPill, which
* counts each group occurrence independently. Without this, shared tool names
* across MCP servers produce a numerator smaller than the denominator even when
* everything is approved.
*/
export function countApprovedToolsInGroups(
groups: ReadonlyArray<ApprovalToolGroup>,
approved: ReadonlySet<string>,
): number {
let count = 0;
for (const group of groups) {
if (group.serverApprovalKey && approved.has(group.serverApprovalKey)) {
// Server-level approval covers all tools. Servers with 0 tools still
// count as 1 (matching the totalItemCount formula).
count += Math.max(group.tools.length, 1);
} else {
for (const tool of group.tools) {
if (approved.has(tool.id)) count += 1;
}
}
}
return count;
}
export function validateMcpServerDefinition(server: McpServerDefinition): string | undefined {
if (!server.name.trim()) {
return 'MCP server name is required.';
+23 -3
View File
@@ -10,6 +10,8 @@ class FakeUpdater extends EventEmitter {
autoInstallOnAppQuit = true;
forceDevUpdateConfig = false;
checkForUpdatesCalls = 0;
quitAndInstallCalls = 0;
@@ -62,7 +64,7 @@ class FakeScheduler implements AutoUpdateScheduler {
}
describe('AutoUpdateService', () => {
test('does not schedule checks for unpackaged apps', async () => {
test('does not schedule checks for unpackaged apps but manual checks still work', async () => {
const updater = new FakeUpdater();
const scheduler = new FakeScheduler();
const service = new AutoUpdateService({ isPackaged: false, scheduler, updater });
@@ -71,8 +73,10 @@ describe('AutoUpdateService', () => {
expect(scheduler.timeouts).toHaveLength(0);
expect(scheduler.intervals).toHaveLength(0);
expect(await service.checkForUpdates()).toEqual({ state: 'idle' });
expect(updater.checkForUpdatesCalls).toBe(0);
expect(updater.forceDevUpdateConfig).toBe(true);
await service.checkForUpdates();
expect(updater.checkForUpdatesCalls).toBe(1);
});
test('configures auto download and schedules startup and periodic checks', async () => {
@@ -148,6 +152,22 @@ describe('AutoUpdateService', () => {
]);
});
test('transitions to up-to-date when no update is available', () => {
const updater = new FakeUpdater();
const service = new AutoUpdateService({ isPackaged: true, updater });
const statuses: UpdateStatus[] = [];
service.onStatus((status) => statuses.push(status));
updater.emit('checking-for-update');
updater.emit('update-not-available', {});
expect(statuses).toEqual([
{ state: 'checking' },
{ state: 'up-to-date' },
]);
expect(service.getStatus()).toEqual({ state: 'up-to-date' });
});
test('reports updater errors and only installs once an update is downloaded', () => {
const updater = new FakeUpdater();
const service = new AutoUpdateService({ isPackaged: true, updater });
+99
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
import {
buildMcpServerApprovalKey,
countApprovedToolsInGroups,
groupApprovalToolsByProvider,
listApprovalToolDefinitions,
listApprovalToolNames,
@@ -658,3 +659,101 @@ describe('probed tools', () => {
expect(names).toContain('mcp_server:Probed Keys');
});
});
describe('countApprovedToolsInGroups', () => {
const TS = '2026-03-28T00:00:00.000Z';
function makeTooling(
mcpServers: McpServerDefinition[] = [],
lspProfiles: LspProfileDefinition[] = [],
): WorkspaceToolingSettings {
return { mcpServers, lspProfiles };
}
function makeMcpServer(id: string, name: string, tools: string[]): McpServerDefinition {
return { id, name, transport: 'local', command: 'node', args: [], tools, createdAt: TS, updatedAt: TS };
}
test('counts all tools approved when all server keys are set', () => {
const tooling = makeTooling([
makeMcpServer('git', 'Git MCP', ['git.status', 'git.diff']),
makeMcpServer('fs', 'Filesystem', ['fs.read']),
]);
const tools = listApprovalToolDefinitions(tooling);
const groups = groupApprovalToolsByProvider(tools, tooling);
const mcpGroups = groups.filter((g) => g.kind === 'mcp');
const approved = new Set(['mcp_server:Git MCP', 'mcp_server:Filesystem']);
const mcpTotal = mcpGroups.reduce(
(sum, g) => sum + Math.max(g.tools.length, g.serverApprovalKey ? 1 : 0), 0,
);
const count = countApprovedToolsInGroups(mcpGroups, approved);
expect(count).toBe(mcpTotal);
});
test('counts shared tool names per group, not as unique IDs', () => {
const tooling = makeTooling([
makeMcpServer('git-a', 'Git A', ['git.status', 'git.diff']),
makeMcpServer('git-b', 'Git B', ['git.status', 'git.diff']),
]);
const tools = listApprovalToolDefinitions(tooling);
const groups = groupApprovalToolsByProvider(tools, tooling);
const mcpGroups = groups.filter((g) => g.kind === 'mcp');
expect(mcpGroups.length).toBe(2);
// Both servers share the same tool names — MCP total should be 4 (2 per group)
const mcpTotal = mcpGroups.reduce(
(sum, g) => sum + Math.max(g.tools.length, g.serverApprovalKey ? 1 : 0), 0,
);
expect(mcpTotal).toBe(4);
// Approve all via server keys — count must match total
const approved = new Set(['mcp_server:Git A', 'mcp_server:Git B']);
const count = countApprovedToolsInGroups(mcpGroups, approved);
expect(count).toBe(mcpTotal);
});
test('counts individual tool approvals per group when no server key', () => {
const tooling = makeTooling([
makeMcpServer('git-a', 'Git A', ['git.status']),
makeMcpServer('git-b', 'Git B', ['git.status']),
]);
const tools = listApprovalToolDefinitions(tooling);
const groups = groupApprovalToolsByProvider(tools, tooling);
// Approve only the individual tool ID (shared across both groups)
const approved = new Set(['git.status']);
const mcpGroups = groups.filter((g) => g.kind === 'mcp');
expect(mcpGroups.length).toBe(2);
// Each group has the same tool → count should be 2 (once per group)
const mcpCount = countApprovedToolsInGroups(mcpGroups, approved);
expect(mcpCount).toBe(2);
});
test('counts empty server with approved key as 1', () => {
const tooling = makeTooling([makeMcpServer('empty', 'Empty Server', [])]);
const tools = listApprovalToolDefinitions(tooling);
const groups = groupApprovalToolsByProvider(tools, tooling);
const approved = new Set(['mcp_server:Empty Server']);
const mcpGroups = groups.filter((g) => g.kind === 'mcp');
expect(mcpGroups.length).toBe(1);
expect(mcpGroups[0].tools.length).toBe(0);
const count = countApprovedToolsInGroups(mcpGroups, approved);
expect(count).toBe(1);
});
test('returns 0 when nothing is approved', () => {
const tooling = makeTooling([
makeMcpServer('git', 'Git MCP', ['git.status']),
]);
const tools = listApprovalToolDefinitions(tooling);
const groups = groupApprovalToolsByProvider(tools, tooling);
const approved = new Set<string>();
const count = countApprovedToolsInGroups(groups, approved);
expect(count).toBe(0);
});
});