mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-28 23:48:39 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41289c960b | ||
|
|
33b293271e | ||
|
|
cbcf239a0a | ||
|
|
c702cf88e2 | ||
|
|
8a4d23c22a | ||
|
|
042cec6065 | ||
|
|
39fee48c0b |
@@ -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 HookPermissionKind = "hook";
|
||||||
private const string ToolCallingActivityType = "tool-calling";
|
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, PendingApprovalRequest> _pendingApprovals = new(StringComparer.Ordinal);
|
||||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _requestApprovedTools = 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)
|
string permissionKind = string.IsNullOrWhiteSpace(request.Kind)
|
||||||
? "tool access"
|
? "tool access"
|
||||||
: request.Kind.Trim();
|
: 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 agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
|
||||||
string? sessionId = NormalizeOptionalString(invocation.SessionId);
|
string? sessionId = NormalizeOptionalString(invocation.SessionId);
|
||||||
string? normalizedToolName = NormalizeOptionalString(toolName);
|
string? normalizedToolName = NormalizeOptionalString(toolName);
|
||||||
@@ -476,10 +504,22 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
PermissionRequestWrite => WritePermissionKind,
|
PermissionRequestWrite => WritePermissionKind,
|
||||||
PermissionRequestRead => ReadPermissionKind,
|
PermissionRequestRead => ReadPermissionKind,
|
||||||
PermissionRequestMemory => StoreMemoryToolName,
|
PermissionRequestMemory => StoreMemoryToolName,
|
||||||
|
PermissionRequestHook hook => ResolveHookToolCategory(hook.ToolName),
|
||||||
_ => null,
|
_ => 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(
|
private static bool MatchesAutoApprovedTool(
|
||||||
IReadOnlyList<string> autoApprovedToolNames,
|
IReadOnlyList<string> autoApprovedToolNames,
|
||||||
string? toolName,
|
string? toolName,
|
||||||
|
|||||||
@@ -248,11 +248,13 @@ internal static class CopilotSessionHooks
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string? autoApprovedToolName = CopilotApprovalCoordinator.ResolveHookToolCategory(toolName) ?? toolName;
|
||||||
|
|
||||||
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
|
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||||
command.Pattern.ApprovalPolicy,
|
command.Pattern.ApprovalPolicy,
|
||||||
agentDefinition.Id,
|
agentDefinition.Id,
|
||||||
toolName,
|
toolName,
|
||||||
toolName);
|
autoApprovedToolName);
|
||||||
|
|
||||||
return new PreToolUseHookOutput
|
return new PreToolUseHookOutput
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -151,6 +151,26 @@ public sealed class CopilotSessionHooksTests
|
|||||||
Assert.Equal("ask", decision?.PermissionDecision);
|
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]
|
[Fact]
|
||||||
public async Task Create_RunsConfiguredNonPreToolHooks()
|
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)
|
private static HookCommandDefinition CreateHookCommand(string name)
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1325,6 +1325,155 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
Assert.Equal("https://example.com", args["url"]);
|
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]
|
[Fact]
|
||||||
public async Task RequestApprovalAsync_RaisesApprovalAndCompletesAfterResolution()
|
public async Task RequestApprovalAsync_RaisesApprovalAndCompletesAfterResolution()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type AutoUpdateListener = (...args: any[]) => void;
|
|||||||
interface AutoUpdaterLike {
|
interface AutoUpdaterLike {
|
||||||
autoDownload: boolean;
|
autoDownload: boolean;
|
||||||
autoInstallOnAppQuit: boolean;
|
autoInstallOnAppQuit: boolean;
|
||||||
|
forceDevUpdateConfig: boolean;
|
||||||
on(event: string, listener: AutoUpdateListener): this;
|
on(event: string, listener: AutoUpdateListener): this;
|
||||||
removeListener(event: string, listener: AutoUpdateListener): this;
|
removeListener(event: string, listener: AutoUpdateListener): this;
|
||||||
checkForUpdates(): Promise<unknown>;
|
checkForUpdates(): Promise<unknown>;
|
||||||
@@ -151,7 +152,7 @@ export class AutoUpdateService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly notAvailableListener = () => {
|
private readonly notAvailableListener = () => {
|
||||||
this.publishStatus({ state: 'idle' });
|
this.publishStatus({ state: 'up-to-date' });
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly progressListener = (progress: AutoUpdateProgressLike) => {
|
private readonly progressListener = (progress: AutoUpdateProgressLike) => {
|
||||||
@@ -180,6 +181,7 @@ export class AutoUpdateService {
|
|||||||
this.scheduler = options.scheduler ?? defaultScheduler;
|
this.scheduler = options.scheduler ?? defaultScheduler;
|
||||||
this.updater.autoDownload = true;
|
this.updater.autoDownload = true;
|
||||||
this.updater.autoInstallOnAppQuit = false;
|
this.updater.autoInstallOnAppQuit = false;
|
||||||
|
this.updater.forceDevUpdateConfig = !options.isPackaged;
|
||||||
|
|
||||||
this.updater.on('checking-for-update', this.checkingListener);
|
this.updater.on('checking-for-update', this.checkingListener);
|
||||||
this.updater.on('update-available', this.availableListener);
|
this.updater.on('update-available', this.availableListener);
|
||||||
@@ -215,10 +217,6 @@ export class AutoUpdateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkForUpdates(): Promise<UpdateStatus> {
|
async checkForUpdates(): Promise<UpdateStatus> {
|
||||||
if (!this.options.isPackaged) {
|
|
||||||
return this.getStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.pendingCheck) {
|
if (this.pendingCheck) {
|
||||||
return this.pendingCheck;
|
return this.pendingCheck;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pat
|
|||||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||||
import { resolveSessionToolingSelection, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
|
import { resolveSessionToolingSelection, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
|
||||||
import {
|
import {
|
||||||
|
countApprovedToolsInGroups,
|
||||||
groupApprovalToolsByProvider,
|
groupApprovalToolsByProvider,
|
||||||
listApprovalToolDefinitions,
|
listApprovalToolDefinitions,
|
||||||
type RuntimeToolDefinition,
|
type RuntimeToolDefinition,
|
||||||
@@ -162,17 +163,7 @@ export function ChatPane({
|
|||||||
);
|
);
|
||||||
const effectiveAutoApprovedCount = useMemo(() => {
|
const effectiveAutoApprovedCount = useMemo(() => {
|
||||||
const groups = groupApprovalToolsByProvider(approvalTools, toolingSettings);
|
const groups = groupApprovalToolsByProvider(approvalTools, toolingSettings);
|
||||||
const counted = new Set<string>();
|
return countApprovedToolsInGroups(groups, effectiveAutoApproved);
|
||||||
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;
|
|
||||||
}, [approvalTools, effectiveAutoApproved, toolingSettings]);
|
}, [approvalTools, effectiveAutoApproved, toolingSettings]);
|
||||||
const isProbingMcp = (mcpProbingServerIds?.length ?? 0) > 0;
|
const isProbingMcp = (mcpProbingServerIds?.length ?? 0) > 0;
|
||||||
const hasApprovalContent = approvalTools.length > 0 || isProbingMcp;
|
const hasApprovalContent = approvalTools.length > 0 || isProbingMcp;
|
||||||
|
|||||||
@@ -51,20 +51,37 @@ const runStatusStyles: Record<SessionRunRecord['status'], { icon: ReactNode; cla
|
|||||||
/* ── Event node icon ───────────────────────────────────────── */
|
/* ── Event node icon ───────────────────────────────────────── */
|
||||||
|
|
||||||
function EventIcon({ kind, status }: { kind: RunTimelineEventRecord['kind']; status: RunTimelineEventRecord['status'] }) {
|
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) {
|
switch (kind) {
|
||||||
case 'run-started':
|
case 'run-started':
|
||||||
return <Play className={`${base} text-[var(--color-text-muted)]`} />;
|
return <Play className={`${base} text-[var(--color-text-muted)]`} />;
|
||||||
case 'thinking':
|
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':
|
case 'handoff':
|
||||||
return <ArrowRight className={`${base} text-[var(--color-status-warning)]`} />;
|
return <ArrowRight className={`${base} text-[var(--color-status-warning)]`} />;
|
||||||
case 'tool-call':
|
case 'tool-call':
|
||||||
return <Wrench className={`${base} text-[var(--color-accent-purple)]`} />;
|
return <Wrench className={`${base} text-[var(--color-accent-purple)]`} />;
|
||||||
case 'approval':
|
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':
|
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':
|
case 'run-completed':
|
||||||
return <CheckCircle2 className={`${base} text-[var(--color-status-success)]`} />;
|
return <CheckCircle2 className={`${base} text-[var(--color-status-success)]`} />;
|
||||||
case 'run-cancelled':
|
case 'run-cancelled':
|
||||||
@@ -95,7 +112,7 @@ function TimelineEventRow({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* Vertical connector line */}
|
{/* Vertical connector line */}
|
||||||
{!isLast && (
|
{!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
|
<button
|
||||||
@@ -106,7 +123,7 @@ function TimelineEventRow({
|
|||||||
>
|
>
|
||||||
{/* Node */}
|
{/* Node */}
|
||||||
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
|
<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} />
|
<EventIcon kind={event.kind} status={event.status} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,11 +196,11 @@ function ThinkingGroupRow({
|
|||||||
return (
|
return (
|
||||||
<div className="group relative flex w-full gap-2.5 py-1">
|
<div className="group relative flex w-full gap-2.5 py-1">
|
||||||
{!isLast && (
|
{!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="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)]">
|
<div className="flex size-[18px] items-center justify-center rounded-full bg-[var(--color-surface-2)]">
|
||||||
<Brain className="size-3.5 text-[var(--color-text-muted)]" />
|
<Brain className="size-2.5 text-[var(--color-text-muted)]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, Code, Cpu, FolderOpen, Palette, Plus, Server, TriangleAlert, Workflow, Wrench } from 'lucide-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 { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
||||||
import { PatternEditor } from '@renderer/components/PatternEditor';
|
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 { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||||
import type { ModelDefinition } from '@shared/domain/models';
|
import type { ModelDefinition } from '@shared/domain/models';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
|
import type { UpdateStatus, UpdateStatusState } from '@shared/contracts/ipc';
|
||||||
import {
|
import {
|
||||||
normalizeLspProfileDefinition,
|
normalizeLspProfileDefinition,
|
||||||
normalizeMcpServerDefinition,
|
normalizeMcpServerDefinition,
|
||||||
@@ -817,6 +818,30 @@ function TroubleshootingSection({
|
|||||||
}) {
|
}) {
|
||||||
const [isResetting, setIsResetting] = useState(false);
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
const [confirmingReset, setConfirmingReset] = 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() {
|
async function handleReset() {
|
||||||
setIsResetting(true);
|
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 (
|
return (
|
||||||
<div>
|
<div className="flex min-h-full flex-col">
|
||||||
<SectionHeader
|
<div className="flex-1">
|
||||||
description="Diagnose issues and manage local application data"
|
<SectionHeader
|
||||||
title="Troubleshooting"
|
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-[var(--color-status-error)]/20 bg-[var(--color-status-error)]/5 p-5">
|
<div className="space-y-2">
|
||||||
<div className="flex items-start gap-3">
|
{/* Check for updates */}
|
||||||
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-[var(--color-status-error)]" />
|
{updateStatus.state === 'downloaded' ? (
|
||||||
<div className="min-w-0 flex-1">
|
<button
|
||||||
<h4 className="text-[13px] font-semibold text-[var(--color-status-error)]">Reset Local Workspace</h4>
|
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"
|
||||||
<p className="mt-1 text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
|
onClick={() => void handleInstallUpdate()}
|
||||||
Restore Aryx to its initial state. This permanently removes all sessions, custom patterns,
|
type="button"
|
||||||
MCP server definitions, LSP profiles, and scratchpad contents. Your GitHub Copilot sign-in
|
>
|
||||||
is not affected.
|
<span className="text-[var(--color-status-success)]">
|
||||||
</p>
|
<RefreshCw className="size-4" />
|
||||||
|
</span>
|
||||||
{!confirmingReset ? (
|
<div className="min-w-0 flex-1">
|
||||||
<button
|
<span className="text-[13px] font-medium text-[var(--color-status-success)]">{getUpdateLabel()}</span>
|
||||||
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"
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">{getUpdateDescription()}</p>
|
||||||
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>
|
||||||
)}
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { ChevronDown, ChevronRight, Loader2, Minus, Search, Sparkles, TerminalSquare } from 'lucide-react';
|
||||||
|
|
||||||
import { ProviderIcon } from '@renderer/components/ProviderIcons';
|
import { ProviderIcon } from '@renderer/components/ProviderIcons';
|
||||||
@@ -250,7 +250,27 @@ export function InlineToolsPill({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && !disabled && (
|
{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 && (
|
{workspaceMcpServers.length > 0 && (
|
||||||
<McpServerGroup
|
<McpServerGroup
|
||||||
label="Workspace MCP"
|
label="Workspace MCP"
|
||||||
@@ -296,6 +316,7 @@ export function InlineToolsPill({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -397,9 +418,19 @@ export function InlineApprovalPill({
|
|||||||
.filter((g) => g.tools.length > 0 || g.label.toLowerCase().includes(searchLower));
|
.filter((g) => g.tools.length > 0 || g.label.toLowerCase().includes(searchLower));
|
||||||
}, [groups, searchLower]);
|
}, [groups, searchLower]);
|
||||||
|
|
||||||
function toggleTool(toolId: string) {
|
function toggleTool(toolId: string, group: ApprovalToolGroup) {
|
||||||
const next = new Set(effectiveAutoApproved);
|
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);
|
next.delete(toolId);
|
||||||
} else {
|
} else {
|
||||||
next.add(toolId);
|
next.add(toolId);
|
||||||
@@ -470,6 +501,31 @@ export function InlineApprovalPill({
|
|||||||
return expandedGroups.has(groupId);
|
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 (
|
return (
|
||||||
<div className="relative" ref={ref}>
|
<div className="relative" ref={ref}>
|
||||||
<button
|
<button
|
||||||
@@ -510,16 +566,25 @@ export function InlineApprovalPill({
|
|||||||
}`}>
|
}`}>
|
||||||
{isOverridden ? 'Session override' : 'Pattern defaults'}
|
{isOverridden ? 'Session override' : 'Pattern defaults'}
|
||||||
</span>
|
</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
|
<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)]"
|
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"
|
type="button"
|
||||||
>
|
>
|
||||||
<RotateCcw className="size-2.5" />
|
{allApprovedGlobal ? 'Unapprove all' : 'Approve all'}
|
||||||
Reset
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
@@ -607,9 +672,9 @@ export function InlineApprovalPill({
|
|||||||
<div key={tool.id} className={isCollapsible ? 'pl-3' : ''}>
|
<div key={tool.id} className={isCollapsible ? 'pl-3' : ''}>
|
||||||
<PopoverToggleRow
|
<PopoverToggleRow
|
||||||
detail={detail}
|
detail={detail}
|
||||||
enabled={effectiveAutoApproved.has(tool.id)}
|
enabled={isToolEffectivelyApproved(tool.id, group)}
|
||||||
label={tool.label}
|
label={tool.label}
|
||||||
onToggle={() => toggleTool(tool.id)}
|
onToggle={() => toggleTool(tool.id, group)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -642,8 +707,8 @@ function GroupToggle({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-pressed={allApproved}
|
aria-pressed={allApproved}
|
||||||
className={`relative inline-flex h-[16px] w-[28px] shrink-0 items-center rounded-full transition-colors ${
|
className={`relative inline-flex h-[14px] w-[24px] shrink-0 items-center rounded-full transition-all duration-200 ${
|
||||||
allApproved ? 'bg-[var(--color-accent)]' : someApproved ? 'bg-[var(--color-surface-3)]' : 'bg-[var(--color-surface-3)]'
|
allApproved ? 'brand-gradient-bg shadow-[0_0_8px_rgba(36,92,249,0.3)]' : 'bg-[var(--color-surface-3)]'
|
||||||
}`}
|
}`}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
type="button"
|
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} />
|
<Minus className="absolute left-1/2 size-2 -translate-x-1/2 text-[var(--color-text-primary)]" strokeWidth={3} />
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className={`inline-block size-[12px] rounded-full bg-white shadow transition-transform ${
|
className={`inline-block size-[10px] rounded-full bg-white shadow-sm transition-transform ${
|
||||||
allApproved ? 'translate-x-[13px]' : 'translate-x-[2px]'
|
allApproved ? 'translate-x-[12px]' : 'translate-x-[2px]'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export interface SetTerminalHeightInput {
|
|||||||
height?: number;
|
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 {
|
export interface UpdateDownloadProgress {
|
||||||
bytesPerSecond: number;
|
bytesPerSecond: number;
|
||||||
|
|||||||
@@ -395,6 +395,34 @@ function resolveApprovalToolGroupKey(
|
|||||||
return { id: 'other', label: 'Other', kind: 'mixed' };
|
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 {
|
export function validateMcpServerDefinition(server: McpServerDefinition): string | undefined {
|
||||||
if (!server.name.trim()) {
|
if (!server.name.trim()) {
|
||||||
return 'MCP server name is required.';
|
return 'MCP server name is required.';
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ class FakeUpdater extends EventEmitter {
|
|||||||
|
|
||||||
autoInstallOnAppQuit = true;
|
autoInstallOnAppQuit = true;
|
||||||
|
|
||||||
|
forceDevUpdateConfig = false;
|
||||||
|
|
||||||
checkForUpdatesCalls = 0;
|
checkForUpdatesCalls = 0;
|
||||||
|
|
||||||
quitAndInstallCalls = 0;
|
quitAndInstallCalls = 0;
|
||||||
@@ -62,7 +64,7 @@ class FakeScheduler implements AutoUpdateScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('AutoUpdateService', () => {
|
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 updater = new FakeUpdater();
|
||||||
const scheduler = new FakeScheduler();
|
const scheduler = new FakeScheduler();
|
||||||
const service = new AutoUpdateService({ isPackaged: false, scheduler, updater });
|
const service = new AutoUpdateService({ isPackaged: false, scheduler, updater });
|
||||||
@@ -71,8 +73,10 @@ describe('AutoUpdateService', () => {
|
|||||||
|
|
||||||
expect(scheduler.timeouts).toHaveLength(0);
|
expect(scheduler.timeouts).toHaveLength(0);
|
||||||
expect(scheduler.intervals).toHaveLength(0);
|
expect(scheduler.intervals).toHaveLength(0);
|
||||||
expect(await service.checkForUpdates()).toEqual({ state: 'idle' });
|
expect(updater.forceDevUpdateConfig).toBe(true);
|
||||||
expect(updater.checkForUpdatesCalls).toBe(0);
|
|
||||||
|
await service.checkForUpdates();
|
||||||
|
expect(updater.checkForUpdatesCalls).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('configures auto download and schedules startup and periodic checks', async () => {
|
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', () => {
|
test('reports updater errors and only installs once an update is downloaded', () => {
|
||||||
const updater = new FakeUpdater();
|
const updater = new FakeUpdater();
|
||||||
const service = new AutoUpdateService({ isPackaged: true, updater });
|
const service = new AutoUpdateService({ isPackaged: true, updater });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildMcpServerApprovalKey,
|
buildMcpServerApprovalKey,
|
||||||
|
countApprovedToolsInGroups,
|
||||||
groupApprovalToolsByProvider,
|
groupApprovalToolsByProvider,
|
||||||
listApprovalToolDefinitions,
|
listApprovalToolDefinitions,
|
||||||
listApprovalToolNames,
|
listApprovalToolNames,
|
||||||
@@ -658,3 +659,101 @@ describe('probed tools', () => {
|
|||||||
expect(names).toContain('mcp_server:Probed Keys');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user