mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58:43 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a261780c6 | ||
|
|
9b7e4dd6e9 | ||
|
|
4bc2c327f7 | ||
|
|
36126f1c74 | ||
|
|
bec50da2b4 | ||
|
|
535adc64be | ||
|
|
772c84fed3 | ||
|
|
9647b5fdb5 |
@@ -0,0 +1 @@
|
|||||||
|
ManifestDPIAware true
|
||||||
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "aryx",
|
"name": "aryx",
|
||||||
"version": "0.0.17",
|
"version": "0.0.19",
|
||||||
"description": "Electron orchestrator for Copilot-powered agent workflows across multiple projects.",
|
"description": "Orchestrator for Copilot-powered agent workflows across multiple projects.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main/index.js",
|
"main": "dist-electron/main/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -126,6 +126,7 @@
|
|||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"perMachine": false,
|
"perMachine": false,
|
||||||
"allowToChangeInstallationDirectory": true,
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"include": "assets/installer.nsh",
|
||||||
"installerIcon": "assets/icons/windows/icon.ico",
|
"installerIcon": "assets/icons/windows/icon.ico",
|
||||||
"uninstallerIcon": "assets/icons/windows/icon.ico"
|
"uninstallerIcon": "assets/icons/windows/icon.ico"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
{
|
{
|
||||||
string? toolName = ResolveApprovalToolName(request, toolNamesByCallId);
|
string? toolName = ResolveApprovalToolName(request, toolNamesByCallId);
|
||||||
string? autoApprovedToolName = ResolveAutoApprovedToolName(request);
|
string? autoApprovedToolName = ResolveAutoApprovedToolName(request);
|
||||||
string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request);
|
string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request, command.Tooling?.McpServers);
|
||||||
string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName);
|
string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName);
|
||||||
|
|
||||||
AgentActivityEventDto? fileChangeActivity = BuildToolCallFileChangeActivity(command, agent, request, toolName);
|
AgentActivityEventDto? fileChangeActivity = BuildToolCallFileChangeActivity(command, agent, request, toolName);
|
||||||
@@ -155,18 +155,7 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
string approvalId,
|
string approvalId,
|
||||||
string? toolName)
|
string? toolName)
|
||||||
{
|
{
|
||||||
string permissionKind = string.IsNullOrWhiteSpace(request.Kind)
|
string permissionKind = ResolvePermissionKind(request, command.Tooling?.McpServers);
|
||||||
? "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 agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
|
||||||
string? sessionId = NormalizeOptionalString(invocation.SessionId);
|
string? sessionId = NormalizeOptionalString(invocation.SessionId);
|
||||||
@@ -208,7 +197,7 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
PermissionKind = permissionKind,
|
PermissionKind = permissionKind,
|
||||||
Title = title,
|
Title = title,
|
||||||
Detail = detail,
|
Detail = detail,
|
||||||
PermissionDetail = BuildPermissionDetail(request),
|
PermissionDetail = BuildPermissionDetail(request, command.Tooling?.McpServers),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +241,9 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static PermissionDetailDto BuildPermissionDetail(PermissionRequest request)
|
internal static PermissionDetailDto BuildPermissionDetail(
|
||||||
|
PermissionRequest request,
|
||||||
|
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
@@ -309,12 +300,7 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
ToolDescription = NormalizeOptionalString(customTool.ToolDescription),
|
ToolDescription = NormalizeOptionalString(customTool.ToolDescription),
|
||||||
Args = customTool.Args,
|
Args = customTool.Args,
|
||||||
},
|
},
|
||||||
PermissionRequestHook hook => new PermissionDetailDto
|
PermissionRequestHook hook => BuildHookPermissionDetail(hook, configuredMcpServers),
|
||||||
{
|
|
||||||
Kind = HookPermissionKind,
|
|
||||||
Args = hook.ToolArgs,
|
|
||||||
HookMessage = NormalizeOptionalString(hook.HookMessage),
|
|
||||||
},
|
|
||||||
_ => new PermissionDetailDto
|
_ => new PermissionDetailDto
|
||||||
{
|
{
|
||||||
Kind = NormalizeOptionalString(request.Kind) ?? "unknown",
|
Kind = NormalizeOptionalString(request.Kind) ?? "unknown",
|
||||||
@@ -430,15 +416,45 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
|
|
||||||
private const string McpServerApprovalPrefix = "mcp_server:";
|
private const string McpServerApprovalPrefix = "mcp_server:";
|
||||||
|
|
||||||
private static string? ResolveMcpServerApprovalKey(PermissionRequest request)
|
private static string? ResolveMcpServerApprovalKey(
|
||||||
|
PermissionRequest request,
|
||||||
|
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
|
||||||
{
|
{
|
||||||
if (request is not PermissionRequestMcp mcp)
|
return request switch
|
||||||
|
{
|
||||||
|
PermissionRequestMcp mcp => BuildMcpServerApprovalKey(mcp.ServerName),
|
||||||
|
PermissionRequestHook hook => ResolveHookMcpServerApprovalKey(hook.ToolName, configuredMcpServers),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string? BuildMcpServerApprovalKey(string? serverName)
|
||||||
|
{
|
||||||
|
string? normalizedServerName = NormalizeOptionalString(serverName);
|
||||||
|
return normalizedServerName is not null ? $"{McpServerApprovalPrefix}{normalizedServerName}" : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string? ResolveHookMcpServerApprovalKey(
|
||||||
|
string? toolName,
|
||||||
|
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
|
||||||
|
=> BuildMcpServerApprovalKey(ResolveHookMcpServerName(toolName, configuredMcpServers));
|
||||||
|
|
||||||
|
internal static string? ResolveHookMcpServerName(
|
||||||
|
string? toolName,
|
||||||
|
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
|
||||||
|
{
|
||||||
|
string? normalizedToolName = NormalizeOptionalString(toolName);
|
||||||
|
if (normalizedToolName is null || configuredMcpServers is null || configuredMcpServers.Count == 0)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
string? serverName = NormalizeOptionalString(mcp.ServerName);
|
return configuredMcpServers
|
||||||
return serverName is not null ? $"{McpServerApprovalPrefix}{serverName}" : null;
|
.Select(ResolveConfiguredMcpServerName)
|
||||||
|
.OfType<string>()
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderByDescending(static serverName => serverName.Length)
|
||||||
|
.FirstOrDefault(serverName => MatchesHookMcpServerToolName(normalizedToolName, serverName));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ResolveApprovalCacheKey(
|
private static string? ResolveApprovalCacheKey(
|
||||||
@@ -520,6 +536,87 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
return HookToolCategories.TryGetValue(normalized, out string? category) ? category : null;
|
return HookToolCategories.TryGetValue(normalized, out string? category) ? category : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ResolvePermissionKind(
|
||||||
|
PermissionRequest request,
|
||||||
|
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
|
||||||
|
{
|
||||||
|
string permissionKind = string.IsNullOrWhiteSpace(request.Kind)
|
||||||
|
? "tool access"
|
||||||
|
: request.Kind.Trim();
|
||||||
|
|
||||||
|
if (request is not PermissionRequestHook hook)
|
||||||
|
{
|
||||||
|
return permissionKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? resolvedCategory = ResolveHookToolCategory(hook.ToolName);
|
||||||
|
if (resolvedCategory is not null)
|
||||||
|
{
|
||||||
|
return resolvedCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolveHookMcpServerName(hook.ToolName, configuredMcpServers) is not null
|
||||||
|
? McpPermissionKind
|
||||||
|
: permissionKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PermissionDetailDto BuildHookPermissionDetail(
|
||||||
|
PermissionRequestHook hook,
|
||||||
|
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
|
||||||
|
{
|
||||||
|
string? serverName = ResolveHookMcpServerName(hook.ToolName, configuredMcpServers);
|
||||||
|
if (serverName is null)
|
||||||
|
{
|
||||||
|
return new PermissionDetailDto
|
||||||
|
{
|
||||||
|
Kind = HookPermissionKind,
|
||||||
|
Args = hook.ToolArgs,
|
||||||
|
HookMessage = NormalizeOptionalString(hook.HookMessage),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PermissionDetailDto
|
||||||
|
{
|
||||||
|
Kind = McpPermissionKind,
|
||||||
|
ServerName = serverName,
|
||||||
|
ToolTitle = ResolveHookMcpToolTitle(hook.ToolName, serverName),
|
||||||
|
Args = hook.ToolArgs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveConfiguredMcpServerName(RunTurnMcpServerConfigDto configuredServer)
|
||||||
|
=> NormalizeOptionalString(configuredServer.Name) ?? NormalizeOptionalString(configuredServer.Id);
|
||||||
|
|
||||||
|
private static bool MatchesHookMcpServerToolName(string toolName, string serverName)
|
||||||
|
{
|
||||||
|
if (string.Equals(toolName, serverName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolName.StartsWith($"{serverName}-", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveHookMcpToolTitle(string? toolName, string serverName)
|
||||||
|
{
|
||||||
|
string? normalizedToolName = NormalizeOptionalString(toolName);
|
||||||
|
if (normalizedToolName is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string prefix = $"{serverName}-";
|
||||||
|
if (!normalizedToolName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return normalizedToolName;
|
||||||
|
}
|
||||||
|
|
||||||
|
string strippedToolName = normalizedToolName[prefix.Length..];
|
||||||
|
return string.IsNullOrWhiteSpace(strippedToolName)
|
||||||
|
? normalizedToolName
|
||||||
|
: strippedToolName;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool MatchesAutoApprovedTool(
|
private static bool MatchesAutoApprovedTool(
|
||||||
IReadOnlyList<string> autoApprovedToolNames,
|
IReadOnlyList<string> autoApprovedToolNames,
|
||||||
string? toolName,
|
string? toolName,
|
||||||
|
|||||||
@@ -249,12 +249,16 @@ internal static class CopilotSessionHooks
|
|||||||
}
|
}
|
||||||
|
|
||||||
string? autoApprovedToolName = CopilotApprovalCoordinator.ResolveHookToolCategory(toolName) ?? toolName;
|
string? autoApprovedToolName = CopilotApprovalCoordinator.ResolveHookToolCategory(toolName) ?? toolName;
|
||||||
|
string? mcpServerApprovalKey = CopilotApprovalCoordinator.ResolveHookMcpServerApprovalKey(
|
||||||
|
toolName,
|
||||||
|
command.Tooling?.McpServers);
|
||||||
|
|
||||||
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
|
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||||
command.Pattern.ApprovalPolicy,
|
command.Pattern.ApprovalPolicy,
|
||||||
agentDefinition.Id,
|
agentDefinition.Id,
|
||||||
toolName,
|
toolName,
|
||||||
autoApprovedToolName);
|
autoApprovedToolName,
|
||||||
|
mcpServerApprovalKey);
|
||||||
|
|
||||||
return new PreToolUseHookOutput
|
return new PreToolUseHookOutput
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -171,6 +171,40 @@ public sealed class CopilotSessionHooksTests
|
|||||||
Assert.Equal("allow", decision?.PermissionDecision);
|
Assert.Equal("allow", decision?.PermissionDecision);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_PreToolUseAutoAllowsWhenMcpServerIsApproved()
|
||||||
|
{
|
||||||
|
RunTurnCommandDto command = CreateCommandWithConfiguredMcpServers(
|
||||||
|
["icm-mcp"],
|
||||||
|
["mcp_server:icm-mcp"]);
|
||||||
|
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||||
|
|
||||||
|
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||||
|
new PreToolUseHookInput
|
||||||
|
{
|
||||||
|
ToolName = "icm-mcp-get_incident_details_by_id",
|
||||||
|
},
|
||||||
|
null!);
|
||||||
|
|
||||||
|
Assert.Equal("allow", decision?.PermissionDecision);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_PreToolUseRequiresApprovalWhenMcpServerIsNotApproved()
|
||||||
|
{
|
||||||
|
RunTurnCommandDto command = CreateCommandWithConfiguredMcpServers(["icm-mcp"]);
|
||||||
|
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||||
|
|
||||||
|
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||||
|
new PreToolUseHookInput
|
||||||
|
{
|
||||||
|
ToolName = "icm-mcp-get_incident_details_by_id",
|
||||||
|
},
|
||||||
|
null!);
|
||||||
|
|
||||||
|
Assert.Equal("ask", decision?.PermissionDecision);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Create_RunsConfiguredNonPreToolHooks()
|
public async Task Create_RunsConfiguredNonPreToolHooks()
|
||||||
{
|
{
|
||||||
@@ -368,6 +402,43 @@ public sealed class CopilotSessionHooksTests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static RunTurnCommandDto CreateCommandWithConfiguredMcpServers(
|
||||||
|
IReadOnlyList<string> serverNames,
|
||||||
|
IReadOnlyList<string>? autoApprovedToolNames = null)
|
||||||
|
{
|
||||||
|
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||||
|
return new RunTurnCommandDto
|
||||||
|
{
|
||||||
|
RequestId = command.RequestId,
|
||||||
|
SessionId = command.SessionId,
|
||||||
|
ProjectPath = command.ProjectPath,
|
||||||
|
Tooling = new RunTurnToolingConfigDto
|
||||||
|
{
|
||||||
|
McpServers = [.. serverNames.Select(CreateMcpServerConfig)],
|
||||||
|
},
|
||||||
|
Pattern = new PatternDefinitionDto
|
||||||
|
{
|
||||||
|
Id = command.Pattern.Id,
|
||||||
|
Name = command.Pattern.Name,
|
||||||
|
Mode = command.Pattern.Mode,
|
||||||
|
Availability = command.Pattern.Availability,
|
||||||
|
ApprovalPolicy = new ApprovalPolicyDto
|
||||||
|
{
|
||||||
|
Rules = command.Pattern.ApprovalPolicy?.Rules ?? [],
|
||||||
|
AutoApprovedToolNames = autoApprovedToolNames ?? [],
|
||||||
|
},
|
||||||
|
Agents = command.Pattern.Agents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RunTurnMcpServerConfigDto CreateMcpServerConfig(string serverName)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = serverName,
|
||||||
|
Name = serverName,
|
||||||
|
};
|
||||||
|
|
||||||
private static HookCommandDefinition CreateHookCommand(string name)
|
private static HookCommandDefinition CreateHookCommand(string name)
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1325,6 +1325,29 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
Assert.Equal("https://example.com", args["url"]);
|
Assert.Equal("https://example.com", args["url"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildPermissionDetail_MapsConfiguredMcpHookToMcpDetail()
|
||||||
|
{
|
||||||
|
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
|
||||||
|
new PermissionRequestHook
|
||||||
|
{
|
||||||
|
Kind = "hook",
|
||||||
|
ToolName = "icm-mcp-get_incident_details_by_id",
|
||||||
|
ToolArgs = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["incidentId"] = 769904783,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[CreateMcpServerConfig("icm-mcp")]);
|
||||||
|
|
||||||
|
Assert.Equal("mcp", detail.Kind);
|
||||||
|
Assert.Equal("icm-mcp", detail.ServerName);
|
||||||
|
Assert.Equal("get_incident_details_by_id", detail.ToolTitle);
|
||||||
|
|
||||||
|
Dictionary<string, object?> args = Assert.IsType<Dictionary<string, object?>>(detail.Args);
|
||||||
|
Assert.Equal(769904783, args["incidentId"]);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("view", "read")]
|
[InlineData("view", "read")]
|
||||||
[InlineData("glob", "read")]
|
[InlineData("glob", "read")]
|
||||||
@@ -1362,6 +1385,16 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(" "));
|
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(" "));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveHookMcpServerApprovalKey_PrefersLongestConfiguredServerName()
|
||||||
|
{
|
||||||
|
string? approvalKey = CopilotApprovalCoordinator.ResolveHookMcpServerApprovalKey(
|
||||||
|
"icm-mcp-get_on_call_schedule",
|
||||||
|
[CreateMcpServerConfig("icm"), CreateMcpServerConfig("icm-mcp")]);
|
||||||
|
|
||||||
|
Assert.Equal("mcp_server:icm-mcp", approvalKey);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryGetApprovalToolName_ResolvesHookToolToCategory()
|
public void TryGetApprovalToolName_ResolvesHookToolToCategory()
|
||||||
{
|
{
|
||||||
@@ -1448,6 +1481,41 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
Assert.Contains("read permission", approvalEvent.Detail);
|
Assert.Contains("read permission", approvalEvent.Detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildPermissionApprovalEvent_UsesMcpKindForConfiguredMcpHookTools()
|
||||||
|
{
|
||||||
|
ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent(
|
||||||
|
new RunTurnCommandDto
|
||||||
|
{
|
||||||
|
RequestId = "turn-1",
|
||||||
|
SessionId = "session-1",
|
||||||
|
Tooling = new RunTurnToolingConfigDto
|
||||||
|
{
|
||||||
|
McpServers = [CreateMcpServerConfig("icm-mcp")],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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("mcp", approvalEvent.PermissionKind);
|
||||||
|
Assert.Contains("mcp permission", approvalEvent.Detail);
|
||||||
|
Assert.NotNull(approvalEvent.PermissionDetail);
|
||||||
|
Assert.Equal("mcp", approvalEvent.PermissionDetail!.Kind);
|
||||||
|
Assert.Equal("icm-mcp", approvalEvent.PermissionDetail.ServerName);
|
||||||
|
Assert.Equal("get_schedule", approvalEvent.PermissionDetail.ToolTitle);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildPermissionApprovalEvent_KeepsHookKindForUnknownHookTools()
|
public void BuildPermissionApprovalEvent_KeepsHookKindForUnknownHookTools()
|
||||||
{
|
{
|
||||||
@@ -1613,6 +1681,43 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
Assert.Equal(PermissionRequestResultKind.Approved, result.Kind);
|
Assert.Equal(PermissionRequestResultKind.Approved, result.Kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestApprovalAsync_AutoApprovesHookRequestsForApprovedMcpServer()
|
||||||
|
{
|
||||||
|
CopilotApprovalCoordinator coordinator = new();
|
||||||
|
bool sawApproval = false;
|
||||||
|
RunTurnCommandDto command = CreateApprovalCommand(
|
||||||
|
autoApprovedToolNames: ["mcp_server:icm-mcp"],
|
||||||
|
mcpServers: [CreateMcpServerConfig("icm-mcp")]);
|
||||||
|
|
||||||
|
PermissionRequestResult result = await coordinator.RequestApprovalAsync(
|
||||||
|
command,
|
||||||
|
command.Pattern.Agents[0],
|
||||||
|
new PermissionRequestHook
|
||||||
|
{
|
||||||
|
Kind = "hook",
|
||||||
|
ToolName = "icm-mcp-get_incident_details_by_id",
|
||||||
|
ToolArgs = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["incidentId"] = 769904783,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new PermissionInvocation
|
||||||
|
{
|
||||||
|
SessionId = "copilot-session-1",
|
||||||
|
},
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal),
|
||||||
|
approval =>
|
||||||
|
{
|
||||||
|
sawApproval = true;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(sawApproval);
|
||||||
|
Assert.Equal(PermissionRequestResultKind.Approved, result.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RequestApprovalAsync_AlwaysApproveCachesRuntimeApprovalForCurrentTurn()
|
public async Task RequestApprovalAsync_AlwaysApproveCachesRuntimeApprovalForCurrentTurn()
|
||||||
{
|
{
|
||||||
@@ -1853,12 +1958,21 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
null!);
|
null!);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RunTurnCommandDto CreateApprovalCommand(string requestId = "turn-1")
|
private static RunTurnCommandDto CreateApprovalCommand(
|
||||||
|
string requestId = "turn-1",
|
||||||
|
IReadOnlyList<string>? autoApprovedToolNames = null,
|
||||||
|
IReadOnlyList<RunTurnMcpServerConfigDto>? mcpServers = null)
|
||||||
{
|
{
|
||||||
return new RunTurnCommandDto
|
return new RunTurnCommandDto
|
||||||
{
|
{
|
||||||
RequestId = requestId,
|
RequestId = requestId,
|
||||||
SessionId = "session-1",
|
SessionId = "session-1",
|
||||||
|
Tooling = mcpServers is null
|
||||||
|
? null
|
||||||
|
: new RunTurnToolingConfigDto
|
||||||
|
{
|
||||||
|
McpServers = [.. mcpServers],
|
||||||
|
},
|
||||||
Pattern = new PatternDefinitionDto
|
Pattern = new PatternDefinitionDto
|
||||||
{
|
{
|
||||||
Id = "pattern-1",
|
Id = "pattern-1",
|
||||||
@@ -1875,7 +1989,9 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
AgentIds = ["agent-1"],
|
AgentIds = ["agent-1"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
AutoApprovedToolNames = ["web_fetch"],
|
AutoApprovedToolNames = autoApprovedToolNames is null
|
||||||
|
? ["web_fetch"]
|
||||||
|
: [.. autoApprovedToolNames],
|
||||||
},
|
},
|
||||||
Agents =
|
Agents =
|
||||||
[
|
[
|
||||||
@@ -1885,6 +2001,13 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static RunTurnMcpServerConfigDto CreateMcpServerConfig(string serverName)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = serverName,
|
||||||
|
Name = serverName,
|
||||||
|
};
|
||||||
|
|
||||||
private sealed class StubChatClient : IChatClient
|
private sealed class StubChatClient : IChatClient
|
||||||
{
|
{
|
||||||
public Task<ChatResponse> GetResponseAsync(
|
public Task<ChatResponse> GetResponseAsync(
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export class AutoUpdateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.started || !this.options.isPackaged) {
|
if (this.started) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-2
@@ -10,7 +10,7 @@ import { NewSessionModal } from '@renderer/components/NewSessionModal';
|
|||||||
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
|
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
|
||||||
import { BookmarksPanel } from '@renderer/components/BookmarksPanel';
|
import { BookmarksPanel } from '@renderer/components/BookmarksPanel';
|
||||||
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
|
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
|
||||||
import { SettingsPanel } from '@renderer/components/SettingsPanel';
|
import { SettingsPanel, type SettingsSection } from '@renderer/components/SettingsPanel';
|
||||||
import { Sidebar } from '@renderer/components/Sidebar';
|
import { Sidebar } from '@renderer/components/Sidebar';
|
||||||
import { TerminalPanel, DEFAULT_HEIGHT as DEFAULT_TERMINAL_HEIGHT, MIN_HEIGHT as MIN_TERMINAL_HEIGHT } from '@renderer/components/TerminalPanel';
|
import { TerminalPanel, DEFAULT_HEIGHT as DEFAULT_TERMINAL_HEIGHT, MIN_HEIGHT as MIN_TERMINAL_HEIGHT } from '@renderer/components/TerminalPanel';
|
||||||
import { resolveChatToolingSettings } from '@renderer/lib/chatTooling';
|
import { resolveChatToolingSettings } from '@renderer/lib/chatTooling';
|
||||||
@@ -46,6 +46,7 @@ import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/proje
|
|||||||
import { applySessionModelConfig } from '@shared/domain/session';
|
import { applySessionModelConfig } from '@shared/domain/session';
|
||||||
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
|
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
|
import type { UpdateStatus } from '@shared/contracts/ipc';
|
||||||
import { createId, nowIso } from '@shared/utils/ids';
|
import { createId, nowIso } from '@shared/utils/ids';
|
||||||
|
|
||||||
function createDraftPattern(defaultModelId: string, defaultReasoningEffort: PatternDefinition['agents'][0]['reasoningEffort']): PatternDefinition {
|
function createDraftPattern(defaultModelId: string, defaultReasoningEffort: PatternDefinition['agents'][0]['reasoningEffort']): PatternDefinition {
|
||||||
@@ -113,6 +114,8 @@ export default function App() {
|
|||||||
const [activeSubagents, setActiveSubagents] = useState<ActiveSubagentMap>({});
|
const [activeSubagents, setActiveSubagents] = useState<ActiveSubagentMap>({});
|
||||||
|
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [settingsSection, setSettingsSection] = useState<SettingsSection>();
|
||||||
|
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({ state: 'idle' });
|
||||||
const [projectSettingsId, setProjectSettingsId] = useState<string>();
|
const [projectSettingsId, setProjectSettingsId] = useState<string>();
|
||||||
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
|
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
|
||||||
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
|
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
|
||||||
@@ -188,6 +191,12 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
|
// Subscribe to auto-update status pushes from the main process
|
||||||
|
useEffect(() => {
|
||||||
|
const off = api.onUpdateStatus((status) => setUpdateStatus(status));
|
||||||
|
return off;
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
// Apply theme to the document root
|
// Apply theme to the document root
|
||||||
const themeSetting: AppearanceTheme = workspace?.settings.theme ?? 'dark';
|
const themeSetting: AppearanceTheme = workspace?.settings.theme ?? 'dark';
|
||||||
useTheme(themeSetting);
|
useTheme(themeSetting);
|
||||||
@@ -494,6 +503,15 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [api, workspace]);
|
}, [api, workspace]);
|
||||||
|
|
||||||
|
const handleOpenSettingsAt = useCallback((section?: SettingsSection) => {
|
||||||
|
setSettingsSection(section);
|
||||||
|
setShowSettings(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInstallUpdate = useCallback(() => {
|
||||||
|
void api.installUpdate();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
// Listen for tray "Quick Scratchpad" action
|
// Listen for tray "Quick Scratchpad" action
|
||||||
const scratchpadRef = useRef(handleCreateScratchpad);
|
const scratchpadRef = useRef(handleCreateScratchpad);
|
||||||
scratchpadRef.current = handleCreateScratchpad;
|
scratchpadRef.current = handleCreateScratchpad;
|
||||||
@@ -637,8 +655,9 @@ export default function App() {
|
|||||||
const overlay = showSettings ? (
|
const overlay = showSettings ? (
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
availableModels={availableModels}
|
availableModels={availableModels}
|
||||||
|
initialSection={settingsSection}
|
||||||
isRefreshingCapabilities={isRefreshingCapabilities}
|
isRefreshingCapabilities={isRefreshingCapabilities}
|
||||||
onClose={() => setShowSettings(false)}
|
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
|
||||||
onDeleteLspProfile={async (id) => {
|
onDeleteLspProfile={async (id) => {
|
||||||
await api.deleteLspProfile(id);
|
await api.deleteLspProfile(id);
|
||||||
}}
|
}}
|
||||||
@@ -741,6 +760,9 @@ export default function App() {
|
|||||||
onRefreshGitContext={(projectId) => {
|
onRefreshGitContext={(projectId) => {
|
||||||
void api.refreshProjectGitContext(projectId);
|
void api.refreshProjectGitContext(projectId);
|
||||||
}}
|
}}
|
||||||
|
updateStatus={updateStatus}
|
||||||
|
onViewUpdateDetails={() => handleOpenSettingsAt('troubleshooting')}
|
||||||
|
onInstallUpdate={handleInstallUpdate}
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface SettingsPanelProps {
|
|||||||
toolingSettings: WorkspaceToolingSettings;
|
toolingSettings: WorkspaceToolingSettings;
|
||||||
discoveredUserTooling: DiscoveredToolingState;
|
discoveredUserTooling: DiscoveredToolingState;
|
||||||
isRefreshingCapabilities: boolean;
|
isRefreshingCapabilities: boolean;
|
||||||
|
initialSection?: SettingsSection;
|
||||||
onRefreshCapabilities: () => void;
|
onRefreshCapabilities: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
|
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
|
||||||
@@ -51,7 +52,7 @@ interface SettingsPanelProps {
|
|||||||
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
|
export type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: SettingsSection;
|
id: SettingsSection;
|
||||||
@@ -111,6 +112,7 @@ export function SettingsPanel({
|
|||||||
toolingSettings,
|
toolingSettings,
|
||||||
discoveredUserTooling,
|
discoveredUserTooling,
|
||||||
isRefreshingCapabilities,
|
isRefreshingCapabilities,
|
||||||
|
initialSection,
|
||||||
onRefreshCapabilities,
|
onRefreshCapabilities,
|
||||||
onClose,
|
onClose,
|
||||||
onSavePattern,
|
onSavePattern,
|
||||||
@@ -132,7 +134,7 @@ export function SettingsPanel({
|
|||||||
onResolveUserDiscoveredTooling,
|
onResolveUserDiscoveredTooling,
|
||||||
onGetQuota,
|
onGetQuota,
|
||||||
}: SettingsPanelProps) {
|
}: SettingsPanelProps) {
|
||||||
const [activeSection, setActiveSection] = useState<SettingsSection>('appearance');
|
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
|
||||||
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
||||||
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
|
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
|
||||||
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
|
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ import { isScratchpadProject, type ProjectRecord, type ProjectGitContext } from
|
|||||||
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
import { querySessions } from '@shared/domain/sessionLibrary';
|
import { querySessions } from '@shared/domain/sessionLibrary';
|
||||||
|
import type { UpdateStatus } from '@shared/contracts/ipc';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
|
import { UpdateBanner } from '@renderer/components/ui';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
workspace: WorkspaceState;
|
workspace: WorkspaceState;
|
||||||
@@ -50,6 +52,9 @@ interface SidebarProps {
|
|||||||
onSetSessionArchived: (sessionId: string, isArchived: boolean) => void;
|
onSetSessionArchived: (sessionId: string, isArchived: boolean) => void;
|
||||||
onDeleteSession: (sessionId: string) => void;
|
onDeleteSession: (sessionId: string) => void;
|
||||||
onRefreshGitContext: (projectId: string) => void;
|
onRefreshGitContext: (projectId: string) => void;
|
||||||
|
updateStatus?: UpdateStatus;
|
||||||
|
onViewUpdateDetails?: () => void;
|
||||||
|
onInstallUpdate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Mode icon + accent colour mapping ─────────────────────── */
|
/* ── Mode icon + accent colour mapping ─────────────────────── */
|
||||||
@@ -120,7 +125,7 @@ function GitContextBadge({ git }: { git: ProjectGitContext }) {
|
|||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]" title={parts.join(' · ') || branchLabel}>
|
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]" title={parts.join(' · ') || branchLabel}>
|
||||||
<GitBranch className="size-2.5 shrink-0" />
|
<GitBranch className="size-2.5 shrink-0" />
|
||||||
<span className="max-w-[80px] truncate">{branchLabel}</span>
|
<span className="max-w-[140px] truncate font-mono">{branchLabel}</span>
|
||||||
{git.isDirty && <Circle className="size-1.5 shrink-0 fill-amber-500 text-amber-500" />}
|
{git.isDirty && <Circle className="size-1.5 shrink-0 fill-amber-500 text-amber-500" />}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -396,76 +401,93 @@ function ProjectGroup({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
className="group flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-[13px] font-semibold text-[var(--color-text-secondary)] transition-all duration-150 hover:bg-[var(--color-surface-2)]/40 hover:text-[var(--color-text-primary)]"
|
className="group flex w-full flex-col gap-0.5 rounded-lg px-2 py-2 text-left transition-all duration-150 hover:bg-[var(--color-surface-2)]/40"
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
type="button"
|
type="button"
|
||||||
|
title={`${project.name}\n${project.path}`}
|
||||||
>
|
>
|
||||||
{expanded ? (
|
{/* Row 1 — project identity + hover actions */}
|
||||||
<ChevronDown className="size-3 shrink-0 text-[var(--color-text-muted)]" />
|
<div className="flex w-full items-center gap-2 text-[13px] font-semibold text-[var(--color-text-secondary)] group-hover:text-[var(--color-text-primary)]">
|
||||||
) : (
|
{expanded ? (
|
||||||
<ChevronRight className="size-3 shrink-0 text-[var(--color-text-muted)]" />
|
<ChevronDown className="size-3 shrink-0 text-[var(--color-text-muted)]" />
|
||||||
)}
|
) : (
|
||||||
{isScratchpad ? (
|
<ChevronRight className="size-3 shrink-0 text-[var(--color-text-muted)]" />
|
||||||
<MessageSquare className="size-3.5 shrink-0 text-[var(--color-text-muted)] transition group-hover:text-[var(--color-accent)]" />
|
)}
|
||||||
) : (
|
{isScratchpad ? (
|
||||||
<FolderOpen className="size-3.5 shrink-0 text-[var(--color-text-muted)] transition group-hover:text-[var(--color-accent)]" />
|
<MessageSquare className="size-3.5 shrink-0 text-[var(--color-text-muted)] transition group-hover:text-[var(--color-accent)]" />
|
||||||
)}
|
) : (
|
||||||
<span className="truncate">{project.name}</span>
|
<FolderOpen className="size-3.5 shrink-0 text-[var(--color-text-muted)] transition group-hover:text-[var(--color-accent)]" />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate">{project.name}</span>
|
||||||
|
|
||||||
{!isScratchpad && project.git && (
|
{isScratchpad && (
|
||||||
<GitContextBadge git={project.git} />
|
<span className="shrink-0 rounded-full bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||||
)}
|
{visibleSessions.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-1.5">
|
{!isScratchpad && (onOpenProjectSettings || onRefreshGitContext) && (
|
||||||
{!isScratchpad && onOpenProjectSettings && (
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
<span
|
{onOpenProjectSettings && (
|
||||||
className="flex size-5 items-center justify-center rounded text-[var(--color-text-muted)] opacity-0 transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)] group-hover:opacity-100"
|
<span
|
||||||
onClick={(e) => {
|
className="flex size-5 items-center justify-center rounded text-[var(--color-text-muted)] opacity-0 transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)] group-hover:opacity-100"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
onOpenProjectSettings(project.id);
|
e.stopPropagation();
|
||||||
}}
|
onOpenProjectSettings(project.id);
|
||||||
role="button"
|
}}
|
||||||
title="Project settings"
|
role="button"
|
||||||
>
|
title="Project settings"
|
||||||
<Settings className="size-3" />
|
>
|
||||||
</span>
|
<Settings className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{onRefreshGitContext && (
|
||||||
|
<span
|
||||||
|
className="flex size-5 items-center justify-center rounded text-[var(--color-text-muted)] opacity-0 transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)] group-hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRefreshGitContext(project.id);
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
title="Refresh git status"
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isScratchpad && onRefreshGitContext && (
|
|
||||||
<span
|
|
||||||
className="flex size-5 items-center justify-center rounded text-[var(--color-text-muted)] opacity-0 transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)] group-hover:opacity-100"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRefreshGitContext(project.id);
|
|
||||||
}}
|
|
||||||
role="button"
|
|
||||||
title="Refresh git status"
|
|
||||||
>
|
|
||||||
<RefreshCw className="size-3" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{runningCount > 0 && (
|
|
||||||
<span className="flex items-center gap-1 rounded-full bg-[var(--color-accent-sky)]/10 px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent-sky)]">
|
|
||||||
<span className="size-1.5 rounded-full bg-[var(--color-accent-sky)] sidebar-pulse" />
|
|
||||||
{runningCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{pendingDiscoveryCount > 0 && (
|
|
||||||
<span
|
|
||||||
className="flex cursor-pointer items-center gap-1 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 transition hover:bg-amber-500/20"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpenProjectSettings?.(project.id);
|
|
||||||
}}
|
|
||||||
role="button"
|
|
||||||
title={`${pendingDiscoveryCount} MCP server${pendingDiscoveryCount === 1 ? '' : 's'} discovered — click to review`}
|
|
||||||
>
|
|
||||||
{pendingDiscoveryCount} new
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="rounded-full bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
|
||||||
{visibleSessions.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2 — metadata strip: branch, status badges, session count */}
|
||||||
|
{!isScratchpad && (project.git || runningCount > 0 || pendingDiscoveryCount > 0 || visibleSessions.length > 0) && (
|
||||||
|
<div className="ml-[26px] flex items-center gap-2">
|
||||||
|
{project.git && <GitContextBadge git={project.git} />}
|
||||||
|
|
||||||
|
{runningCount > 0 && (
|
||||||
|
<span className="flex items-center gap-1 rounded-full bg-[var(--color-accent-sky)]/10 px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent-sky)]">
|
||||||
|
<span className="size-1.5 rounded-full bg-[var(--color-accent-sky)] sidebar-pulse" />
|
||||||
|
{runningCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{pendingDiscoveryCount > 0 && (
|
||||||
|
<span
|
||||||
|
className="flex cursor-pointer items-center gap-1 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 transition hover:bg-amber-500/20"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenProjectSettings?.(project.id);
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
title={`${pendingDiscoveryCount} MCP server${pendingDiscoveryCount === 1 ? '' : 's'} discovered — click to review`}
|
||||||
|
>
|
||||||
|
{pendingDiscoveryCount} new
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto rounded-full bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||||
|
{visibleSessions.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
@@ -523,6 +545,9 @@ export function Sidebar({
|
|||||||
onSetSessionArchived,
|
onSetSessionArchived,
|
||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
onRefreshGitContext,
|
onRefreshGitContext,
|
||||||
|
updateStatus,
|
||||||
|
onViewUpdateDetails,
|
||||||
|
onInstallUpdate,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const scratchpadProject = workspace.projects.find((project) => isScratchpadProject(project));
|
const scratchpadProject = workspace.projects.find((project) => isScratchpadProject(project));
|
||||||
const userProjects = workspace.projects.filter((project) => !isScratchpadProject(project));
|
const userProjects = workspace.projects.filter((project) => !isScratchpadProject(project));
|
||||||
@@ -735,6 +760,15 @@ export function Sidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Update notification banner */}
|
||||||
|
{updateStatus && onViewUpdateDetails && onInstallUpdate && (
|
||||||
|
<UpdateBanner
|
||||||
|
status={updateStatus}
|
||||||
|
onViewDetails={onViewUpdateDetails}
|
||||||
|
onInstallUpdate={onInstallUpdate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
{userProjects.length > 0 && (
|
{userProjects.length > 0 && (
|
||||||
<div className="border-t border-[var(--color-border-subtle)] px-3 py-2">
|
<div className="border-t border-[var(--color-border-subtle)] px-3 py-2">
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
Server,
|
Server,
|
||||||
Terminal,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import type { PermissionDetail } from '@shared/contracts/sidecar';
|
import type { PermissionDetail } from '@shared/contracts/sidecar';
|
||||||
@@ -61,6 +60,37 @@ export function permissionDetailSummary(detail: PermissionDetail): string | unde
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Display helpers ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Recursively parse string values that contain JSON objects or arrays (display-time only). */
|
||||||
|
function deepParseJsonStrings(value: unknown): unknown {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (
|
||||||
|
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
||||||
|
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return deepParseJsonStrings(JSON.parse(trimmed));
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(deepParseJsonStrings);
|
||||||
|
}
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
result[k] = deepParseJsonStrings(v);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Kind-specific renderers ────────────────────────────────── */
|
/* ── Kind-specific renderers ────────────────────────────────── */
|
||||||
|
|
||||||
function ShellDetail({ detail }: { detail: PermissionDetail }) {
|
function ShellDetail({ detail }: { detail: PermissionDetail }) {
|
||||||
@@ -134,7 +164,7 @@ function McpDetail({ detail }: { detail: PermissionDetail }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{detail.args && Object.keys(detail.args).length > 0 && (
|
{detail.args && Object.keys(detail.args).length > 0 && (
|
||||||
<CollapsibleCode label="Arguments" text={JSON.stringify(detail.args, null, 2)} />
|
<CollapsibleCode label="Arguments" text={JSON.stringify(deepParseJsonStrings(detail.args), null, 2)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -185,7 +215,7 @@ function CustomToolDetail({ detail }: { detail: PermissionDetail }) {
|
|||||||
<p className="text-[11px] text-[var(--color-text-secondary)]">{detail.toolDescription}</p>
|
<p className="text-[11px] text-[var(--color-text-secondary)]">{detail.toolDescription}</p>
|
||||||
)}
|
)}
|
||||||
{detail.args && Object.keys(detail.args).length > 0 && (
|
{detail.args && Object.keys(detail.args).length > 0 && (
|
||||||
<CollapsibleCode label="Arguments" text={JSON.stringify(detail.args, null, 2)} />
|
<CollapsibleCode label="Arguments" text={JSON.stringify(deepParseJsonStrings(detail.args), null, 2)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -201,13 +231,13 @@ function HookDetail({ detail }: { detail: PermissionDetail }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{detail.args && Object.keys(detail.args).length > 0 && (
|
{detail.args && Object.keys(detail.args).length > 0 && (
|
||||||
<CollapsibleCode label="Arguments" text={JSON.stringify(detail.args, null, 2)} />
|
<CollapsibleCode label="Arguments" text={JSON.stringify(deepParseJsonStrings(detail.args), null, 2)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Shared primitives ──────────────────────────────────────── */
|
/* ── Shared primitives──────────────────────────────────────── */
|
||||||
|
|
||||||
function IntentionLine({ text }: { text: string }) {
|
function IntentionLine({ text }: { text: string }) {
|
||||||
return <p className="text-[11px] italic text-[var(--color-text-secondary)]">{text}</p>;
|
return <p className="text-[11px] italic text-[var(--color-text-secondary)]">{text}</p>;
|
||||||
@@ -242,6 +272,47 @@ function DiffBlock({ text }: { text: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── JSON syntax highlighting ───────────────────────────────── */
|
||||||
|
|
||||||
|
const jsonTokenPattern =
|
||||||
|
/("(?:[^"\\]|\\.)*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|([{}\[\],])/g;
|
||||||
|
|
||||||
|
function JsonHighlighted({ json }: { json: string }) {
|
||||||
|
const elements: React.ReactNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let key = 0;
|
||||||
|
|
||||||
|
for (const match of json.matchAll(jsonTokenPattern)) {
|
||||||
|
const idx = match.index ?? 0;
|
||||||
|
if (idx > lastIndex) elements.push(json.slice(lastIndex, idx));
|
||||||
|
|
||||||
|
if (match[1] && match[2]) {
|
||||||
|
// Object key + colon
|
||||||
|
elements.push(
|
||||||
|
<span key={key++} className="text-[var(--color-text-accent)]">{match[1]}</span>,
|
||||||
|
<span key={key++} className="text-[var(--color-text-muted)]">{match[2]}</span>,
|
||||||
|
);
|
||||||
|
} else if (match[1]) {
|
||||||
|
// String value
|
||||||
|
elements.push(<span key={key++} className="text-[var(--color-status-success)]">{match[1]}</span>);
|
||||||
|
} else if (match[3]) {
|
||||||
|
// true / false / null
|
||||||
|
elements.push(<span key={key++} className="text-[var(--color-accent-sky)]">{match[3]}</span>);
|
||||||
|
} else if (match[4]) {
|
||||||
|
// Number
|
||||||
|
elements.push(<span key={key++} className="text-[var(--color-accent-sky)]">{match[4]}</span>);
|
||||||
|
} else if (match[5]) {
|
||||||
|
// Structural punctuation
|
||||||
|
elements.push(<span key={key++} className="text-[var(--color-text-muted)]">{match[5]}</span>);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = idx + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < json.length) elements.push(json.slice(lastIndex));
|
||||||
|
return <>{elements}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function CollapsibleCode({
|
function CollapsibleCode({
|
||||||
label,
|
label,
|
||||||
text,
|
text,
|
||||||
@@ -254,6 +325,7 @@ function CollapsibleCode({
|
|||||||
defaultExpanded?: boolean;
|
defaultExpanded?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||||
|
const isJson = text.trimStart().startsWith('{') || text.trimStart().startsWith('[');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]">
|
<div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]">
|
||||||
@@ -272,7 +344,7 @@ function CollapsibleCode({
|
|||||||
<div className="border-t border-[var(--color-border-subtle)] px-2.5 py-1.5">
|
<div className="border-t border-[var(--color-border-subtle)] px-2.5 py-1.5">
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<pre className="max-h-48 overflow-auto font-mono text-[10px] leading-relaxed text-[var(--color-text-primary)]">
|
<pre className="max-h-48 overflow-auto font-mono text-[10px] leading-relaxed text-[var(--color-text-primary)]">
|
||||||
{text}
|
{isJson ? <JsonHighlighted json={text} /> : text}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ArrowDownToLine, Download, RefreshCw, Sparkles, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { UpdateStatus } from '@shared/contracts/ipc';
|
||||||
|
|
||||||
|
export interface UpdateBannerProps {
|
||||||
|
status: UpdateStatus;
|
||||||
|
onViewDetails: () => void;
|
||||||
|
onInstallUpdate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdateBanner({ status, onViewDetails, onInstallUpdate }: UpdateBannerProps) {
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
const isActionable =
|
||||||
|
status.state === 'available' ||
|
||||||
|
status.state === 'downloading' ||
|
||||||
|
status.state === 'downloaded';
|
||||||
|
|
||||||
|
// Nothing to show
|
||||||
|
if (!isActionable) return null;
|
||||||
|
|
||||||
|
// Allow dismissal for transient states, never for downloaded
|
||||||
|
if (dismissed && status.state !== 'downloaded') return null;
|
||||||
|
|
||||||
|
const version = status.version ? `v${status.version}` : '';
|
||||||
|
|
||||||
|
if (status.state === 'downloaded') {
|
||||||
|
return (
|
||||||
|
<div className="update-banner-enter px-3 pb-2" role="alert">
|
||||||
|
<button
|
||||||
|
className="group relative flex w-full items-center gap-2.5 overflow-hidden rounded-xl border border-[var(--color-status-success)]/25 bg-[var(--color-status-success)]/[0.07] px-3 py-2.5 text-left transition-all duration-200 hover:border-[var(--color-status-success)]/40 hover:bg-[var(--color-status-success)]/[0.12]"
|
||||||
|
onClick={onInstallUpdate}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{/* Subtle glow effect */}
|
||||||
|
<div className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100" style={{ background: 'radial-gradient(ellipse at center, rgba(52, 211, 153, 0.08), transparent 70%)' }} />
|
||||||
|
|
||||||
|
<span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-[var(--color-status-success)]/15">
|
||||||
|
<Sparkles className="size-3.5 text-[var(--color-status-success)]" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="block text-[12px] font-semibold text-[var(--color-status-success)]">
|
||||||
|
Update ready {version}
|
||||||
|
</span>
|
||||||
|
<span className="block text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
Restart to apply
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded-lg bg-[var(--color-status-success)]/15 px-2 py-1 text-[10px] font-semibold text-[var(--color-status-success)] transition-all duration-200 group-hover:bg-[var(--color-status-success)]/25">
|
||||||
|
<RefreshCw className="inline-block size-3 mr-1 align-[-2px]" />
|
||||||
|
Restart
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// available / downloading
|
||||||
|
const isDownloading = status.state === 'downloading';
|
||||||
|
const percent = status.downloadProgress ? Math.round(status.downloadProgress.percent) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="update-banner-enter px-3 pb-2" role="status">
|
||||||
|
<div className="relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-2)]/60">
|
||||||
|
<button
|
||||||
|
className="group flex w-full items-center gap-2.5 px-3 py-2 text-left transition-all duration-200 hover:bg-[var(--color-surface-2)]"
|
||||||
|
onClick={onViewDetails}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-[var(--color-accent)]/10">
|
||||||
|
{isDownloading
|
||||||
|
? <Download className="size-3 text-[var(--color-accent)] animate-pulse" />
|
||||||
|
: <ArrowDownToLine className="size-3 text-[var(--color-accent)]" />}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="block text-[11px] font-medium text-[var(--color-text-primary)]">
|
||||||
|
{isDownloading
|
||||||
|
? `Downloading ${version}${percent > 0 ? ` · ${percent}%` : '…'}`
|
||||||
|
: `Update available ${version}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex size-5 shrink-0 items-center justify-center rounded text-[var(--color-text-muted)] opacity-0 transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)] group-hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDismissed(true);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Download progress bar */}
|
||||||
|
{isDownloading && percent > 0 && (
|
||||||
|
<div className="h-[2px] w-full bg-[var(--color-surface-3)]">
|
||||||
|
<div
|
||||||
|
className="h-full bg-[var(--color-accent)] transition-[width] duration-500 ease-out"
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,3 +7,5 @@ export { TextInput } from './TextInput';
|
|||||||
export { TextareaInput } from './TextareaInput';
|
export { TextareaInput } from './TextareaInput';
|
||||||
export { SelectInput } from './SelectInput';
|
export { SelectInput } from './SelectInput';
|
||||||
export { InfoCallout } from './InfoCallout';
|
export { InfoCallout } from './InfoCallout';
|
||||||
|
export type { UpdateBannerProps } from './UpdateBanner';
|
||||||
|
export { UpdateBanner } from './UpdateBanner';
|
||||||
|
|||||||
@@ -626,6 +626,19 @@ body {
|
|||||||
animation: banner-slide-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
animation: banner-slide-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Update banner slide-up ──────────────────────────────────── */
|
||||||
|
|
||||||
|
@keyframes update-banner-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-banner-enter {
|
||||||
|
animation: update-banner-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Thinking process section ────────────────────────────────── */
|
/* ── Thinking process section ────────────────────────────────── */
|
||||||
|
|
||||||
@keyframes thinking-process-in {
|
@keyframes thinking-process-in {
|
||||||
@@ -651,6 +664,7 @@ body {
|
|||||||
.msg-actions-enter,
|
.msg-actions-enter,
|
||||||
.session-item-enter,
|
.session-item-enter,
|
||||||
.banner-slide-enter,
|
.banner-slide-enter,
|
||||||
|
.update-banner-enter,
|
||||||
.thinking-process-enter {
|
.thinking-process-enter {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,18 +64,20 @@ class FakeScheduler implements AutoUpdateScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('AutoUpdateService', () => {
|
describe('AutoUpdateService', () => {
|
||||||
test('does not schedule checks for unpackaged apps but manual checks still work', async () => {
|
test('schedules checks for unpackaged apps using dev update config', 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 });
|
||||||
|
|
||||||
service.start();
|
service.start();
|
||||||
|
|
||||||
expect(scheduler.timeouts).toHaveLength(0);
|
|
||||||
expect(scheduler.intervals).toHaveLength(0);
|
|
||||||
expect(updater.forceDevUpdateConfig).toBe(true);
|
expect(updater.forceDevUpdateConfig).toBe(true);
|
||||||
|
expect(scheduler.timeouts).toEqual([{ callback: expect.any(Function), delayMs: 10_000 }]);
|
||||||
|
expect(scheduler.intervals).toEqual([{ callback: expect.any(Function), delayMs: 4 * 60 * 60 * 1000 }]);
|
||||||
|
|
||||||
|
await scheduler.runTimeout();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
await service.checkForUpdates();
|
|
||||||
expect(updater.checkForUpdatesCalls).toBe(1);
|
expect(updater.checkForUpdatesCalls).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user