Compare commits

...
8 Commits
Author SHA1 Message Date
David Kaya 9a261780c6 chore: bump version to 0.0.19 2026-03-31 11:09:12 +02:00
David KayaandCopilot 9b7e4dd6e9 feat: format and syntax-highlight JSON arguments in approval popup
- Add deepParseJsonStrings utility to recursively unwrap JSON-encoded
  string values inside tool-call args before display
- Apply to McpDetail, CustomToolDetail, and HookDetail renderers
- Add lightweight regex-based JSON syntax highlighting (keys, strings,
  numbers, booleans, punctuation) in CollapsibleCode when content is JSON
- Remove unused Terminal import

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 10:43:28 +02:00
David KayaandCopilot 4bc2c327f7 fix: honor MCP server auto-approvals in hook flow
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 10:11:15 +02:00
David Kaya 36126f1c74 chore: bump version to 0.0.18 2026-03-30 17:20:40 +02:00
David KayaandCopilot bec50da2b4 fix: add DPI awareness to NSIS installer
Add ManifestDPIAware true via a custom NSIS include file so the
installer renders at native resolution on high-DPI displays.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 17:19:58 +02:00
David KayaandCopilot 535adc64be fix: enable background update checks in dev builds
Remove the isPackaged guard from AutoUpdateService.start() so that
background update checks run in both packaged and dev builds.
forceDevUpdateConfig already handles dev-mode config correctly via
dev-app-update.yml — the start() gate was preventing the 10s initial
check and 4h periodic checks from ever being scheduled in dev.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 17:18:02 +02:00
David KayaandCopilot 772c84fed3 feat: add in-app update notification banner to sidebar
Subscribe to auto-update status at the App level and render a compact
UpdateBanner in the sidebar footer when an update is available,
downloading, or downloaded:

- Available/downloading: subtle banner with progress bar, dismissable
- Downloaded: prominent 'Restart to update' action with glow effect

Clicking the banner opens Settings directly on the Troubleshooting
section via a new initialSection prop on SettingsPanel.

New files:
- src/renderer/components/ui/UpdateBanner.tsx

Modified files:
- App.tsx: subscribe to onUpdateStatus, wire props
- Sidebar.tsx: accept and render UpdateBanner
- SettingsPanel.tsx: add initialSection prop + export SettingsSection type
- styles.css: add update-banner-enter slide-up animation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 17:15:38 +02:00
David KayaandCopilot 9647b5fdb5 fix: restructure sidebar project header to prevent name truncation
Restructure the ProjectGroup header from a single cramped line into a
two-row layout so the project name gets adequate space:

- Row 1: chevron + icon + project name (min-w-0 flex-1) + hover actions
- Row 2: git branch badge + running/discovery/session count badges

Additional polish:
- Branch label uses font-mono (JetBrains Mono) for developer feel
- Branch max-width increased from 80px to 140px for better readability
- Project name tooltip shows full name and path on hover
- Scratchpad keeps session count inline on the identity row

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 17:02:47 +02:00
15 changed files with 662 additions and 109 deletions
+1
View File
@@ -0,0 +1 @@
ManifestDPIAware true
+3 -2
View File
@@ -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(
+1 -1
View File
@@ -192,7 +192,7 @@ export class AutoUpdateService {
} }
start(): void { start(): void {
if (this.started || !this.options.isPackaged) { if (this.started) {
return; return;
} }
+24 -2
View File
@@ -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}
/> />
} }
+4 -2
View File
@@ -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);
+98 -64
View File
@@ -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>
+108
View File
@@ -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>
);
}
+2
View File
@@ -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';
+14
View File
@@ -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;
} }
+6 -4
View File
@@ -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);
}); });