mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-28 23:48:39 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5946dc75ba | ||
|
|
59d3c81f9f | ||
|
|
deb5c96d58 | ||
|
|
56de8b7bd6 | ||
|
|
38dd358755 | ||
|
|
770a0f3529 | ||
|
|
e37d69bd63 | ||
|
|
c01a427d8a | ||
|
|
01b3949557 | ||
|
|
c01110979c | ||
|
|
a034b333b3 | ||
|
|
41289c960b | ||
|
|
33b293271e | ||
|
|
cbcf239a0a | ||
|
|
c702cf88e2 | ||
|
|
8a4d23c22a | ||
|
|
042cec6065 |
@@ -161,6 +161,8 @@ A session is the working unit of the product. It binds together:
|
|||||||
|
|
||||||
This is how Aryx keeps "ongoing work" first class. Sessions can survive restarts, can be organized, and can accumulate operational history over time.
|
This is how Aryx keeps "ongoing work" first class. Sessions can survive restarts, can be organized, and can accumulate operational history over time.
|
||||||
|
|
||||||
|
Individual messages can be pinned as bookmarks. A dedicated bookmarks panel (`BookmarksPanel`) lists all pinned messages across all sessions globally, navigating to the originating session and message on selection. This data is derived renderer-side from the workspace state; there is no separate backend API.
|
||||||
|
|
||||||
### Runs
|
### Runs
|
||||||
|
|
||||||
Each user turn becomes a **run**. A run is more than the final assistant output; it also tracks:
|
Each user turn becomes a **run**. A run is more than the final assistant output; it also tracks:
|
||||||
@@ -212,6 +214,8 @@ The protocol also carries **turn-scoped lifecycle events** alongside output delt
|
|||||||
|
|
||||||
- **Sub-agent events**: started, completed, failed, selected, deselected — surfaced when custom agents are defined
|
- **Sub-agent events**: started, completed, failed, selected, deselected — surfaced when custom agents are defined
|
||||||
- **Skill invocation events**: emitted when an agent-side skill is triggered
|
- **Skill invocation events**: emitted when an agent-side skill is triggered
|
||||||
|
- **Message reclassification events**: let the sidecar retroactively mark a streamed assistant message as `thinking` once the SDK confirms that message requested tool work, so the UI can separate intermediate planning chatter from the final response without sacrificing live streaming
|
||||||
|
- **Assistant intent and reasoning-delta events**: optional Copilot SDK metadata that exposes short "what I'm doing" labels plus incremental reasoning text for richer thinking-process surfaces
|
||||||
- **Hook lifecycle events**: start and end of configured project hook commands discovered from `.github/hooks/*.json`; Aryx suppresses the SDK's built-in no-op hook chatter so the UI only sees meaningful hook activity
|
- **Hook lifecycle events**: start and end of configured project hook commands discovered from `.github/hooks/*.json`; Aryx suppresses the SDK's built-in no-op hook chatter so the UI only sees meaningful hook activity
|
||||||
- **Assistant usage events**: per-LLM-call tokens, cost, AIU, and quota snapshots from the Copilot SDK's `assistant.usage` stream
|
- **Assistant usage events**: per-LLM-call tokens, cost, AIU, and quota snapshots from the Copilot SDK's `assistant.usage` stream
|
||||||
- **Session compaction events**: start and complete, with token-reduction metrics when infinite sessions trigger context trimming
|
- **Session compaction events**: start and complete, with token-reduction metrics when infinite sessions trigger context trimming
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/davidkaya/aryx/releases">Download</a> · <a href="https://aryx.dev">Website</a> · <a href="https://github.com/davidkaya/aryx/issues">Issues</a>
|
<a href="https://github.com/davidkaya/aryx/releases">Download</a> · <a href="https://aryx.app">Website</a> · <a href="https://github.com/davidkaya/aryx/issues">Issues</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -43,6 +43,7 @@ Aryx is a desktop app that turns GitHub Copilot into a full workspace. Connect r
|
|||||||
| Session branching | Fork a session at any user message to explore a different direction |
|
| Session branching | Fork a session at any user message to explore a different direction |
|
||||||
| Session search | Full-text search across all session messages, not just titles |
|
| Session search | Full-text search across all session messages, not just titles |
|
||||||
| Message actions | Copy, pin, edit-and-resend, and regenerate individual messages |
|
| Message actions | Copy, pin, edit-and-resend, and regenerate individual messages |
|
||||||
|
| Bookmarks | Browse all pinned messages across sessions in one panel (`Ctrl+Shift+B`) |
|
||||||
| System tray | Minimize to tray, quick-launch scratchpads, and see running session count |
|
| System tray | Minimize to tray, quick-launch scratchpads, and see running session count |
|
||||||
| Desktop notifications | Native OS alerts when runs complete, fail, or need approval |
|
| Desktop notifications | Native OS alerts when runs complete, fail, or need approval |
|
||||||
| Onboarding | First-launch walkthrough, interactive tooltips, and a "try it" quickstart |
|
| Onboarding | First-launch walkthrough, interactive tooltips, and a "try it" quickstart |
|
||||||
@@ -94,3 +95,7 @@ bun run publish-release # publish to GitHub Releases
|
|||||||
```
|
```
|
||||||
|
|
||||||
Tagged releases use GitHub Actions to build and publish Windows (NSIS), macOS (DMG, signed + notarized), and Linux (AppImage) artifacts. The app uses `electron-updater` for in-app updates.
|
Tagged releases use GitHub Actions to build and publish Windows (NSIS), macOS (DMG, signed + notarized), and Linux (AppImage) artifacts. The app uses `electron-updater` for in-app updates.
|
||||||
|
|
||||||
|
## Trademarks
|
||||||
|
|
||||||
|
GitHub and GitHub Copilot are trademarks of Microsoft Corporation. Aryx is an independent project, not affiliated with or endorsed by Microsoft or GitHub.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
provider: github
|
||||||
|
owner: davidkaya
|
||||||
|
repo: aryx
|
||||||
|
releaseType: release
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aryx",
|
"name": "aryx",
|
||||||
"version": "0.0.13",
|
"version": "0.0.17",
|
||||||
"description": "Electron orchestrator for Copilot-powered agent workflows across multiple projects.",
|
"description": "Electron 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",
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ public sealed class ChatMessageDto
|
|||||||
public string AuthorName { get; init; } = string.Empty;
|
public string AuthorName { get; init; } = string.Empty;
|
||||||
public string Content { get; init; } = string.Empty;
|
public string Content { get; init; } = string.Empty;
|
||||||
public string CreatedAt { get; init; } = string.Empty;
|
public string CreatedAt { get; init; } = string.Empty;
|
||||||
|
public string? MessageKind { get; set; }
|
||||||
public IReadOnlyList<ChatMessageAttachmentDto> Attachments { get; init; } = [];
|
public IReadOnlyList<ChatMessageAttachmentDto> Attachments { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +332,13 @@ public sealed class TurnCompleteEventDto : SidecarEventDto
|
|||||||
public bool Cancelled { get; init; }
|
public bool Cancelled { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class MessageReclassifiedEventDto : SidecarEventDto
|
||||||
|
{
|
||||||
|
public string SessionId { get; init; } = string.Empty;
|
||||||
|
public string MessageId { get; init; } = string.Empty;
|
||||||
|
public string NewKind { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class AgentActivityEventDto : SidecarEventDto
|
public sealed class AgentActivityEventDto : SidecarEventDto
|
||||||
{
|
{
|
||||||
public string SessionId { get; init; } = string.Empty;
|
public string SessionId { get; init; } = string.Empty;
|
||||||
@@ -376,6 +384,23 @@ public sealed class SkillInvokedEventDto : SidecarEventDto
|
|||||||
public string? Description { get; init; }
|
public string? Description { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class AssistantIntentEventDto : SidecarEventDto
|
||||||
|
{
|
||||||
|
public string SessionId { get; init; } = string.Empty;
|
||||||
|
public string? AgentId { get; init; }
|
||||||
|
public string? AgentName { get; init; }
|
||||||
|
public string Intent { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ReasoningDeltaEventDto : SidecarEventDto
|
||||||
|
{
|
||||||
|
public string SessionId { get; init; } = string.Empty;
|
||||||
|
public string? AgentId { get; init; }
|
||||||
|
public string? AgentName { get; init; }
|
||||||
|
public string ReasoningId { get; init; } = string.Empty;
|
||||||
|
public string ContentDelta { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class HookLifecycleEventDto : SidecarEventDto
|
public sealed class HookLifecycleEventDto : SidecarEventDto
|
||||||
{
|
{
|
||||||
public string SessionId { get; init; } = string.Empty;
|
public string SessionId { get; init; } = string.Empty;
|
||||||
|
|||||||
@@ -21,6 +21,24 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
private const string HookPermissionKind = "hook";
|
private const string HookPermissionKind = "hook";
|
||||||
private const string ToolCallingActivityType = "tool-calling";
|
private const string ToolCallingActivityType = "tool-calling";
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> HookToolCategories = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["view"] = ReadPermissionKind,
|
||||||
|
["glob"] = ReadPermissionKind,
|
||||||
|
["grep"] = ReadPermissionKind,
|
||||||
|
["lsp"] = ReadPermissionKind,
|
||||||
|
["edit"] = WritePermissionKind,
|
||||||
|
["create"] = WritePermissionKind,
|
||||||
|
["powershell"] = ShellPermissionKind,
|
||||||
|
["read_powershell"] = ShellPermissionKind,
|
||||||
|
["write_powershell"] = ShellPermissionKind,
|
||||||
|
["stop_powershell"] = ShellPermissionKind,
|
||||||
|
["list_powershell"] = ShellPermissionKind,
|
||||||
|
["web_fetch"] = UrlPermissionKind,
|
||||||
|
["web_search"] = UrlPermissionKind,
|
||||||
|
["store_memory"] = MemoryPermissionKind,
|
||||||
|
};
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, PendingApprovalRequest> _pendingApprovals = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, PendingApprovalRequest> _pendingApprovals = new(StringComparer.Ordinal);
|
||||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _requestApprovedTools = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _requestApprovedTools = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
@@ -140,6 +158,16 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
string permissionKind = string.IsNullOrWhiteSpace(request.Kind)
|
string permissionKind = string.IsNullOrWhiteSpace(request.Kind)
|
||||||
? "tool access"
|
? "tool access"
|
||||||
: request.Kind.Trim();
|
: request.Kind.Trim();
|
||||||
|
|
||||||
|
if (request is PermissionRequestHook hook)
|
||||||
|
{
|
||||||
|
string? resolvedCategory = ResolveHookToolCategory(hook.ToolName);
|
||||||
|
if (resolvedCategory is not null)
|
||||||
|
{
|
||||||
|
permissionKind = resolvedCategory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
|
string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
|
||||||
string? sessionId = NormalizeOptionalString(invocation.SessionId);
|
string? sessionId = NormalizeOptionalString(invocation.SessionId);
|
||||||
string? normalizedToolName = NormalizeOptionalString(toolName);
|
string? normalizedToolName = NormalizeOptionalString(toolName);
|
||||||
@@ -476,10 +504,22 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
PermissionRequestWrite => WritePermissionKind,
|
PermissionRequestWrite => WritePermissionKind,
|
||||||
PermissionRequestRead => ReadPermissionKind,
|
PermissionRequestRead => ReadPermissionKind,
|
||||||
PermissionRequestMemory => StoreMemoryToolName,
|
PermissionRequestMemory => StoreMemoryToolName,
|
||||||
|
PermissionRequestHook hook => ResolveHookToolCategory(hook.ToolName),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static string? ResolveHookToolCategory(string? toolName)
|
||||||
|
{
|
||||||
|
string? normalized = NormalizeOptionalString(toolName);
|
||||||
|
if (normalized is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return HookToolCategories.TryGetValue(normalized, out string? category) ? category : null;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool MatchesAutoApprovedTool(
|
private static bool MatchesAutoApprovedTool(
|
||||||
IReadOnlyList<string> autoApprovedToolNames,
|
IReadOnlyList<string> autoApprovedToolNames,
|
||||||
string? toolName,
|
string? toolName,
|
||||||
|
|||||||
@@ -248,11 +248,13 @@ internal static class CopilotSessionHooks
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string? autoApprovedToolName = CopilotApprovalCoordinator.ResolveHookToolCategory(toolName) ?? toolName;
|
||||||
|
|
||||||
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
|
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||||
command.Pattern.ApprovalPolicy,
|
command.Pattern.ApprovalPolicy,
|
||||||
agentDefinition.Id,
|
agentDefinition.Id,
|
||||||
toolName,
|
toolName,
|
||||||
toolName);
|
autoApprovedToolName);
|
||||||
|
|
||||||
return new PreToolUseHookOutput
|
return new PreToolUseHookOutput
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ internal sealed class CopilotTurnExecutionState
|
|||||||
{
|
{
|
||||||
private readonly RunTurnCommandDto _command;
|
private readonly RunTurnCommandDto _command;
|
||||||
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly HashSet<string> _reclassifiedMessageIds = new(StringComparer.Ordinal);
|
||||||
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
|
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
|
||||||
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
|
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
|
||||||
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
|
||||||
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
|
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
|
||||||
private int _fallbackMessageIndex;
|
private int _fallbackMessageIndex;
|
||||||
|
private string? _lastObservedMessageId;
|
||||||
|
|
||||||
public CopilotTurnExecutionState(RunTurnCommandDto command)
|
public CopilotTurnExecutionState(RunTurnCommandDto command)
|
||||||
{
|
{
|
||||||
@@ -79,15 +81,34 @@ internal sealed class CopilotTurnExecutionState
|
|||||||
case AssistantMessageEvent assistantMessage when !string.IsNullOrWhiteSpace(assistantMessage.Data?.MessageId):
|
case AssistantMessageEvent assistantMessage when !string.IsNullOrWhiteSpace(assistantMessage.Data?.MessageId):
|
||||||
RecordObservedAgentForMessage(agent, assistantMessage.Data!.MessageId);
|
RecordObservedAgentForMessage(agent, assistantMessage.Data!.MessageId);
|
||||||
QueueThinkingIfNeeded(agent);
|
QueueThinkingIfNeeded(agent);
|
||||||
|
if (assistantMessage.Data?.ToolRequests is { Length: > 0 })
|
||||||
|
{
|
||||||
|
QueueMessageReclassifiedIfNeeded(assistantMessage.Data.MessageId);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case ToolExecutionStartEvent toolExecutionStart
|
case ToolExecutionStartEvent toolExecutionStart
|
||||||
when !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolCallId)
|
when !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolCallId)
|
||||||
&& !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolName):
|
&& !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolName):
|
||||||
ToolNamesByCallId[toolExecutionStart.Data.ToolCallId.Trim()] = toolExecutionStart.Data.ToolName.Trim();
|
ToolNamesByCallId[toolExecutionStart.Data.ToolCallId.Trim()] = toolExecutionStart.Data.ToolName.Trim();
|
||||||
|
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
|
||||||
break;
|
break;
|
||||||
case AssistantReasoningDeltaEvent:
|
case AssistantIntentEvent intentEvent:
|
||||||
ActiveAgent = agent;
|
ActiveAgent = agent;
|
||||||
QueueThinkingIfNeeded(agent);
|
QueueThinkingIfNeeded(agent);
|
||||||
|
AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Data);
|
||||||
|
if (assistantIntent is not null)
|
||||||
|
{
|
||||||
|
_pendingEvents.Enqueue(assistantIntent);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case AssistantReasoningDeltaEvent reasoningDelta:
|
||||||
|
ActiveAgent = agent;
|
||||||
|
QueueThinkingIfNeeded(agent);
|
||||||
|
ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent(agent, reasoningDelta.Data);
|
||||||
|
if (reasoningDeltaEvent is not null)
|
||||||
|
{
|
||||||
|
_pendingEvents.Enqueue(reasoningDeltaEvent);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case SubagentStartedEvent started:
|
case SubagentStartedEvent started:
|
||||||
ActiveAgent = agent;
|
ActiveAgent = agent;
|
||||||
@@ -218,6 +239,23 @@ internal sealed class CopilotTurnExecutionState
|
|||||||
{
|
{
|
||||||
ActiveAgent = agent;
|
ActiveAgent = agent;
|
||||||
_observedAgentsByMessageId[messageId] = agent;
|
_observedAgentsByMessageId[messageId] = agent;
|
||||||
|
_lastObservedMessageId = messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QueueMessageReclassifiedIfNeeded(string? messageId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(messageId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string normalizedMessageId = messageId.Trim();
|
||||||
|
if (!_reclassifiedMessageIds.Add(normalizedMessageId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingEvents.Enqueue(CreateMessageReclassifiedEvent(normalizedMessageId));
|
||||||
}
|
}
|
||||||
|
|
||||||
private AgentActivityEventDto? CreateThinkingActivityIfNeeded(AgentIdentity agent)
|
private AgentActivityEventDto? CreateThinkingActivityIfNeeded(AgentIdentity agent)
|
||||||
@@ -240,6 +278,18 @@ internal sealed class CopilotTurnExecutionState
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private MessageReclassifiedEventDto CreateMessageReclassifiedEvent(string messageId)
|
||||||
|
{
|
||||||
|
return new MessageReclassifiedEventDto
|
||||||
|
{
|
||||||
|
Type = "message-reclassified",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
MessageId = messageId,
|
||||||
|
NewKind = "thinking",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdateCompletedMessages(
|
public void UpdateCompletedMessages(
|
||||||
IReadOnlyList<ChatMessage> allMessages,
|
IReadOnlyList<ChatMessage> allMessages,
|
||||||
IReadOnlyList<ChatMessage> inputMessages)
|
IReadOnlyList<ChatMessage> inputMessages)
|
||||||
@@ -263,6 +313,14 @@ internal sealed class CopilotTurnExecutionState
|
|||||||
ActiveAgent);
|
ActiveAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (ChatMessageDto message in CompletedMessages)
|
||||||
|
{
|
||||||
|
if (_reclassifiedMessageIds.Contains(message.Id))
|
||||||
|
{
|
||||||
|
message.MessageKind = "thinking";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return CompletedMessages;
|
return CompletedMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +412,50 @@ internal sealed class CopilotTurnExecutionState
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private AssistantIntentEventDto? CreateAssistantIntentEvent(
|
||||||
|
AgentIdentity agent,
|
||||||
|
AssistantIntentData? data)
|
||||||
|
{
|
||||||
|
string? intent = data?.Intent?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(intent))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AssistantIntentEventDto
|
||||||
|
{
|
||||||
|
Type = "assistant-intent",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
Intent = intent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReasoningDeltaEventDto? CreateReasoningDeltaEvent(
|
||||||
|
AgentIdentity agent,
|
||||||
|
AssistantReasoningDeltaData? data)
|
||||||
|
{
|
||||||
|
if (data is null
|
||||||
|
|| string.IsNullOrWhiteSpace(data.ReasoningId)
|
||||||
|
|| string.IsNullOrEmpty(data.DeltaContent))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ReasoningDeltaEventDto
|
||||||
|
{
|
||||||
|
Type = "reasoning-delta",
|
||||||
|
RequestId = _command.RequestId,
|
||||||
|
SessionId = _command.SessionId,
|
||||||
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
|
ReasoningId = data.ReasoningId,
|
||||||
|
ContentDelta = data.DeltaContent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private SkillInvokedEventDto CreateSkillInvokedEvent(
|
private SkillInvokedEventDto CreateSkillInvokedEvent(
|
||||||
AgentIdentity agent,
|
AgentIdentity agent,
|
||||||
SkillInvokedData? data)
|
SkillInvokedData? data)
|
||||||
|
|||||||
@@ -151,6 +151,26 @@ public sealed class CopilotSessionHooksTests
|
|||||||
Assert.Equal("ask", decision?.PermissionDecision);
|
Assert.Equal("ask", decision?.PermissionDecision);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("view", "read")]
|
||||||
|
[InlineData("grep", "read")]
|
||||||
|
[InlineData("edit", "write")]
|
||||||
|
[InlineData("powershell", "shell")]
|
||||||
|
public async Task Create_PreToolUseAutoAllowsWhenCategoryIsApproved(string toolName, string category)
|
||||||
|
{
|
||||||
|
RunTurnCommandDto command = CreateCommandWithAutoApprovedCategory(category);
|
||||||
|
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||||
|
|
||||||
|
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||||
|
new PreToolUseHookInput
|
||||||
|
{
|
||||||
|
ToolName = toolName,
|
||||||
|
},
|
||||||
|
null!);
|
||||||
|
|
||||||
|
Assert.Equal("allow", decision?.PermissionDecision);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Create_RunsConfiguredNonPreToolHooks()
|
public async Task Create_RunsConfiguredNonPreToolHooks()
|
||||||
{
|
{
|
||||||
@@ -309,6 +329,45 @@ public sealed class CopilotSessionHooksTests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static RunTurnCommandDto CreateCommandWithAutoApprovedCategory(string category)
|
||||||
|
{
|
||||||
|
return new RunTurnCommandDto
|
||||||
|
{
|
||||||
|
RequestId = "turn-1",
|
||||||
|
SessionId = "session-1",
|
||||||
|
ProjectPath = @"C:\workspace\project",
|
||||||
|
Pattern = new PatternDefinitionDto
|
||||||
|
{
|
||||||
|
Id = "pattern-1",
|
||||||
|
Name = "Pattern",
|
||||||
|
Mode = "single",
|
||||||
|
Availability = "available",
|
||||||
|
ApprovalPolicy = new ApprovalPolicyDto
|
||||||
|
{
|
||||||
|
Rules =
|
||||||
|
[
|
||||||
|
new ApprovalCheckpointRuleDto
|
||||||
|
{
|
||||||
|
Kind = "tool-call",
|
||||||
|
AgentIds = ["agent-1"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
AutoApprovedToolNames = [category],
|
||||||
|
},
|
||||||
|
Agents =
|
||||||
|
[
|
||||||
|
new PatternAgentDefinitionDto
|
||||||
|
{
|
||||||
|
Id = "agent-1",
|
||||||
|
Name = "Primary",
|
||||||
|
Model = "gpt-5.4",
|
||||||
|
Instructions = "Help.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static HookCommandDefinition CreateHookCommand(string name)
|
private static HookCommandDefinition CreateHookCommand(string name)
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Aryx.AgentHost.Contracts;
|
using Aryx.AgentHost.Contracts;
|
||||||
using Aryx.AgentHost.Services;
|
using Aryx.AgentHost.Services;
|
||||||
using GitHub.Copilot.SDK;
|
using GitHub.Copilot.SDK;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
namespace Aryx.AgentHost.Tests;
|
namespace Aryx.AgentHost.Tests;
|
||||||
|
|
||||||
@@ -83,6 +84,132 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
Assert.Equal("view", toolName);
|
Assert.Equal("view", toolName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObserveSessionEvent_AssistantMessageWithToolRequests_QueuesMessageReclassifiedEvent()
|
||||||
|
{
|
||||||
|
RunTurnCommandDto command = CreateCommand();
|
||||||
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
|
state.ObserveSessionEvent(
|
||||||
|
command.Pattern.Agents[0],
|
||||||
|
SessionEvent.FromJson(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "assistant.message",
|
||||||
|
"data": {
|
||||||
|
"messageId": "msg-2",
|
||||||
|
"content": "Let me search for that.",
|
||||||
|
"toolRequests": [
|
||||||
|
{
|
||||||
|
"toolCallId": "tool-call-1",
|
||||||
|
"name": "rg",
|
||||||
|
"arguments": {
|
||||||
|
"pattern": "identifierUri"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"id": "3f75988b-8e69-4c90-a203-6b01d1c1f90b",
|
||||||
|
"timestamp": "2026-03-27T00:00:00Z"
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
|
||||||
|
|
||||||
|
AgentActivityEventDto thinking = Assert.Single(pending.OfType<AgentActivityEventDto>());
|
||||||
|
Assert.Equal("thinking", thinking.ActivityType);
|
||||||
|
|
||||||
|
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
|
||||||
|
Assert.Equal("session-1", reclassified.SessionId);
|
||||||
|
Assert.Equal("msg-2", reclassified.MessageId);
|
||||||
|
Assert.Equal("thinking", reclassified.NewKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObserveSessionEvent_ToolExecutionStart_ReclassifiesLastObservedMessageOnce()
|
||||||
|
{
|
||||||
|
RunTurnCommandDto command = CreateCommand();
|
||||||
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
|
state.ObserveSessionEvent(
|
||||||
|
command.Pattern.Agents[0],
|
||||||
|
SessionEvent.FromJson(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "assistant.message_delta",
|
||||||
|
"data": {
|
||||||
|
"messageId": "msg-3",
|
||||||
|
"deltaContent": "Searching"
|
||||||
|
},
|
||||||
|
"id": "0b65f0e9-d0fb-417e-ab5c-7a3343d8581b",
|
||||||
|
"timestamp": "2026-03-27T00:00:00Z"
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
_ = state.DrainPendingEvents();
|
||||||
|
|
||||||
|
state.ObserveSessionEvent(
|
||||||
|
command.Pattern.Agents[0],
|
||||||
|
SessionEvent.FromJson(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "tool.execution_start",
|
||||||
|
"data": {
|
||||||
|
"toolCallId": "tool-call-1",
|
||||||
|
"toolName": "rg"
|
||||||
|
},
|
||||||
|
"id": "8f33240e-bd3f-475c-aeb6-a4b7908e47b0",
|
||||||
|
"timestamp": "2026-03-27T00:00:01Z"
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
state.ObserveSessionEvent(
|
||||||
|
command.Pattern.Agents[0],
|
||||||
|
SessionEvent.FromJson(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "tool.execution_start",
|
||||||
|
"data": {
|
||||||
|
"toolCallId": "tool-call-2",
|
||||||
|
"toolName": "view"
|
||||||
|
},
|
||||||
|
"id": "a23f9c9a-f947-4282-866d-f599451c3899",
|
||||||
|
"timestamp": "2026-03-27T00:00:02Z"
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
|
||||||
|
|
||||||
|
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
|
||||||
|
Assert.Equal("msg-3", reclassified.MessageId);
|
||||||
|
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? firstToolName));
|
||||||
|
Assert.Equal("rg", firstToolName);
|
||||||
|
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-2", out string? secondToolName));
|
||||||
|
Assert.Equal("view", secondToolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObserveSessionEvent_AssistantMessageWithoutToolRequests_DoesNotQueueMessageReclassifiedEvent()
|
||||||
|
{
|
||||||
|
RunTurnCommandDto command = CreateCommand();
|
||||||
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
|
state.ObserveSessionEvent(
|
||||||
|
command.Pattern.Agents[0],
|
||||||
|
SessionEvent.FromJson(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "assistant.message",
|
||||||
|
"data": {
|
||||||
|
"messageId": "msg-4",
|
||||||
|
"content": "Final answer."
|
||||||
|
},
|
||||||
|
"id": "d07fe954-1258-4f6a-bf79-1550d6143ed0",
|
||||||
|
"timestamp": "2026-03-27T00:00:00Z"
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
Assert.Empty(state.DrainPendingEvents().OfType<MessageReclassifiedEventDto>());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EmitThinkingIfNeeded_DoesNotDuplicateQueuedThinkingActivity()
|
public async Task EmitThinkingIfNeeded_DoesNotDuplicateQueuedThinkingActivity()
|
||||||
{
|
{
|
||||||
@@ -119,6 +246,70 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
Assert.Equal("agent-1", thinking.AgentId);
|
Assert.Equal("agent-1", thinking.AgentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObserveSessionEvent_AssistantIntent_QueuesIntentEvent()
|
||||||
|
{
|
||||||
|
RunTurnCommandDto command = CreateCommand();
|
||||||
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
|
state.ObserveSessionEvent(
|
||||||
|
command.Pattern.Agents[0],
|
||||||
|
SessionEvent.FromJson(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "assistant.intent",
|
||||||
|
"data": {
|
||||||
|
"intent": "Searching incident playbooks"
|
||||||
|
},
|
||||||
|
"id": "64cf59fe-63f0-4217-adf4-9bd6b3a80452",
|
||||||
|
"timestamp": "2026-03-27T00:00:00Z"
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
|
||||||
|
|
||||||
|
AgentActivityEventDto thinking = Assert.Single(pending.OfType<AgentActivityEventDto>());
|
||||||
|
Assert.Equal("thinking", thinking.ActivityType);
|
||||||
|
|
||||||
|
AssistantIntentEventDto intent = Assert.Single(pending.OfType<AssistantIntentEventDto>());
|
||||||
|
Assert.Equal("session-1", intent.SessionId);
|
||||||
|
Assert.Equal("agent-1", intent.AgentId);
|
||||||
|
Assert.Equal("Searching incident playbooks", intent.Intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObserveSessionEvent_AssistantReasoningDelta_QueuesReasoningDeltaEvent()
|
||||||
|
{
|
||||||
|
RunTurnCommandDto command = CreateCommand();
|
||||||
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
|
state.ObserveSessionEvent(
|
||||||
|
command.Pattern.Agents[0],
|
||||||
|
SessionEvent.FromJson(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "assistant.reasoning_delta",
|
||||||
|
"data": {
|
||||||
|
"reasoningId": "reasoning-2",
|
||||||
|
"deltaContent": "Searching logs."
|
||||||
|
},
|
||||||
|
"id": "bd269258-5e5d-46b6-bf3f-bd8cba793b1a",
|
||||||
|
"timestamp": "2026-03-27T00:00:00Z"
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
|
||||||
|
|
||||||
|
AgentActivityEventDto thinking = Assert.Single(pending.OfType<AgentActivityEventDto>());
|
||||||
|
Assert.Equal("thinking", thinking.ActivityType);
|
||||||
|
|
||||||
|
ReasoningDeltaEventDto reasoning = Assert.Single(pending.OfType<ReasoningDeltaEventDto>());
|
||||||
|
Assert.Equal("session-1", reasoning.SessionId);
|
||||||
|
Assert.Equal("agent-1", reasoning.AgentId);
|
||||||
|
Assert.Equal("reasoning-2", reasoning.ReasoningId);
|
||||||
|
Assert.Equal("Searching logs.", reasoning.ContentDelta);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DrainPendingMcpOauthRequests_ReturnsQueuedRequestsAndClearsQueue()
|
public void DrainPendingMcpOauthRequests_ReturnsQueuedRequestsAndClearsQueue()
|
||||||
{
|
{
|
||||||
@@ -411,6 +602,56 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FinalizeCompletedMessages_TagsReclassifiedMessagesAsThinking()
|
||||||
|
{
|
||||||
|
RunTurnCommandDto command = CreateCommand();
|
||||||
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
|
// Simulate assistant message with tool requests → triggers reclassification
|
||||||
|
state.ObserveSessionEvent(
|
||||||
|
command.Pattern.Agents[0],
|
||||||
|
SessionEvent.FromJson(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "assistant.message",
|
||||||
|
"data": {
|
||||||
|
"messageId": "msg-intermediate",
|
||||||
|
"content": "Let me search...",
|
||||||
|
"toolRequests": [
|
||||||
|
{
|
||||||
|
"toolCallId": "tool-call-1",
|
||||||
|
"name": "grep",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"timestamp": "2026-03-27T00:00:00Z"
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
state.DrainPendingEvents();
|
||||||
|
|
||||||
|
// Build completed messages with a reclassified and a non-reclassified message
|
||||||
|
ChatMessage intermediateMsg = new(ChatRole.Assistant, "Let me search...");
|
||||||
|
intermediateMsg.MessageId = "msg-intermediate";
|
||||||
|
intermediateMsg.AuthorName = "Primary";
|
||||||
|
|
||||||
|
ChatMessage finalMsg = new(ChatRole.Assistant, "Here are the results.");
|
||||||
|
finalMsg.MessageId = "msg-final";
|
||||||
|
finalMsg.AuthorName = "Primary";
|
||||||
|
|
||||||
|
state.UpdateCompletedMessages([intermediateMsg, finalMsg], []);
|
||||||
|
IReadOnlyList<ChatMessageDto> messages = state.FinalizeCompletedMessages();
|
||||||
|
|
||||||
|
ChatMessageDto intermediate = Assert.Single(messages, m => m.Id == "msg-intermediate");
|
||||||
|
Assert.Equal("thinking", intermediate.MessageKind);
|
||||||
|
|
||||||
|
ChatMessageDto final_ = Assert.Single(messages, m => m.Id == "msg-final");
|
||||||
|
Assert.Null(final_.MessageKind);
|
||||||
|
}
|
||||||
|
|
||||||
private static RunTurnCommandDto CreateCommand()
|
private static RunTurnCommandDto CreateCommand()
|
||||||
{
|
{
|
||||||
return new RunTurnCommandDto
|
return new RunTurnCommandDto
|
||||||
|
|||||||
@@ -1325,6 +1325,155 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
Assert.Equal("https://example.com", args["url"]);
|
Assert.Equal("https://example.com", args["url"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("view", "read")]
|
||||||
|
[InlineData("glob", "read")]
|
||||||
|
[InlineData("grep", "read")]
|
||||||
|
[InlineData("lsp", "read")]
|
||||||
|
[InlineData("edit", "write")]
|
||||||
|
[InlineData("create", "write")]
|
||||||
|
[InlineData("powershell", "shell")]
|
||||||
|
[InlineData("read_powershell", "shell")]
|
||||||
|
[InlineData("write_powershell", "shell")]
|
||||||
|
[InlineData("stop_powershell", "shell")]
|
||||||
|
[InlineData("list_powershell", "shell")]
|
||||||
|
[InlineData("web_fetch", "url")]
|
||||||
|
[InlineData("web_search", "url")]
|
||||||
|
[InlineData("store_memory", "memory")]
|
||||||
|
public void ResolveHookToolCategory_ReturnsExpectedCategoryForKnownTools(string toolName, string expectedCategory)
|
||||||
|
{
|
||||||
|
Assert.Equal(expectedCategory, CopilotApprovalCoordinator.ResolveHookToolCategory(toolName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("icm-mcp-get_on_call_schedule")]
|
||||||
|
[InlineData("custom_tool")]
|
||||||
|
[InlineData("unknown")]
|
||||||
|
public void ResolveHookToolCategory_ReturnsNullForUnknownTools(string toolName)
|
||||||
|
{
|
||||||
|
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(toolName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveHookToolCategory_ReturnsNullForNullOrEmpty()
|
||||||
|
{
|
||||||
|
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(null));
|
||||||
|
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(""));
|
||||||
|
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetApprovalToolName_ResolvesHookToolToCategory()
|
||||||
|
{
|
||||||
|
Assert.True(
|
||||||
|
CopilotApprovalCoordinator.TryGetApprovalToolName(
|
||||||
|
new PermissionRequestHook
|
||||||
|
{
|
||||||
|
Kind = "hook",
|
||||||
|
ToolName = "view",
|
||||||
|
ToolArgs = """{"path":"README.md"}""",
|
||||||
|
},
|
||||||
|
out string? toolName));
|
||||||
|
Assert.Equal("view", toolName);
|
||||||
|
|
||||||
|
// But the auto-approved name (fallback) resolves to the category
|
||||||
|
PermissionRequestHook hookRequest = new()
|
||||||
|
{
|
||||||
|
Kind = "hook",
|
||||||
|
ToolName = "view",
|
||||||
|
ToolArgs = """{"path":"README.md"}""",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify GetFallbackToolName returns category via ResolveAutoApprovedToolName path
|
||||||
|
Assert.True(
|
||||||
|
CopilotApprovalCoordinator.TryGetApprovalToolName(
|
||||||
|
hookRequest,
|
||||||
|
out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RequiresToolCallApproval_HonorsHookToolCategoryForAutoApproval()
|
||||||
|
{
|
||||||
|
ApprovalPolicyDto policy = new()
|
||||||
|
{
|
||||||
|
Rules =
|
||||||
|
[
|
||||||
|
new ApprovalCheckpointRuleDto
|
||||||
|
{
|
||||||
|
Kind = "tool-call",
|
||||||
|
AgentIds = ["agent-1"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
AutoApprovedToolNames = ["read"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// "view" is a hook tool that maps to "read" category — should be auto-approved
|
||||||
|
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||||
|
policy, "agent-1", "view", "read"));
|
||||||
|
|
||||||
|
// "grep" also maps to "read"
|
||||||
|
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||||
|
policy, "agent-1", "grep", "read"));
|
||||||
|
|
||||||
|
// "edit" maps to "write" — not auto-approved
|
||||||
|
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||||
|
policy, "agent-1", "edit", "write"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildPermissionApprovalEvent_UsesResolvedCategoryForHookPermissionKind()
|
||||||
|
{
|
||||||
|
ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent(
|
||||||
|
new RunTurnCommandDto
|
||||||
|
{
|
||||||
|
RequestId = "turn-1",
|
||||||
|
SessionId = "session-1",
|
||||||
|
},
|
||||||
|
CreateAgent("agent-1", "Primary"),
|
||||||
|
new PermissionRequestHook
|
||||||
|
{
|
||||||
|
Kind = "hook",
|
||||||
|
ToolName = "view",
|
||||||
|
ToolArgs = """{"path":"README.md"}""",
|
||||||
|
},
|
||||||
|
new PermissionInvocation
|
||||||
|
{
|
||||||
|
SessionId = "copilot-session-1",
|
||||||
|
},
|
||||||
|
"approval-1",
|
||||||
|
"view");
|
||||||
|
|
||||||
|
Assert.Equal("view", approvalEvent.ToolName);
|
||||||
|
Assert.Equal("read", approvalEvent.PermissionKind);
|
||||||
|
Assert.Contains("read permission", approvalEvent.Detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildPermissionApprovalEvent_KeepsHookKindForUnknownHookTools()
|
||||||
|
{
|
||||||
|
ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent(
|
||||||
|
new RunTurnCommandDto
|
||||||
|
{
|
||||||
|
RequestId = "turn-1",
|
||||||
|
SessionId = "session-1",
|
||||||
|
},
|
||||||
|
CreateAgent("agent-1", "Primary"),
|
||||||
|
new PermissionRequestHook
|
||||||
|
{
|
||||||
|
Kind = "hook",
|
||||||
|
ToolName = "icm-mcp-get_schedule",
|
||||||
|
ToolArgs = """{"teamIds":[91982]}""",
|
||||||
|
},
|
||||||
|
new PermissionInvocation
|
||||||
|
{
|
||||||
|
SessionId = "copilot-session-1",
|
||||||
|
},
|
||||||
|
"approval-1",
|
||||||
|
"icm-mcp-get_schedule");
|
||||||
|
|
||||||
|
Assert.Equal("hook", approvalEvent.PermissionKind);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RequestApprovalAsync_RaisesApprovalAndCompletesAfterResolution()
|
public async Task RequestApprovalAsync_RaisesApprovalAndCompletesAfterResolution()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
ExitPlanModeRequestedEvent,
|
ExitPlanModeRequestedEvent,
|
||||||
MessageMode,
|
MessageMode,
|
||||||
McpOauthRequiredEvent,
|
McpOauthRequiredEvent,
|
||||||
|
MessageReclassifiedEvent,
|
||||||
RunTurnCustomAgentConfig,
|
RunTurnCustomAgentConfig,
|
||||||
RunTurnToolingConfig,
|
RunTurnToolingConfig,
|
||||||
SidecarCapabilities,
|
SidecarCapabilities,
|
||||||
@@ -1364,6 +1365,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
async (event) => {
|
async (event) => {
|
||||||
await this.handleExitPlanModeRequested(workspace, session.id, event);
|
await this.handleExitPlanModeRequested(workspace, session.id, event);
|
||||||
},
|
},
|
||||||
|
async (event) => {
|
||||||
|
await this.applyMessageReclassified(workspace, session.id, event);
|
||||||
|
},
|
||||||
async (event) => {
|
async (event) => {
|
||||||
await this.handleTurnScopedEvent(workspace, session.id, event);
|
await this.handleTurnScopedEvent(workspace, session.id, event);
|
||||||
},
|
},
|
||||||
@@ -1715,6 +1719,31 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async applyMessageReclassified(
|
||||||
|
workspace: WorkspaceState,
|
||||||
|
sessionId: string,
|
||||||
|
event: MessageReclassifiedEvent,
|
||||||
|
): Promise<void> {
|
||||||
|
const session = this.requireSession(workspace, sessionId);
|
||||||
|
const message = session.messages.find((m) => m.id === event.messageId);
|
||||||
|
if (!message || message.messageKind === 'thinking') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.messageKind = 'thinking';
|
||||||
|
const occurredAt = nowIso();
|
||||||
|
session.updatedAt = occurredAt;
|
||||||
|
await this.workspaceRepository.save(workspace);
|
||||||
|
|
||||||
|
this.emitSessionEvent({
|
||||||
|
sessionId,
|
||||||
|
kind: 'message-reclassified',
|
||||||
|
occurredAt,
|
||||||
|
messageId: event.messageId,
|
||||||
|
messageKind: 'thinking',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async applyAgentActivity(
|
private async applyAgentActivity(
|
||||||
workspace: WorkspaceState,
|
workspace: WorkspaceState,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -1795,6 +1824,15 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
const pattern = this.requirePattern(workspace, session.patternId);
|
const pattern = this.requirePattern(workspace, session.patternId);
|
||||||
const incomingIds = new Set(messages.map((message) => message.id));
|
const incomingIds = new Set(messages.map((message) => message.id));
|
||||||
|
|
||||||
|
// Messages that were streamed during the turn already exist in session.messages
|
||||||
|
// (possibly with messageKind: 'thinking' from reclassification). Unstreamed messages
|
||||||
|
// (e.g. from sub-agents) only appear now. Classify them as thinking when a visible
|
||||||
|
// response was already streamed, since they are intermediate tool-driving steps.
|
||||||
|
const existingIds = new Set(session.messages.map((m) => m.id));
|
||||||
|
const hasVisibleResponse = session.messages.some(
|
||||||
|
(m) => m.role === 'assistant' && m.messageKind !== 'thinking',
|
||||||
|
);
|
||||||
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
const occurredAt = nowIso();
|
const occurredAt = nowIso();
|
||||||
const existing = session.messages.find((current) => current.id === message.id);
|
const existing = session.messages.find((current) => current.id === message.id);
|
||||||
@@ -1803,9 +1841,22 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
existing.content = message.content;
|
existing.content = message.content;
|
||||||
existing.pending = false;
|
existing.pending = false;
|
||||||
} else {
|
} else {
|
||||||
session.messages.push({ ...message, pending: false });
|
const isUnstreamedIntermediate =
|
||||||
|
message.role === 'assistant'
|
||||||
|
&& hasVisibleResponse
|
||||||
|
&& !message.messageKind;
|
||||||
|
session.messages.push({
|
||||||
|
...message,
|
||||||
|
pending: false,
|
||||||
|
messageKind: message.messageKind ?? (isUnstreamedIntermediate ? 'thinking' : undefined),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reclassifiedAsThinking =
|
||||||
|
!existingIds.has(message.id)
|
||||||
|
&& (message.messageKind === 'thinking'
|
||||||
|
|| (message.role === 'assistant' && hasVisibleResponse && !message.messageKind));
|
||||||
|
|
||||||
const nextRun = this.updateSessionRun(session, requestId, (run) =>
|
const nextRun = this.updateSessionRun(session, requestId, (run) =>
|
||||||
upsertRunMessageEvent(run, {
|
upsertRunMessageEvent(run, {
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
@@ -1822,6 +1873,15 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
authorName: message.authorName,
|
authorName: message.authorName,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
});
|
});
|
||||||
|
if (reclassifiedAsThinking) {
|
||||||
|
this.emitSessionEvent({
|
||||||
|
sessionId,
|
||||||
|
kind: 'message-reclassified',
|
||||||
|
occurredAt,
|
||||||
|
messageId: message.id,
|
||||||
|
messageKind: 'thinking',
|
||||||
|
});
|
||||||
|
}
|
||||||
if (nextRun) {
|
if (nextRun) {
|
||||||
this.emitRunUpdated(sessionId, occurredAt, nextRun);
|
this.emitRunUpdated(sessionId, occurredAt, nextRun);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type AutoUpdateListener = (...args: any[]) => void;
|
|||||||
interface AutoUpdaterLike {
|
interface AutoUpdaterLike {
|
||||||
autoDownload: boolean;
|
autoDownload: boolean;
|
||||||
autoInstallOnAppQuit: boolean;
|
autoInstallOnAppQuit: boolean;
|
||||||
|
forceDevUpdateConfig: boolean;
|
||||||
on(event: string, listener: AutoUpdateListener): this;
|
on(event: string, listener: AutoUpdateListener): this;
|
||||||
removeListener(event: string, listener: AutoUpdateListener): this;
|
removeListener(event: string, listener: AutoUpdateListener): this;
|
||||||
checkForUpdates(): Promise<unknown>;
|
checkForUpdates(): Promise<unknown>;
|
||||||
@@ -151,7 +152,7 @@ export class AutoUpdateService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly notAvailableListener = () => {
|
private readonly notAvailableListener = () => {
|
||||||
this.publishStatus({ state: 'idle' });
|
this.publishStatus({ state: 'up-to-date' });
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly progressListener = (progress: AutoUpdateProgressLike) => {
|
private readonly progressListener = (progress: AutoUpdateProgressLike) => {
|
||||||
@@ -180,6 +181,7 @@ export class AutoUpdateService {
|
|||||||
this.scheduler = options.scheduler ?? defaultScheduler;
|
this.scheduler = options.scheduler ?? defaultScheduler;
|
||||||
this.updater.autoDownload = true;
|
this.updater.autoDownload = true;
|
||||||
this.updater.autoInstallOnAppQuit = false;
|
this.updater.autoInstallOnAppQuit = false;
|
||||||
|
this.updater.forceDevUpdateConfig = !options.isPackaged;
|
||||||
|
|
||||||
this.updater.on('checking-for-update', this.checkingListener);
|
this.updater.on('checking-for-update', this.checkingListener);
|
||||||
this.updater.on('update-available', this.availableListener);
|
this.updater.on('update-available', this.availableListener);
|
||||||
@@ -215,10 +217,6 @@ export class AutoUpdateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkForUpdates(): Promise<UpdateStatus> {
|
async checkForUpdates(): Promise<UpdateStatus> {
|
||||||
if (!this.options.isPackaged) {
|
|
||||||
return this.getStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.pendingCheck) {
|
if (this.pendingCheck) {
|
||||||
return this.pendingCheck;
|
return this.pendingCheck;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ApprovalRequestedEvent,
|
ApprovalRequestedEvent,
|
||||||
ExitPlanModeRequestedEvent,
|
ExitPlanModeRequestedEvent,
|
||||||
McpOauthRequiredEvent,
|
McpOauthRequiredEvent,
|
||||||
|
MessageReclassifiedEvent,
|
||||||
TurnDeltaEvent,
|
TurnDeltaEvent,
|
||||||
UserInputRequestedEvent,
|
UserInputRequestedEvent,
|
||||||
SubagentEvent,
|
SubagentEvent,
|
||||||
@@ -12,6 +13,8 @@ import type {
|
|||||||
SessionCompactionEvent,
|
SessionCompactionEvent,
|
||||||
PendingMessagesModifiedEvent,
|
PendingMessagesModifiedEvent,
|
||||||
AssistantUsageEvent,
|
AssistantUsageEvent,
|
||||||
|
AssistantIntentEvent,
|
||||||
|
ReasoningDeltaEvent,
|
||||||
} from '@shared/contracts/sidecar';
|
} from '@shared/contracts/sidecar';
|
||||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||||
|
|
||||||
@@ -22,7 +25,9 @@ export type TurnScopedEvent =
|
|||||||
| SessionUsageEvent
|
| SessionUsageEvent
|
||||||
| SessionCompactionEvent
|
| SessionCompactionEvent
|
||||||
| PendingMessagesModifiedEvent
|
| PendingMessagesModifiedEvent
|
||||||
| AssistantUsageEvent;
|
| AssistantUsageEvent
|
||||||
|
| AssistantIntentEvent
|
||||||
|
| ReasoningDeltaEvent;
|
||||||
|
|
||||||
export interface RunTurnPendingCommand {
|
export interface RunTurnPendingCommand {
|
||||||
kind: 'run-turn';
|
kind: 'run-turn';
|
||||||
@@ -34,6 +39,7 @@ export interface RunTurnPendingCommand {
|
|||||||
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>;
|
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>;
|
||||||
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>;
|
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>;
|
||||||
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>;
|
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>;
|
||||||
|
onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise<void>;
|
||||||
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>;
|
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>;
|
||||||
errored: boolean;
|
errored: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
SidecarCapabilities,
|
SidecarCapabilities,
|
||||||
SidecarEvent,
|
SidecarEvent,
|
||||||
TurnDeltaEvent,
|
TurnDeltaEvent,
|
||||||
|
MessageReclassifiedEvent,
|
||||||
UserInputRequestedEvent,
|
UserInputRequestedEvent,
|
||||||
McpOauthRequiredEvent,
|
McpOauthRequiredEvent,
|
||||||
ExitPlanModeRequestedEvent,
|
ExitPlanModeRequestedEvent,
|
||||||
@@ -134,9 +135,10 @@ export class SidecarClient {
|
|||||||
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>,
|
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>,
|
||||||
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>,
|
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>,
|
||||||
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
|
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
|
||||||
|
onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise<void>,
|
||||||
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>,
|
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>,
|
||||||
): Promise<ChatMessageRecord[]> {
|
): Promise<ChatMessageRecord[]> {
|
||||||
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, onTurnScopedEvent);
|
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, onMessageReclassified, onTurnScopedEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise<void> {
|
async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise<void> {
|
||||||
@@ -286,6 +288,7 @@ export class SidecarClient {
|
|||||||
onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>,
|
onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>,
|
||||||
onMcpOAuthRequired?: (event: McpOauthRequiredEvent) => void | Promise<void>,
|
onMcpOAuthRequired?: (event: McpOauthRequiredEvent) => void | Promise<void>,
|
||||||
onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
|
onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
|
||||||
|
onMessageReclassified?: (event: MessageReclassifiedEvent) => void | Promise<void>,
|
||||||
onTurnScopedEvent?: (event: TurnScopedEvent) => void | Promise<void>,
|
onTurnScopedEvent?: (event: TurnScopedEvent) => void | Promise<void>,
|
||||||
): Promise<TResult> {
|
): Promise<TResult> {
|
||||||
const state = await this.ensureProcess();
|
const state = await this.ensureProcess();
|
||||||
@@ -303,6 +306,7 @@ export class SidecarClient {
|
|||||||
onUserInput: onUserInput ?? (() => undefined),
|
onUserInput: onUserInput ?? (() => undefined),
|
||||||
onMcpOAuthRequired: onMcpOAuthRequired ?? (() => undefined),
|
onMcpOAuthRequired: onMcpOAuthRequired ?? (() => undefined),
|
||||||
onExitPlanMode: onExitPlanMode ?? (() => undefined),
|
onExitPlanMode: onExitPlanMode ?? (() => undefined),
|
||||||
|
onMessageReclassified: onMessageReclassified ?? (() => undefined),
|
||||||
onTurnScopedEvent: onTurnScopedEvent ?? (() => undefined),
|
onTurnScopedEvent: onTurnScopedEvent ?? (() => undefined),
|
||||||
errored: false,
|
errored: false,
|
||||||
});
|
});
|
||||||
@@ -439,6 +443,11 @@ export class SidecarClient {
|
|||||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onExitPlanMode(event));
|
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onExitPlanMode(event));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
case 'message-reclassified':
|
||||||
|
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
||||||
|
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onMessageReclassified(event));
|
||||||
|
}
|
||||||
|
return;
|
||||||
case 'subagent-event':
|
case 'subagent-event':
|
||||||
case 'skill-invoked':
|
case 'skill-invoked':
|
||||||
case 'hook-lifecycle':
|
case 'hook-lifecycle':
|
||||||
@@ -446,6 +455,8 @@ export class SidecarClient {
|
|||||||
case 'session-compaction':
|
case 'session-compaction':
|
||||||
case 'pending-messages-modified':
|
case 'pending-messages-modified':
|
||||||
case 'assistant-usage':
|
case 'assistant-usage':
|
||||||
|
case 'assistant-intent':
|
||||||
|
case 'reasoning-delta':
|
||||||
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
||||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onTurnScopedEvent(event));
|
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onTurnScopedEvent(event));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export function createMainWindow(): BrowserWindowType {
|
|||||||
}),
|
}),
|
||||||
backgroundColor: '#09090b',
|
backgroundColor: '#09090b',
|
||||||
titleBarStyle: 'hidden',
|
titleBarStyle: 'hidden',
|
||||||
|
...(process.platform === 'darwin' && {
|
||||||
|
trafficLightPosition: { x: 16, y: 22 },
|
||||||
|
}),
|
||||||
titleBarOverlay: {
|
titleBarOverlay: {
|
||||||
color: '#09090b',
|
color: '#09090b',
|
||||||
symbolColor: '#a1a1aa',
|
symbolColor: '#a1a1aa',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingMo
|
|||||||
import { KeyboardShortcutsPanel } from '@renderer/components/KeyboardShortcutsPanel';
|
import { KeyboardShortcutsPanel } from '@renderer/components/KeyboardShortcutsPanel';
|
||||||
import { NewSessionModal } from '@renderer/components/NewSessionModal';
|
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 { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
|
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
|
||||||
import { SettingsPanel } from '@renderer/components/SettingsPanel';
|
import { SettingsPanel } from '@renderer/components/SettingsPanel';
|
||||||
import { Sidebar } from '@renderer/components/Sidebar';
|
import { Sidebar } from '@renderer/components/Sidebar';
|
||||||
@@ -118,6 +119,7 @@ export default function App() {
|
|||||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
|
const [showBookmarks, setShowBookmarks] = useState(false);
|
||||||
|
|
||||||
// Terminal state
|
// Terminal state
|
||||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||||
@@ -324,6 +326,13 @@ export default function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ctrl/Cmd+Shift+B — Bookmarks ──
|
||||||
|
if (mod && e.shiftKey && e.key === 'B') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowBookmarks((prev) => !prev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Ctrl/Cmd+, — Open settings ──
|
// ── Ctrl/Cmd+, — Open settings ──
|
||||||
if (mod && e.key === ',') {
|
if (mod && e.key === ',') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -824,6 +833,7 @@ export default function App() {
|
|||||||
onOpenAppDataFolder={() => void api.openAppDataFolder()}
|
onOpenAppDataFolder={() => void api.openAppDataFolder()}
|
||||||
onShowShortcuts={() => setShowShortcuts(true)}
|
onShowShortcuts={() => setShowShortcuts(true)}
|
||||||
onShowSearch={() => setShowSearch(true)}
|
onShowSearch={() => setShowSearch(true)}
|
||||||
|
onShowBookmarks={() => setShowBookmarks(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -840,6 +850,19 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showBookmarks && workspace && (
|
||||||
|
<BookmarksPanel
|
||||||
|
workspace={workspace}
|
||||||
|
onClose={() => setShowBookmarks(false)}
|
||||||
|
onSelectSession={(sessionId) => {
|
||||||
|
void api.selectSession(sessionId);
|
||||||
|
}}
|
||||||
|
onUnpinMessage={(sessionId, messageId) => {
|
||||||
|
void api.setSessionMessagePinned({ sessionId, messageId, isPinned: false });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Bookmark, BookmarkMinus, ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
|
import { listPinnedMessages, type PinnedMessageHit } from '@shared/domain/sessionLibrary';
|
||||||
|
|
||||||
|
export interface BookmarksPanelProps {
|
||||||
|
workspace: WorkspaceState;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelectSession: (sessionId: string) => void;
|
||||||
|
onUnpinMessage: (sessionId: string, messageId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookmarksPanel({ workspace, onClose, onSelectSession, onUnpinMessage }: BookmarksPanelProps) {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Escape to close in capture phase
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleEscape, true);
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape, true);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const hits = useMemo<PinnedMessageHit[]>(
|
||||||
|
() => listPinnedMessages(workspace),
|
||||||
|
[workspace],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clamp selected index when items are removed
|
||||||
|
useEffect(() => {
|
||||||
|
if (hits.length > 0 && selectedIndex >= hits.length) {
|
||||||
|
setSelectedIndex(hits.length - 1);
|
||||||
|
}
|
||||||
|
}, [hits.length, selectedIndex]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback((hit: PinnedMessageHit) => {
|
||||||
|
onClose();
|
||||||
|
onSelectSession(hit.session.id);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.querySelector(`[data-message-id="${CSS.escape(hit.message.id)}"]`);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
el.classList.add('ring-1', 'ring-[var(--color-accent)]/40', 'rounded-lg');
|
||||||
|
setTimeout(() => el.classList.remove('ring-1', 'ring-[var(--color-accent)]/40', 'rounded-lg'), 2000);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}, [onClose, onSelectSession]);
|
||||||
|
|
||||||
|
const handleUnpin = useCallback((e: React.MouseEvent, hit: PinnedMessageHit) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUnpinMessage(hit.session.id, hit.message.id);
|
||||||
|
}, [onUnpinMessage]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (hits.length > 0) setSelectedIndex((i) => Math.min(i + 1, hits.length - 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((i) => Math.max(i - 1, 0));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const hit = hits[selectedIndex];
|
||||||
|
if (hit) handleSelect(hit);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hits, selectedIndex, handleSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const item = listRef.current?.querySelector(`[data-bookmark-index="${selectedIndex}"]`);
|
||||||
|
item?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="palette-backdrop-enter fixed inset-0 z-[60] flex justify-center bg-[#07080e]/80 pt-[15vh] backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Bookmarks"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="palette-enter glow-border flex h-fit max-h-[min(520px,65vh)] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-[var(--color-surface-1)] shadow-[0_16px_64px_rgba(0,0,0,0.5)]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
tabIndex={-1}
|
||||||
|
ref={(el) => el?.focus()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 border-b border-[var(--color-border)] px-4 py-3.5">
|
||||||
|
<Bookmark className="size-4 shrink-0 text-[var(--color-text-accent)]" />
|
||||||
|
<span className="text-[14px] font-medium text-[var(--color-text-primary)]">
|
||||||
|
Bookmarks
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
{hits.length} {hits.length === 1 ? 'message' : 'messages'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div ref={listRef} className="flex-1 overflow-y-auto py-1.5" role="listbox">
|
||||||
|
{hits.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 px-4 py-10 text-center">
|
||||||
|
<Bookmark className="size-6 text-[var(--color-text-muted)]/40" />
|
||||||
|
<span className="text-[13px] text-[var(--color-text-muted)]">
|
||||||
|
No bookmarked messages yet
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-[var(--color-text-muted)]/60">
|
||||||
|
Pin messages from any session to save them here
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
hits.map((hit, index) => {
|
||||||
|
const isSelected = index === selectedIndex;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${hit.session.id}-${hit.message.id}`}
|
||||||
|
data-bookmark-index={index}
|
||||||
|
className={`group/row flex w-full items-start gap-3 px-4 py-2.5 text-left transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)]'
|
||||||
|
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-glass-hover)] hover:text-[var(--color-text-primary)]'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSelect(hit)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
{/* Session title row */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bookmark
|
||||||
|
className={`size-3.5 shrink-0 fill-[var(--color-accent-sky)] text-[var(--color-accent-sky)]`}
|
||||||
|
/>
|
||||||
|
<span className="truncate text-[12px] font-medium">{hit.session.title}</span>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-muted)]">·</span>
|
||||||
|
<span className="truncate text-[10px] text-[var(--color-text-muted)]">{hit.projectName}</span>
|
||||||
|
<ArrowRight className="ml-auto size-3 shrink-0 text-[var(--color-text-muted)]" />
|
||||||
|
</div>
|
||||||
|
{/* Message snippet */}
|
||||||
|
<div className="pl-5.5 text-[12px] leading-relaxed text-[var(--color-text-muted)]">
|
||||||
|
<span className="line-clamp-2">{hit.snippet}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pl-5.5 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{hit.message.role === 'user' ? 'You' : hit.message.authorName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unpin button */}
|
||||||
|
<button
|
||||||
|
className="mt-1 flex size-6 shrink-0 items-center justify-center rounded-md text-[var(--color-text-muted)] opacity-0 transition-all duration-100 hover:bg-[var(--color-surface-2)] hover:text-[var(--color-status-error)] group-hover/row:opacity-100"
|
||||||
|
onClick={(e) => handleUnpin(e, hit)}
|
||||||
|
aria-label={`Unpin message from ${hit.session.title}`}
|
||||||
|
title="Remove bookmark"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<BookmarkMinus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer hints */}
|
||||||
|
<div className="flex items-center gap-4 border-t border-[var(--color-border)] px-4 py-2 text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 font-mono text-[10px]">↑↓</kbd>
|
||||||
|
navigate
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 font-mono text-[10px]">↵</kbd>
|
||||||
|
jump to message
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 font-mono text-[10px]">esc</kbd>
|
||||||
|
close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
|
|||||||
import { InlineApprovalPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
|
import { InlineApprovalPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
|
||||||
import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill';
|
import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill';
|
||||||
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
|
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
|
||||||
|
import { ThinkingProcess } from '@renderer/components/chat/ThinkingProcess';
|
||||||
import { SubagentActivityList } from '@renderer/components/chat/SubagentActivityCard';
|
import { SubagentActivityList } from '@renderer/components/chat/SubagentActivityCard';
|
||||||
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
|
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
|
||||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||||
@@ -30,6 +31,7 @@ import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pat
|
|||||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||||
import { resolveSessionToolingSelection, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
|
import { resolveSessionToolingSelection, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
|
||||||
import {
|
import {
|
||||||
|
countApprovedToolsInGroups,
|
||||||
groupApprovalToolsByProvider,
|
groupApprovalToolsByProvider,
|
||||||
listApprovalToolDefinitions,
|
listApprovalToolDefinitions,
|
||||||
type RuntimeToolDefinition,
|
type RuntimeToolDefinition,
|
||||||
@@ -114,12 +116,28 @@ export function ChatPane({
|
|||||||
const composerRef = useRef<MarkdownComposerHandle>(null);
|
const composerRef = useRef<MarkdownComposerHandle>(null);
|
||||||
|
|
||||||
const isSessionBusy = session.status === 'running';
|
const isSessionBusy = session.status === 'running';
|
||||||
|
const { visibleMessages, thinkingMessages } = useMemo(() => {
|
||||||
|
const visible: typeof session.messages = [];
|
||||||
|
const thinking: typeof session.messages = [];
|
||||||
|
for (const message of session.messages) {
|
||||||
|
if (message.messageKind === 'thinking') {
|
||||||
|
thinking.push(message);
|
||||||
|
} else {
|
||||||
|
visible.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { visibleMessages: visible, thinkingMessages: thinking };
|
||||||
|
}, [session.messages]);
|
||||||
const lastAssistantIndex = useMemo(() => {
|
const lastAssistantIndex = useMemo(() => {
|
||||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
for (let i = visibleMessages.length - 1; i >= 0; i--) {
|
||||||
if (session.messages[i].role === 'assistant') return i;
|
if (visibleMessages[i].role === 'assistant') return i;
|
||||||
}
|
}
|
||||||
return -1;
|
return -1;
|
||||||
}, [session.messages]);
|
}, [visibleMessages]);
|
||||||
|
const turnStartedAt = useMemo(() => {
|
||||||
|
if (session.runs.length === 0) return undefined;
|
||||||
|
return session.runs[0].startedAt;
|
||||||
|
}, [session.runs]);
|
||||||
const pendingApproval = session.pendingApproval?.status === 'pending' ? session.pendingApproval : undefined;
|
const pendingApproval = session.pendingApproval?.status === 'pending' ? session.pendingApproval : undefined;
|
||||||
const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending');
|
const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending');
|
||||||
const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length;
|
const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length;
|
||||||
@@ -162,17 +180,7 @@ export function ChatPane({
|
|||||||
);
|
);
|
||||||
const effectiveAutoApprovedCount = useMemo(() => {
|
const effectiveAutoApprovedCount = useMemo(() => {
|
||||||
const groups = groupApprovalToolsByProvider(approvalTools, toolingSettings);
|
const groups = groupApprovalToolsByProvider(approvalTools, toolingSettings);
|
||||||
const counted = new Set<string>();
|
return countApprovedToolsInGroups(groups, effectiveAutoApproved);
|
||||||
for (const group of groups) {
|
|
||||||
if (group.serverApprovalKey && effectiveAutoApproved.has(group.serverApprovalKey)) {
|
|
||||||
for (const tool of group.tools) counted.add(tool.id);
|
|
||||||
} else {
|
|
||||||
for (const tool of group.tools) {
|
|
||||||
if (effectiveAutoApproved.has(tool.id)) counted.add(tool.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return counted.size;
|
|
||||||
}, [approvalTools, effectiveAutoApproved, toolingSettings]);
|
}, [approvalTools, effectiveAutoApproved, toolingSettings]);
|
||||||
const isProbingMcp = (mcpProbingServerIds?.length ?? 0) > 0;
|
const isProbingMcp = (mcpProbingServerIds?.length ?? 0) > 0;
|
||||||
const hasApprovalContent = approvalTools.length > 0 || isProbingMcp;
|
const hasApprovalContent = approvalTools.length > 0 || isProbingMcp;
|
||||||
@@ -367,7 +375,7 @@ export function ChatPane({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{session.messages.map((message, index) => {
|
{visibleMessages.map((message, index) => {
|
||||||
const isUser = message.role === 'user';
|
const isUser = message.role === 'user';
|
||||||
const isEditing = editingMessageId === message.id;
|
const isEditing = editingMessageId === message.id;
|
||||||
const isLastAssistant = index === lastAssistantIndex;
|
const isLastAssistant = index === lastAssistantIndex;
|
||||||
@@ -385,101 +393,122 @@ export function ChatPane({
|
|||||||
const phaseLabel =
|
const phaseLabel =
|
||||||
phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined;
|
phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined;
|
||||||
const showActions = !isSessionBusy && !message.pending;
|
const showActions = !isSessionBusy && !message.pending;
|
||||||
|
const showThinkingBefore = isLastAssistant && thinkingMessages.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="message-enter group py-3" data-message-id={message.id} key={message.id}>
|
<div key={message.id}>
|
||||||
<div className="flex gap-3">
|
{showThinkingBefore && (
|
||||||
<div
|
<div className="py-2">
|
||||||
className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full ${
|
<ThinkingProcess
|
||||||
isUser ? 'brand-gradient-bg text-white' : 'bg-[var(--color-surface-2)] text-[var(--color-text-secondary)]'
|
messages={thinkingMessages}
|
||||||
}`}
|
isActive={isSessionBusy}
|
||||||
>
|
turnStartedAt={turnStartedAt}
|
||||||
{isUser ? <User className="size-3.5" /> : <Bot className="size-3.5" />}
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
)}
|
||||||
<div className="mb-1 flex items-center gap-2 text-[12px] font-medium text-[var(--color-text-secondary)]">
|
<div className="message-enter group py-3" data-message-id={message.id}>
|
||||||
<span>{message.authorName}</span>
|
<div className="flex gap-3">
|
||||||
{message.isPinned && (
|
<div
|
||||||
<Bookmark className="size-3 fill-[var(--color-accent-sky)] text-[var(--color-accent-sky)]" />
|
className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full ${
|
||||||
)}
|
isUser ? 'brand-gradient-bg text-white' : 'bg-[var(--color-surface-2)] text-[var(--color-text-secondary)]'
|
||||||
{!isUser && phaseLabel && (
|
}`}
|
||||||
<span
|
>
|
||||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] ${assistantBadgeClass}`}
|
{isUser ? <User className="size-3.5" /> : <Bot className="size-3.5" />}
|
||||||
>
|
|
||||||
{phaseLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{showActions && (
|
|
||||||
<div className="ml-auto">
|
|
||||||
<MessageActions
|
|
||||||
message={message}
|
|
||||||
isLastAssistant={isLastAssistant}
|
|
||||||
onCopy={() => handleCopyMessage(message.content)}
|
|
||||||
onPin={() => onPinMessage?.(message.id, !message.isPinned)}
|
|
||||||
onBranch={() => onBranchFromMessage?.(message.id)}
|
|
||||||
onRegenerate={onRegenerateMessage ? () => onRegenerateMessage(message.id) : undefined}
|
|
||||||
onEdit={onEditAndResendMessage && isUser ? () => setEditingMessageId(message.id) : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
{/* Edit mode */}
|
<div className="mb-1 flex items-center gap-2 text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||||
{isEditing ? (
|
<span>{message.authorName}</span>
|
||||||
<MessageEditComposer
|
{message.isPinned && (
|
||||||
initialContent={message.content}
|
<Bookmark className="size-3 fill-[var(--color-accent-sky)] text-[var(--color-accent-sky)]" />
|
||||||
onSave={(content) => handleEditSave(message.id, content)}
|
|
||||||
onCancel={() => setEditingMessageId(undefined)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
isUser
|
|
||||||
? 'text-[14px] leading-relaxed text-[var(--color-text-primary)]'
|
|
||||||
: `rounded-xl border px-4 py-3 text-[14px] leading-relaxed text-[var(--color-text-primary)] ${assistantContainerClass}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Attachment thumbnails */}
|
|
||||||
{isUser && message.attachments && message.attachments.length > 0 && (
|
|
||||||
<div className="mb-2 flex flex-wrap gap-2">
|
|
||||||
{message.attachments.map((att, attIdx) =>
|
|
||||||
isImageAttachment(att) ? (
|
|
||||||
<img
|
|
||||||
key={attIdx}
|
|
||||||
alt={getAttachmentDisplayName(att)}
|
|
||||||
className="max-h-48 max-w-xs rounded-lg border border-[var(--color-border)] object-cover"
|
|
||||||
src={`data:${att.mimeType};base64,${att.data}`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
key={attIdx}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2 py-1 text-[11px] text-[var(--color-text-secondary)]"
|
|
||||||
>
|
|
||||||
<Paperclip className="size-3" />
|
|
||||||
{getAttachmentDisplayName(att)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{!isUser && message.pending ? (
|
{!isUser && phaseLabel && (
|
||||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-[var(--color-text-primary)]">
|
<span
|
||||||
{message.content}
|
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] ${assistantBadgeClass}`}
|
||||||
</div>
|
>
|
||||||
) : (
|
{phaseLabel}
|
||||||
<MarkdownContent content={message.content} />
|
</span>
|
||||||
)}
|
)}
|
||||||
{message.pending && message.content && (
|
{showActions && (
|
||||||
<span className="mt-1 inline-block h-4 w-[2px] animate-pulse rounded-sm bg-[var(--color-accent)]" />
|
<div className="ml-auto">
|
||||||
|
<MessageActions
|
||||||
|
message={message}
|
||||||
|
isLastAssistant={isLastAssistant}
|
||||||
|
onCopy={() => handleCopyMessage(message.content)}
|
||||||
|
onPin={() => onPinMessage?.(message.id, !message.isPinned)}
|
||||||
|
onBranch={() => onBranchFromMessage?.(message.id)}
|
||||||
|
onRegenerate={onRegenerateMessage ? () => onRegenerateMessage(message.id) : undefined}
|
||||||
|
onEdit={onEditAndResendMessage && isUser ? () => setEditingMessageId(message.id) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{message.pending && !message.content && <ThinkingDots />}
|
{/* Edit mode */}
|
||||||
|
{isEditing ? (
|
||||||
|
<MessageEditComposer
|
||||||
|
initialContent={message.content}
|
||||||
|
onSave={(content) => handleEditSave(message.id, content)}
|
||||||
|
onCancel={() => setEditingMessageId(undefined)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isUser
|
||||||
|
? 'text-[14px] leading-relaxed text-[var(--color-text-primary)]'
|
||||||
|
: `rounded-xl border px-4 py-3 text-[14px] leading-relaxed text-[var(--color-text-primary)] ${assistantContainerClass}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Attachment thumbnails */}
|
||||||
|
{isUser && message.attachments && message.attachments.length > 0 && (
|
||||||
|
<div className="mb-2 flex flex-wrap gap-2">
|
||||||
|
{message.attachments.map((att, attIdx) =>
|
||||||
|
isImageAttachment(att) ? (
|
||||||
|
<img
|
||||||
|
key={attIdx}
|
||||||
|
alt={getAttachmentDisplayName(att)}
|
||||||
|
className="max-h-48 max-w-xs rounded-lg border border-[var(--color-border)] object-cover"
|
||||||
|
src={`data:${att.mimeType};base64,${att.data}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={attIdx}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2 py-1 text-[11px] text-[var(--color-text-secondary)]"
|
||||||
|
>
|
||||||
|
<Paperclip className="size-3" />
|
||||||
|
{getAttachmentDisplayName(att)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isUser && message.pending ? (
|
||||||
|
<div className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-[var(--color-text-primary)]">
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MarkdownContent content={message.content} />
|
||||||
|
)}
|
||||||
|
{message.pending && message.content && (
|
||||||
|
<span className="mt-1 inline-block h-4 w-[2px] animate-pulse rounded-sm bg-[var(--color-accent)]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{message.pending && !message.content && <ThinkingDots />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{thinkingMessages.length > 0 && lastAssistantIndex < 0 && (
|
||||||
|
<div className="py-2">
|
||||||
|
<ThinkingProcess
|
||||||
|
messages={thinkingMessages}
|
||||||
|
isActive={isSessionBusy}
|
||||||
|
turnStartedAt={turnStartedAt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{activeSubagents && activeSubagents.length > 0 && (
|
{activeSubagents && activeSubagents.length > 0 && (
|
||||||
<div className="px-6 py-1">
|
<div className="px-6 py-1">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
|
Bookmark,
|
||||||
Copy,
|
Copy,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
@@ -51,6 +52,7 @@ export interface CommandPaletteProps {
|
|||||||
onOpenAppDataFolder: () => void;
|
onOpenAppDataFolder: () => void;
|
||||||
onShowShortcuts: () => void;
|
onShowShortcuts: () => void;
|
||||||
onShowSearch: () => void;
|
onShowSearch: () => void;
|
||||||
|
onShowBookmarks: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Score how well `query` matches `text` (and optional `keywords`). 0 = no match. */
|
/** Score how well `query` matches `text` (and optional `keywords`). 0 = no match. */
|
||||||
@@ -92,6 +94,7 @@ export function CommandPalette({
|
|||||||
onOpenAppDataFolder,
|
onOpenAppDataFolder,
|
||||||
onShowShortcuts,
|
onShowShortcuts,
|
||||||
onShowSearch,
|
onShowSearch,
|
||||||
|
onShowBookmarks,
|
||||||
}: CommandPaletteProps) {
|
}: CommandPaletteProps) {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
@@ -234,6 +237,16 @@ export function CommandPalette({
|
|||||||
action: onShowSearch,
|
action: onShowSearch,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cmds.push({
|
||||||
|
id: 'view-bookmarks',
|
||||||
|
label: 'View Bookmarks',
|
||||||
|
category: 'General',
|
||||||
|
keywords: 'pin pinned bookmark bookmarked saved',
|
||||||
|
shortcut: shortcutKeys('bookmarks'),
|
||||||
|
icon: <Bookmark className={ICON} />,
|
||||||
|
action: onShowBookmarks,
|
||||||
|
});
|
||||||
|
|
||||||
cmds.push({
|
cmds.push({
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
label: 'Open Settings',
|
label: 'Open Settings',
|
||||||
@@ -316,7 +329,7 @@ export function CommandPalette({
|
|||||||
onSelectSession, onSelectProject, onNewSession, onCreateScratchpad,
|
onSelectSession, onSelectProject, onNewSession, onCreateScratchpad,
|
||||||
onOpenSettings, onOpenProjectSettings, onToggleTerminal, onSetTheme,
|
onOpenSettings, onOpenProjectSettings, onToggleTerminal, onSetTheme,
|
||||||
onDuplicateSession, onPinSession, onArchiveSession, onAddProject,
|
onDuplicateSession, onPinSession, onArchiveSession, onAddProject,
|
||||||
onOpenAppDataFolder, onShowShortcuts, onShowSearch,
|
onOpenAppDataFolder, onShowShortcuts, onShowSearch, onShowBookmarks,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const filteredCommands = useMemo(() => {
|
const filteredCommands = useMemo(() => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react';
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, Code, Cpu, FolderOpen, Palette, Plus, RefreshCw, Server, TriangleAlert, Workflow, Wrench } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, Palette, Plus, RefreshCw, Server, TriangleAlert, Workflow, Wrench } from 'lucide-react';
|
||||||
|
|
||||||
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
||||||
import { PatternEditor } from '@renderer/components/PatternEditor';
|
import { PatternEditor } from '@renderer/components/PatternEditor';
|
||||||
@@ -859,6 +859,8 @@ function TroubleshootingSection({
|
|||||||
switch (updateStatus.state) {
|
switch (updateStatus.state) {
|
||||||
case 'checking':
|
case 'checking':
|
||||||
return 'Checking for updates…';
|
return 'Checking for updates…';
|
||||||
|
case 'up-to-date':
|
||||||
|
return 'Up to date';
|
||||||
case 'available':
|
case 'available':
|
||||||
return `Update available: v${updateStatus.version ?? 'unknown'}`;
|
return `Update available: v${updateStatus.version ?? 'unknown'}`;
|
||||||
case 'downloading':
|
case 'downloading':
|
||||||
@@ -876,6 +878,8 @@ function TroubleshootingSection({
|
|||||||
switch (updateStatus.state) {
|
switch (updateStatus.state) {
|
||||||
case 'checking':
|
case 'checking':
|
||||||
return 'Contacting the update server…';
|
return 'Contacting the update server…';
|
||||||
|
case 'up-to-date':
|
||||||
|
return 'You are running the latest version of Aryx.';
|
||||||
case 'available':
|
case 'available':
|
||||||
case 'downloading':
|
case 'downloading':
|
||||||
return 'A new version is being downloaded and will be installed automatically.';
|
return 'A new version is being downloaded and will be installed automatically.';
|
||||||
@@ -922,11 +926,13 @@ function TroubleshootingSection({
|
|||||||
onClick={() => void handleCheckForUpdates()}
|
onClick={() => void handleCheckForUpdates()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="text-[var(--color-text-muted)] transition-all duration-200 group-hover:text-[var(--color-text-secondary)]">
|
<span className={`transition-all duration-200 ${updateStatus.state === 'up-to-date' ? 'text-[var(--color-status-success)]' : 'text-[var(--color-text-muted)] group-hover:text-[var(--color-text-secondary)]'}`}>
|
||||||
<RefreshCw className={`size-4 ${isChecking ? 'animate-spin' : ''}`} />
|
{updateStatus.state === 'up-to-date'
|
||||||
|
? <CircleCheck className="size-4" />
|
||||||
|
: <RefreshCw className={`size-4 ${isChecking ? 'animate-spin' : ''}`} />}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<span className={`text-[13px] font-medium ${updateStatus.state === 'error' ? 'text-[var(--color-status-error)]' : 'text-[var(--color-text-primary)]'}`}>
|
<span className={`text-[13px] font-medium ${updateStatus.state === 'error' ? 'text-[var(--color-status-error)]' : updateStatus.state === 'up-to-date' ? 'text-[var(--color-status-success)]' : 'text-[var(--color-text-primary)]'}`}>
|
||||||
{getUpdateLabel()}
|
{getUpdateLabel()}
|
||||||
</span>
|
</span>
|
||||||
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">{getUpdateDescription()}</p>
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">{getUpdateDescription()}</p>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import appIconUrl from '../../../assets/icons/icon.png';
|
import appIconUrl from '../../../assets/icons/icon.png';
|
||||||
|
import { isMac } from '@renderer/lib/platform';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Archive,
|
Archive,
|
||||||
@@ -581,7 +582,7 @@ export function Sidebar({
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Header — extra top padding clears the title bar overlay zone */}
|
{/* Header — extra top padding clears the title bar overlay zone */}
|
||||||
<div className="drag-region flex items-center justify-between border-b border-[var(--color-border-subtle)] px-4 pb-3 pt-3">
|
<div className={`drag-region flex items-center justify-between border-b border-[var(--color-border-subtle)] pb-3 pt-3 pr-4 ${isMac ? 'pl-20' : 'pl-4'}`}>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<img alt="aryx" className="size-8 rounded-xl" src={appIconUrl} />
|
<img alt="aryx" className="size-8 rounded-xl" src={appIconUrl} />
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { ChevronDown, ChevronRight, Loader2, Minus, Search, Sparkles, TerminalSquare } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Loader2, Minus, Search, Sparkles, TerminalSquare } from 'lucide-react';
|
||||||
|
|
||||||
import { ProviderIcon } from '@renderer/components/ProviderIcons';
|
import { ProviderIcon } from '@renderer/components/ProviderIcons';
|
||||||
@@ -250,7 +250,27 @@ export function InlineToolsPill({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && !disabled && (
|
{open && !disabled && (
|
||||||
<div className="absolute bottom-full left-0 z-40 mb-1.5 w-64 overflow-y-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] py-1 shadow-2xl">
|
<div className="absolute bottom-full left-0 z-40 mb-1.5 max-h-[28rem] w-64 overflow-y-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-2xl">
|
||||||
|
{/* Header: enable / disable all */}
|
||||||
|
<div className="sticky top-0 z-10 border-b border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
||||||
|
<div className="flex items-center justify-end px-3 py-1.5">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||||
|
onClick={() => {
|
||||||
|
const allEnabled = enabledCount === totalCount;
|
||||||
|
onToggle({
|
||||||
|
enabledMcpServerIds: allEnabled ? [] : mcpServers.map((s) => s.id),
|
||||||
|
enabledLspProfileIds: allEnabled ? [] : lspProfiles.map((p) => p.id),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{enabledCount === totalCount ? 'Disable all' : 'Enable all'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-1">
|
||||||
{workspaceMcpServers.length > 0 && (
|
{workspaceMcpServers.length > 0 && (
|
||||||
<McpServerGroup
|
<McpServerGroup
|
||||||
label="Workspace MCP"
|
label="Workspace MCP"
|
||||||
@@ -296,6 +316,7 @@ export function InlineToolsPill({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -397,9 +418,19 @@ export function InlineApprovalPill({
|
|||||||
.filter((g) => g.tools.length > 0 || g.label.toLowerCase().includes(searchLower));
|
.filter((g) => g.tools.length > 0 || g.label.toLowerCase().includes(searchLower));
|
||||||
}, [groups, searchLower]);
|
}, [groups, searchLower]);
|
||||||
|
|
||||||
function toggleTool(toolId: string) {
|
function toggleTool(toolId: string, group: ApprovalToolGroup) {
|
||||||
const next = new Set(effectiveAutoApproved);
|
const next = new Set(effectiveAutoApproved);
|
||||||
if (next.has(toolId)) {
|
|
||||||
|
// If the group has server-level approval, expand it to individual tools
|
||||||
|
// so the user can selectively disable one tool.
|
||||||
|
if (group.serverApprovalKey && next.has(group.serverApprovalKey)) {
|
||||||
|
next.delete(group.serverApprovalKey);
|
||||||
|
for (const tool of group.tools) {
|
||||||
|
if (tool.id !== toolId) {
|
||||||
|
next.add(tool.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (next.has(toolId)) {
|
||||||
next.delete(toolId);
|
next.delete(toolId);
|
||||||
} else {
|
} else {
|
||||||
next.add(toolId);
|
next.add(toolId);
|
||||||
@@ -470,6 +501,31 @@ export function InlineApprovalPill({
|
|||||||
return expandedGroups.has(groupId);
|
return expandedGroups.has(groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isToolEffectivelyApproved(toolId: string, group: ApprovalToolGroup): boolean {
|
||||||
|
if (effectiveAutoApproved.has(toolId)) return true;
|
||||||
|
if (group.serverApprovalKey && effectiveAutoApproved.has(group.serverApprovalKey)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allApprovedGlobal = effectiveAutoApprovedCount === totalItemCount && totalItemCount > 0;
|
||||||
|
|
||||||
|
const approveAll = useCallback(() => {
|
||||||
|
const next = new Set<string>();
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.serverApprovalKey) {
|
||||||
|
next.add(group.serverApprovalKey);
|
||||||
|
}
|
||||||
|
for (const tool of group.tools) {
|
||||||
|
next.add(tool.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onUpdate({ autoApprovedToolNames: [...next] });
|
||||||
|
}, [groups, onUpdate]);
|
||||||
|
|
||||||
|
const unapproveAll = useCallback(() => {
|
||||||
|
onUpdate({ autoApprovedToolNames: [] });
|
||||||
|
}, [onUpdate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={ref}>
|
<div className="relative" ref={ref}>
|
||||||
<button
|
<button
|
||||||
@@ -510,16 +566,25 @@ export function InlineApprovalPill({
|
|||||||
}`}>
|
}`}>
|
||||||
{isOverridden ? 'Session override' : 'Pattern defaults'}
|
{isOverridden ? 'Session override' : 'Pattern defaults'}
|
||||||
</span>
|
</span>
|
||||||
{isOverridden && (
|
<span className="ml-auto flex items-center gap-1">
|
||||||
|
{isOverridden && (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||||
|
onClick={() => onUpdate({})}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-2.5" />
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||||
onClick={() => onUpdate({})}
|
onClick={allApprovedGlobal ? unapproveAll : approveAll}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<RotateCcw className="size-2.5" />
|
{allApprovedGlobal ? 'Unapprove all' : 'Approve all'}
|
||||||
Reset
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
@@ -607,9 +672,9 @@ export function InlineApprovalPill({
|
|||||||
<div key={tool.id} className={isCollapsible ? 'pl-3' : ''}>
|
<div key={tool.id} className={isCollapsible ? 'pl-3' : ''}>
|
||||||
<PopoverToggleRow
|
<PopoverToggleRow
|
||||||
detail={detail}
|
detail={detail}
|
||||||
enabled={effectiveAutoApproved.has(tool.id)}
|
enabled={isToolEffectivelyApproved(tool.id, group)}
|
||||||
label={tool.label}
|
label={tool.label}
|
||||||
onToggle={() => toggleTool(tool.id)}
|
onToggle={() => toggleTool(tool.id, group)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -642,8 +707,8 @@ function GroupToggle({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-pressed={allApproved}
|
aria-pressed={allApproved}
|
||||||
className={`relative inline-flex h-[16px] w-[28px] shrink-0 items-center rounded-full transition-colors ${
|
className={`relative inline-flex h-[14px] w-[24px] shrink-0 items-center rounded-full transition-all duration-200 ${
|
||||||
allApproved ? 'bg-[var(--color-accent)]' : someApproved ? 'bg-[var(--color-surface-3)]' : 'bg-[var(--color-surface-3)]'
|
allApproved ? 'brand-gradient-bg shadow-[0_0_8px_rgba(36,92,249,0.3)]' : 'bg-[var(--color-surface-3)]'
|
||||||
}`}
|
}`}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -652,8 +717,8 @@ function GroupToggle({
|
|||||||
<Minus className="absolute left-1/2 size-2 -translate-x-1/2 text-[var(--color-text-primary)]" strokeWidth={3} />
|
<Minus className="absolute left-1/2 size-2 -translate-x-1/2 text-[var(--color-text-primary)]" strokeWidth={3} />
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className={`inline-block size-[12px] rounded-full bg-white shadow transition-transform ${
|
className={`inline-block size-[10px] rounded-full bg-white shadow-sm transition-transform ${
|
||||||
allApproved ? 'translate-x-[13px]' : 'translate-x-[2px]'
|
allApproved ? 'translate-x-[12px]' : 'translate-x-[2px]'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||||
|
|
||||||
|
interface ThinkingProcessProps {
|
||||||
|
messages: ChatMessageRecord[];
|
||||||
|
isActive: boolean;
|
||||||
|
turnStartedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThinkingProcess({ messages, isActive, turnStartedAt }: ThinkingProcessProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const wasActiveRef = useRef(isActive);
|
||||||
|
|
||||||
|
// Auto-expand when the turn is active and thinking messages appear.
|
||||||
|
// Auto-collapse once the turn finishes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive && messages.length > 0) {
|
||||||
|
setExpanded(true);
|
||||||
|
} else if (wasActiveRef.current && !isActive) {
|
||||||
|
setExpanded(false);
|
||||||
|
}
|
||||||
|
wasActiveRef.current = isActive;
|
||||||
|
}, [isActive, messages.length]);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
|
||||||
|
|
||||||
|
const elapsed = useMemo(() => {
|
||||||
|
if (!turnStartedAt || messages.length === 0) return undefined;
|
||||||
|
const start = new Date(turnStartedAt).getTime();
|
||||||
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
const end = isActive ? Date.now() : new Date(lastMessage.createdAt).getTime();
|
||||||
|
const seconds = Math.max(0, Math.round((end - start) / 1000));
|
||||||
|
if (seconds < 2) return undefined;
|
||||||
|
return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
|
||||||
|
}, [turnStartedAt, messages, isActive]);
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepCount = messages.length;
|
||||||
|
const summaryParts: string[] = [];
|
||||||
|
if (elapsed) summaryParts.push(`${elapsed}`);
|
||||||
|
summaryParts.push(`${stepCount} ${stepCount === 1 ? 'step' : 'steps'}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="thinking-process-enter mb-2 overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-1)]/60">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
onKeyDown={(e) => { if (e.key === ' ') { e.preventDefault(); toggle(); } }}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-2)]/50"
|
||||||
|
>
|
||||||
|
<Brain className="size-3.5 shrink-0 text-[var(--color-accent-purple)]" />
|
||||||
|
{isActive ? (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[var(--color-text-secondary)]">Thinking</span>
|
||||||
|
<ThinkingPulse />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--color-text-secondary)]">
|
||||||
|
Thought for {summaryParts.join(' · ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto shrink-0">
|
||||||
|
{expanded
|
||||||
|
? <ChevronDown className="size-3 text-[var(--color-text-muted)]" />
|
||||||
|
: <ChevronRight className="size-3 text-[var(--color-text-muted)]" />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="border-t border-[var(--color-border)]/30 px-3 py-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<ThinkingStep key={message.id} message={message} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThinkingStep({ message }: { message: ChatMessageRecord }) {
|
||||||
|
const preview = useMemo(() => truncatePreview(message.content, 180), [message.content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 text-[12px] leading-relaxed">
|
||||||
|
<span className="mt-0.5 shrink-0 text-[var(--color-text-muted)]">▸</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{message.authorName && (
|
||||||
|
<span className="mr-1.5 font-medium text-[var(--color-text-secondary)]">
|
||||||
|
{message.authorName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[var(--color-text-muted)]">{preview}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThinkingPulse() {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
|
||||||
|
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
|
||||||
|
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncatePreview(text: string, maxLength: number): string {
|
||||||
|
const firstLine = text.split('\n')[0] ?? '';
|
||||||
|
const cleaned = firstLine.trim();
|
||||||
|
if (cleaned.length <= maxLength) return cleaned;
|
||||||
|
return `${cleaned.slice(0, maxLength)}…`;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const isMac = navigator.platform.startsWith('Mac');
|
import { isMac } from '@renderer/lib/platform';
|
||||||
|
|
||||||
/** Platform-aware modifier key label. */
|
/** Platform-aware modifier key label. */
|
||||||
export const MOD = isMac ? '⌘' : 'Ctrl';
|
export const MOD = isMac ? '⌘' : 'Ctrl';
|
||||||
@@ -18,6 +18,7 @@ export const shortcuts: ShortcutDefinition[] = [
|
|||||||
// ── Navigation ──
|
// ── Navigation ──
|
||||||
{ id: 'command-palette', label: 'Command palette', keys: `${MOD}+K`, category: 'Navigation' },
|
{ id: 'command-palette', label: 'Command palette', keys: `${MOD}+K`, category: 'Navigation' },
|
||||||
{ id: 'search-sessions', label: 'Search sessions', keys: `${MOD}+Shift+F`, category: 'Navigation' },
|
{ id: 'search-sessions', label: 'Search sessions', keys: `${MOD}+Shift+F`, category: 'Navigation' },
|
||||||
|
{ id: 'bookmarks', label: 'View bookmarks', keys: `${MOD}+Shift+B`, category: 'Navigation' },
|
||||||
{ id: 'settings', label: 'Open settings', keys: `${MOD}+,`, category: 'Navigation' },
|
{ id: 'settings', label: 'Open settings', keys: `${MOD}+,`, category: 'Navigation' },
|
||||||
{ id: 'toggle-terminal', label: 'Toggle terminal', keys: 'Ctrl+`', category: 'Navigation' },
|
{ id: 'toggle-terminal', label: 'Toggle terminal', keys: 'Ctrl+`', category: 'Navigation' },
|
||||||
{ id: 'shortcut-help', label: 'Keyboard shortcuts', keys: `${MOD}+/`, category: 'Navigation' },
|
{ id: 'shortcut-help', label: 'Keyboard shortcuts', keys: `${MOD}+/`, category: 'Navigation' },
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export function getAssistantMessagePhase(
|
|||||||
return 'default';
|
return 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.messageKind === 'thinking') {
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
if (message.pending) {
|
if (message.pending) {
|
||||||
return 'thinking';
|
return 'thinking';
|
||||||
}
|
}
|
||||||
@@ -26,7 +30,7 @@ export function getAssistantMessagePhase(
|
|||||||
function findLastCompletedAssistantIndex(messages: ChatMessageRecord[]): number {
|
function findLastCompletedAssistantIndex(messages: ChatMessageRecord[]): number {
|
||||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
const message = messages[index];
|
const message = messages[index];
|
||||||
if (message.role === 'assistant' && !message.pending) {
|
if (message.role === 'assistant' && !message.pending && message.messageKind !== 'thinking') {
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const isMac = navigator.platform.startsWith('Mac');
|
||||||
@@ -41,6 +41,8 @@ function applySessionEvent(session: SessionRecord, event: SessionEventRecord): S
|
|||||||
return applyMessageDeltaEvent(session, event);
|
return applyMessageDeltaEvent(session, event);
|
||||||
case 'message-complete':
|
case 'message-complete':
|
||||||
return applyMessageCompleteEvent(session, event);
|
return applyMessageCompleteEvent(session, event);
|
||||||
|
case 'message-reclassified':
|
||||||
|
return applyMessageReclassifiedEvent(session, event);
|
||||||
case 'run-updated':
|
case 'run-updated':
|
||||||
return applyRunUpdatedEvent(session, event);
|
return applyRunUpdatedEvent(session, event);
|
||||||
default:
|
default:
|
||||||
@@ -172,6 +174,30 @@ function applyMessageCompleteEvent(session: SessionRecord, event: SessionEventRe
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyMessageReclassifiedEvent(session: SessionRecord, event: SessionEventRecord): SessionRecord {
|
||||||
|
if (!event.messageId || !event.messageKind) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageIndex = session.messages.findIndex((message) => message.id === event.messageId);
|
||||||
|
if (messageIndex < 0) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = session.messages[messageIndex];
|
||||||
|
if (existing.messageKind === event.messageKind) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMessages = session.messages.slice();
|
||||||
|
nextMessages[messageIndex] = { ...existing, messageKind: event.messageKind };
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
messages: nextMessages,
|
||||||
|
updatedAt: event.occurredAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function applyRunUpdatedEvent(session: SessionRecord, event: SessionEventRecord): SessionRecord {
|
function applyRunUpdatedEvent(session: SessionRecord, event: SessionEventRecord): SessionRecord {
|
||||||
if (!event.run) {
|
if (!event.run) {
|
||||||
return session;
|
return session;
|
||||||
|
|||||||
+15
-1
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Thinking process section ────────────────────────────────── */
|
||||||
|
|
||||||
|
@keyframes thinking-process-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-process-enter {
|
||||||
|
animation: thinking-process-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Respect reduced motion ──────────────────────────────────── */
|
/* ── Respect reduced motion ──────────────────────────────────── */
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -637,7 +650,8 @@ body {
|
|||||||
.message-enter,
|
.message-enter,
|
||||||
.msg-actions-enter,
|
.msg-actions-enter,
|
||||||
.session-item-enter,
|
.session-item-enter,
|
||||||
.banner-slide-enter {
|
.banner-slide-enter,
|
||||||
|
.thinking-process-enter {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export interface SetTerminalHeightInput {
|
|||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateStatusState = 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'error';
|
export type UpdateStatusState = 'idle' | 'checking' | 'up-to-date' | 'available' | 'downloading' | 'downloaded' | 'error';
|
||||||
|
|
||||||
export interface UpdateDownloadProgress {
|
export interface UpdateDownloadProgress {
|
||||||
bytesPerSecond: number;
|
bytesPerSecond: number;
|
||||||
|
|||||||
@@ -239,6 +239,14 @@ export interface TurnCompleteEvent {
|
|||||||
cancelled?: boolean;
|
cancelled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MessageReclassifiedEvent {
|
||||||
|
type: 'message-reclassified';
|
||||||
|
requestId: string;
|
||||||
|
sessionId: string;
|
||||||
|
messageId: string;
|
||||||
|
newKind: 'thinking';
|
||||||
|
}
|
||||||
|
|
||||||
export type AgentActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
|
export type AgentActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
|
||||||
|
|
||||||
export interface ToolCallFileChangePreview {
|
export interface ToolCallFileChangePreview {
|
||||||
@@ -297,6 +305,25 @@ export interface SkillInvokedEvent {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssistantIntentEvent {
|
||||||
|
type: 'assistant-intent';
|
||||||
|
requestId: string;
|
||||||
|
sessionId: string;
|
||||||
|
agentId?: string;
|
||||||
|
agentName?: string;
|
||||||
|
intent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReasoningDeltaEvent {
|
||||||
|
type: 'reasoning-delta';
|
||||||
|
requestId: string;
|
||||||
|
sessionId: string;
|
||||||
|
agentId?: string;
|
||||||
|
agentName?: string;
|
||||||
|
reasoningId: string;
|
||||||
|
contentDelta: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HookLifecycleEvent {
|
export interface HookLifecycleEvent {
|
||||||
type: 'hook-lifecycle';
|
type: 'hook-lifecycle';
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@@ -526,9 +553,12 @@ export type SidecarEvent =
|
|||||||
| PatternValidationEvent
|
| PatternValidationEvent
|
||||||
| TurnDeltaEvent
|
| TurnDeltaEvent
|
||||||
| TurnCompleteEvent
|
| TurnCompleteEvent
|
||||||
|
| MessageReclassifiedEvent
|
||||||
| AgentActivityEvent
|
| AgentActivityEvent
|
||||||
| SubagentEvent
|
| SubagentEvent
|
||||||
| SkillInvokedEvent
|
| SkillInvokedEvent
|
||||||
|
| AssistantIntentEvent
|
||||||
|
| ReasoningDeltaEvent
|
||||||
| HookLifecycleEvent
|
| HookLifecycleEvent
|
||||||
| SessionUsageEvent
|
| SessionUsageEvent
|
||||||
| SessionCompactionEvent
|
| SessionCompactionEvent
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
||||||
|
import type { ChatMessageKind } from '@shared/domain/session';
|
||||||
|
|
||||||
import type { QuotaSnapshot, ToolCallFileChangePreview } from '@shared/contracts/sidecar';
|
import type { QuotaSnapshot, ToolCallFileChangePreview } from '@shared/contracts/sidecar';
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ export type SessionEventKind =
|
|||||||
| 'status'
|
| 'status'
|
||||||
| 'message-delta'
|
| 'message-delta'
|
||||||
| 'message-complete'
|
| 'message-complete'
|
||||||
|
| 'message-reclassified'
|
||||||
| 'agent-activity'
|
| 'agent-activity'
|
||||||
| 'run-updated'
|
| 'run-updated'
|
||||||
| 'error'
|
| 'error'
|
||||||
@@ -27,6 +29,7 @@ export interface SessionEventRecord {
|
|||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
status?: 'idle' | 'running' | 'error';
|
status?: 'idle' | 'running' | 'error';
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
|
messageKind?: ChatMessageKind;
|
||||||
authorName?: string;
|
authorName?: string;
|
||||||
contentDelta?: string;
|
contentDelta?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
|||||||
import type { InteractionMode } from '@shared/contracts/sidecar';
|
import type { InteractionMode } from '@shared/contracts/sidecar';
|
||||||
|
|
||||||
export type ChatRole = 'system' | 'user' | 'assistant';
|
export type ChatRole = 'system' | 'user' | 'assistant';
|
||||||
|
export type ChatMessageKind = 'response' | 'thinking';
|
||||||
export type SessionStatus = 'idle' | 'running' | 'error';
|
export type SessionStatus = 'idle' | 'running' | 'error';
|
||||||
export type SessionTitleSource = 'auto' | 'manual';
|
export type SessionTitleSource = 'auto' | 'manual';
|
||||||
export type SessionBranchOriginAction = 'branch' | 'regenerate' | 'edit-and-resend';
|
export type SessionBranchOriginAction = 'branch' | 'regenerate' | 'edit-and-resend';
|
||||||
@@ -33,6 +34,7 @@ export interface ChatMessageRecord {
|
|||||||
authorName: string;
|
authorName: string;
|
||||||
content: string;
|
content: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
messageKind?: ChatMessageKind;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
attachments?: ChatMessageAttachment[];
|
attachments?: ChatMessageAttachment[];
|
||||||
|
|||||||
@@ -395,6 +395,44 @@ export function editAndResendSessionRecord(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pinned messages ──
|
||||||
|
|
||||||
|
export interface PinnedMessageHit {
|
||||||
|
session: SessionRecord;
|
||||||
|
projectName: string;
|
||||||
|
message: ChatMessageRecord;
|
||||||
|
/** Truncated preview of the message content. */
|
||||||
|
snippet: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMessageSnippet(content: string, maxLength = 120): string {
|
||||||
|
const collapsed = content.replace(/\n+/g, ' ').trim();
|
||||||
|
if (collapsed.length <= maxLength) return collapsed;
|
||||||
|
return collapsed.slice(0, maxLength) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPinnedMessages(workspace: WorkspaceState): PinnedMessageHit[] {
|
||||||
|
const projectNames = new Map<string, string>(
|
||||||
|
workspace.projects.map((p) => [p.id, isScratchpadProject(p) ? 'Scratchpad' : p.name]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return workspace.sessions
|
||||||
|
.filter((session) => !session.isArchived)
|
||||||
|
.flatMap((session) =>
|
||||||
|
session.messages
|
||||||
|
.filter((message) => message.isPinned && message.content)
|
||||||
|
.map((message) => ({
|
||||||
|
session,
|
||||||
|
projectName: projectNames.get(session.projectId) ?? 'Unknown',
|
||||||
|
message,
|
||||||
|
snippet: extractMessageSnippet(message.content),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.message.createdAt.localeCompare(a.message.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session query ──
|
||||||
|
|
||||||
export function querySessions(workspace: WorkspaceState, input: QuerySessionsInput): SessionQueryResult[] {
|
export function querySessions(workspace: WorkspaceState, input: QuerySessionsInput): SessionQueryResult[] {
|
||||||
const projectsById = new Map<string, ProjectRecord>(workspace.projects.map((project) => [project.id, project]));
|
const projectsById = new Map<string, ProjectRecord>(workspace.projects.map((project) => [project.id, project]));
|
||||||
const patternsById = new Map<string, PatternDefinition>(workspace.patterns.map((pattern) => [pattern.id, pattern]));
|
const patternsById = new Map<string, PatternDefinition>(workspace.patterns.map((pattern) => [pattern.id, pattern]));
|
||||||
|
|||||||
@@ -395,6 +395,34 @@ function resolveApprovalToolGroupKey(
|
|||||||
return { id: 'other', label: 'Other', kind: 'mixed' };
|
return { id: 'other', label: 'Other', kind: 'mixed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count how many tools are effectively auto-approved across all groups.
|
||||||
|
*
|
||||||
|
* This must use per-group counting (not unique-tool-ID deduplication) to stay
|
||||||
|
* consistent with the total-item formula used in InlineApprovalPill, which
|
||||||
|
* counts each group occurrence independently. Without this, shared tool names
|
||||||
|
* across MCP servers produce a numerator smaller than the denominator even when
|
||||||
|
* everything is approved.
|
||||||
|
*/
|
||||||
|
export function countApprovedToolsInGroups(
|
||||||
|
groups: ReadonlyArray<ApprovalToolGroup>,
|
||||||
|
approved: ReadonlySet<string>,
|
||||||
|
): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.serverApprovalKey && approved.has(group.serverApprovalKey)) {
|
||||||
|
// Server-level approval covers all tools. Servers with 0 tools still
|
||||||
|
// count as 1 (matching the totalItemCount formula).
|
||||||
|
count += Math.max(group.tools.length, 1);
|
||||||
|
} else {
|
||||||
|
for (const tool of group.tools) {
|
||||||
|
if (approved.has(tool.id)) count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateMcpServerDefinition(server: McpServerDefinition): string | undefined {
|
export function validateMcpServerDefinition(server: McpServerDefinition): string | undefined {
|
||||||
if (!server.name.trim()) {
|
if (!server.name.trim()) {
|
||||||
return 'MCP server name is required.';
|
return 'MCP server name is required.';
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ class FakeUpdater extends EventEmitter {
|
|||||||
|
|
||||||
autoInstallOnAppQuit = true;
|
autoInstallOnAppQuit = true;
|
||||||
|
|
||||||
|
forceDevUpdateConfig = false;
|
||||||
|
|
||||||
checkForUpdatesCalls = 0;
|
checkForUpdatesCalls = 0;
|
||||||
|
|
||||||
quitAndInstallCalls = 0;
|
quitAndInstallCalls = 0;
|
||||||
@@ -62,7 +64,7 @@ class FakeScheduler implements AutoUpdateScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('AutoUpdateService', () => {
|
describe('AutoUpdateService', () => {
|
||||||
test('does not schedule checks for unpackaged apps', async () => {
|
test('does not schedule checks for unpackaged apps but manual checks still work', async () => {
|
||||||
const updater = new FakeUpdater();
|
const updater = new FakeUpdater();
|
||||||
const scheduler = new FakeScheduler();
|
const scheduler = new FakeScheduler();
|
||||||
const service = new AutoUpdateService({ isPackaged: false, scheduler, updater });
|
const service = new AutoUpdateService({ isPackaged: false, scheduler, updater });
|
||||||
@@ -71,8 +73,10 @@ describe('AutoUpdateService', () => {
|
|||||||
|
|
||||||
expect(scheduler.timeouts).toHaveLength(0);
|
expect(scheduler.timeouts).toHaveLength(0);
|
||||||
expect(scheduler.intervals).toHaveLength(0);
|
expect(scheduler.intervals).toHaveLength(0);
|
||||||
expect(await service.checkForUpdates()).toEqual({ state: 'idle' });
|
expect(updater.forceDevUpdateConfig).toBe(true);
|
||||||
expect(updater.checkForUpdatesCalls).toBe(0);
|
|
||||||
|
await service.checkForUpdates();
|
||||||
|
expect(updater.checkForUpdatesCalls).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('configures auto download and schedules startup and periodic checks', async () => {
|
test('configures auto download and schedules startup and periodic checks', async () => {
|
||||||
@@ -148,6 +152,22 @@ describe('AutoUpdateService', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('transitions to up-to-date when no update is available', () => {
|
||||||
|
const updater = new FakeUpdater();
|
||||||
|
const service = new AutoUpdateService({ isPackaged: true, updater });
|
||||||
|
const statuses: UpdateStatus[] = [];
|
||||||
|
service.onStatus((status) => statuses.push(status));
|
||||||
|
|
||||||
|
updater.emit('checking-for-update');
|
||||||
|
updater.emit('update-not-available', {});
|
||||||
|
|
||||||
|
expect(statuses).toEqual([
|
||||||
|
{ state: 'checking' },
|
||||||
|
{ state: 'up-to-date' },
|
||||||
|
]);
|
||||||
|
expect(service.getStatus()).toEqual({ state: 'up-to-date' });
|
||||||
|
});
|
||||||
|
|
||||||
test('reports updater errors and only installs once an update is downloaded', () => {
|
test('reports updater errors and only installs once an update is downloaded', () => {
|
||||||
const updater = new FakeUpdater();
|
const updater = new FakeUpdater();
|
||||||
const service = new AutoUpdateService({ isPackaged: true, updater });
|
const service = new AutoUpdateService({ isPackaged: true, updater });
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ describe('run turn pending helpers', () => {
|
|||||||
onUserInput: () => undefined,
|
onUserInput: () => undefined,
|
||||||
onExitPlanMode: () => undefined,
|
onExitPlanMode: () => undefined,
|
||||||
onMcpOAuthRequired: () => undefined,
|
onMcpOAuthRequired: () => undefined,
|
||||||
|
onMessageReclassified: () => undefined,
|
||||||
onTurnScopedEvent: () => undefined,
|
onTurnScopedEvent: () => undefined,
|
||||||
errored: false,
|
errored: false,
|
||||||
};
|
};
|
||||||
@@ -45,6 +46,7 @@ describe('run turn pending helpers', () => {
|
|||||||
onUserInput: () => undefined,
|
onUserInput: () => undefined,
|
||||||
onExitPlanMode: () => undefined,
|
onExitPlanMode: () => undefined,
|
||||||
onMcpOAuthRequired: () => undefined,
|
onMcpOAuthRequired: () => undefined,
|
||||||
|
onMessageReclassified: () => undefined,
|
||||||
onTurnScopedEvent: () => undefined,
|
onTurnScopedEvent: () => undefined,
|
||||||
errored: false,
|
errored: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -85,4 +85,59 @@ describe('assistant message phase', () => {
|
|||||||
|
|
||||||
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default');
|
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('returns default for thinking-kind messages regardless of other state', () => {
|
||||||
|
const session = createSession([
|
||||||
|
{
|
||||||
|
id: 'msg-1',
|
||||||
|
role: 'assistant',
|
||||||
|
authorName: 'Primary Agent',
|
||||||
|
content: 'Let me search...',
|
||||||
|
createdAt: '2026-03-23T00:00:00.000Z',
|
||||||
|
messageKind: 'thinking',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-2',
|
||||||
|
role: 'assistant',
|
||||||
|
authorName: 'Primary Agent',
|
||||||
|
content: 'Here is the result.',
|
||||||
|
createdAt: '2026-03-23T00:00:01.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default');
|
||||||
|
expect(getAssistantMessagePhase(session, session.messages[1], 1)).toBe('final');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips thinking messages when determining the last completed assistant', () => {
|
||||||
|
const session = createSession([
|
||||||
|
{
|
||||||
|
id: 'msg-1',
|
||||||
|
role: 'assistant',
|
||||||
|
authorName: 'Primary Agent',
|
||||||
|
content: 'Let me search...',
|
||||||
|
createdAt: '2026-03-23T00:00:00.000Z',
|
||||||
|
messageKind: 'thinking',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-2',
|
||||||
|
role: 'assistant',
|
||||||
|
authorName: 'Primary Agent',
|
||||||
|
content: 'More searching...',
|
||||||
|
createdAt: '2026-03-23T00:00:01.000Z',
|
||||||
|
messageKind: 'thinking',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-3',
|
||||||
|
role: 'assistant',
|
||||||
|
authorName: 'Primary Agent',
|
||||||
|
content: 'Final answer.',
|
||||||
|
createdAt: '2026-03-23T00:00:02.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default');
|
||||||
|
expect(getAssistantMessagePhase(session, session.messages[1], 1)).toBe('default');
|
||||||
|
expect(getAssistantMessagePhase(session, session.messages[2], 2)).toBe('final');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -281,4 +281,73 @@ describe('session workspace helpers', () => {
|
|||||||
} satisfies SessionEventRecord),
|
} satisfies SessionEventRecord),
|
||||||
).toBe(workspace);
|
).toBe(workspace);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('reclassifies a message as thinking when message-reclassified event arrives', () => {
|
||||||
|
const workspace = applySessionEventWorkspace(createWorkspace(), {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
kind: 'message-delta',
|
||||||
|
occurredAt: '2026-03-23T00:00:01.000Z',
|
||||||
|
messageId: 'assistant-1',
|
||||||
|
authorName: 'Primary Agent',
|
||||||
|
contentDelta: 'Let me search...',
|
||||||
|
content: 'Let me search...',
|
||||||
|
} satisfies SessionEventRecord);
|
||||||
|
|
||||||
|
const reclassified = applySessionEventWorkspace(workspace, {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
kind: 'message-reclassified',
|
||||||
|
occurredAt: '2026-03-23T00:00:02.000Z',
|
||||||
|
messageId: 'assistant-1',
|
||||||
|
messageKind: 'thinking',
|
||||||
|
} satisfies SessionEventRecord);
|
||||||
|
|
||||||
|
expect(reclassified?.sessions[0].messages[0]).toMatchObject({
|
||||||
|
id: 'assistant-1',
|
||||||
|
messageKind: 'thinking',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores message-reclassified for an already reclassified message', () => {
|
||||||
|
let workspace = applySessionEventWorkspace(createWorkspace(), {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
kind: 'message-delta',
|
||||||
|
occurredAt: '2026-03-23T00:00:01.000Z',
|
||||||
|
messageId: 'assistant-1',
|
||||||
|
authorName: 'Primary Agent',
|
||||||
|
contentDelta: 'Let me search...',
|
||||||
|
content: 'Let me search...',
|
||||||
|
} satisfies SessionEventRecord);
|
||||||
|
|
||||||
|
workspace = applySessionEventWorkspace(workspace, {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
kind: 'message-reclassified',
|
||||||
|
occurredAt: '2026-03-23T00:00:02.000Z',
|
||||||
|
messageId: 'assistant-1',
|
||||||
|
messageKind: 'thinking',
|
||||||
|
} satisfies SessionEventRecord);
|
||||||
|
|
||||||
|
const duplicate = applySessionEventWorkspace(workspace, {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
kind: 'message-reclassified',
|
||||||
|
occurredAt: '2026-03-23T00:00:03.000Z',
|
||||||
|
messageId: 'assistant-1',
|
||||||
|
messageKind: 'thinking',
|
||||||
|
} satisfies SessionEventRecord);
|
||||||
|
|
||||||
|
// Should return the same reference (no change)
|
||||||
|
expect(duplicate).toBe(workspace);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores message-reclassified for unknown message ids', () => {
|
||||||
|
const workspace = createWorkspace();
|
||||||
|
const result = applySessionEventWorkspace(workspace, {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
kind: 'message-reclassified',
|
||||||
|
occurredAt: '2026-03-23T00:00:01.000Z',
|
||||||
|
messageId: 'nonexistent',
|
||||||
|
messageKind: 'thinking',
|
||||||
|
} satisfies SessionEventRecord);
|
||||||
|
|
||||||
|
expect(result).toBe(workspace);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildMcpServerApprovalKey,
|
buildMcpServerApprovalKey,
|
||||||
|
countApprovedToolsInGroups,
|
||||||
groupApprovalToolsByProvider,
|
groupApprovalToolsByProvider,
|
||||||
listApprovalToolDefinitions,
|
listApprovalToolDefinitions,
|
||||||
listApprovalToolNames,
|
listApprovalToolNames,
|
||||||
@@ -658,3 +659,101 @@ describe('probed tools', () => {
|
|||||||
expect(names).toContain('mcp_server:Probed Keys');
|
expect(names).toContain('mcp_server:Probed Keys');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('countApprovedToolsInGroups', () => {
|
||||||
|
const TS = '2026-03-28T00:00:00.000Z';
|
||||||
|
|
||||||
|
function makeTooling(
|
||||||
|
mcpServers: McpServerDefinition[] = [],
|
||||||
|
lspProfiles: LspProfileDefinition[] = [],
|
||||||
|
): WorkspaceToolingSettings {
|
||||||
|
return { mcpServers, lspProfiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMcpServer(id: string, name: string, tools: string[]): McpServerDefinition {
|
||||||
|
return { id, name, transport: 'local', command: 'node', args: [], tools, createdAt: TS, updatedAt: TS };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('counts all tools approved when all server keys are set', () => {
|
||||||
|
const tooling = makeTooling([
|
||||||
|
makeMcpServer('git', 'Git MCP', ['git.status', 'git.diff']),
|
||||||
|
makeMcpServer('fs', 'Filesystem', ['fs.read']),
|
||||||
|
]);
|
||||||
|
const tools = listApprovalToolDefinitions(tooling);
|
||||||
|
const groups = groupApprovalToolsByProvider(tools, tooling);
|
||||||
|
const mcpGroups = groups.filter((g) => g.kind === 'mcp');
|
||||||
|
const approved = new Set(['mcp_server:Git MCP', 'mcp_server:Filesystem']);
|
||||||
|
|
||||||
|
const mcpTotal = mcpGroups.reduce(
|
||||||
|
(sum, g) => sum + Math.max(g.tools.length, g.serverApprovalKey ? 1 : 0), 0,
|
||||||
|
);
|
||||||
|
const count = countApprovedToolsInGroups(mcpGroups, approved);
|
||||||
|
expect(count).toBe(mcpTotal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('counts shared tool names per group, not as unique IDs', () => {
|
||||||
|
const tooling = makeTooling([
|
||||||
|
makeMcpServer('git-a', 'Git A', ['git.status', 'git.diff']),
|
||||||
|
makeMcpServer('git-b', 'Git B', ['git.status', 'git.diff']),
|
||||||
|
]);
|
||||||
|
const tools = listApprovalToolDefinitions(tooling);
|
||||||
|
const groups = groupApprovalToolsByProvider(tools, tooling);
|
||||||
|
|
||||||
|
const mcpGroups = groups.filter((g) => g.kind === 'mcp');
|
||||||
|
expect(mcpGroups.length).toBe(2);
|
||||||
|
// Both servers share the same tool names — MCP total should be 4 (2 per group)
|
||||||
|
const mcpTotal = mcpGroups.reduce(
|
||||||
|
(sum, g) => sum + Math.max(g.tools.length, g.serverApprovalKey ? 1 : 0), 0,
|
||||||
|
);
|
||||||
|
expect(mcpTotal).toBe(4);
|
||||||
|
|
||||||
|
// Approve all via server keys — count must match total
|
||||||
|
const approved = new Set(['mcp_server:Git A', 'mcp_server:Git B']);
|
||||||
|
const count = countApprovedToolsInGroups(mcpGroups, approved);
|
||||||
|
expect(count).toBe(mcpTotal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('counts individual tool approvals per group when no server key', () => {
|
||||||
|
const tooling = makeTooling([
|
||||||
|
makeMcpServer('git-a', 'Git A', ['git.status']),
|
||||||
|
makeMcpServer('git-b', 'Git B', ['git.status']),
|
||||||
|
]);
|
||||||
|
const tools = listApprovalToolDefinitions(tooling);
|
||||||
|
const groups = groupApprovalToolsByProvider(tools, tooling);
|
||||||
|
|
||||||
|
// Approve only the individual tool ID (shared across both groups)
|
||||||
|
const approved = new Set(['git.status']);
|
||||||
|
const mcpGroups = groups.filter((g) => g.kind === 'mcp');
|
||||||
|
expect(mcpGroups.length).toBe(2);
|
||||||
|
|
||||||
|
// Each group has the same tool → count should be 2 (once per group)
|
||||||
|
const mcpCount = countApprovedToolsInGroups(mcpGroups, approved);
|
||||||
|
expect(mcpCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('counts empty server with approved key as 1', () => {
|
||||||
|
const tooling = makeTooling([makeMcpServer('empty', 'Empty Server', [])]);
|
||||||
|
const tools = listApprovalToolDefinitions(tooling);
|
||||||
|
const groups = groupApprovalToolsByProvider(tools, tooling);
|
||||||
|
const approved = new Set(['mcp_server:Empty Server']);
|
||||||
|
|
||||||
|
const mcpGroups = groups.filter((g) => g.kind === 'mcp');
|
||||||
|
expect(mcpGroups.length).toBe(1);
|
||||||
|
expect(mcpGroups[0].tools.length).toBe(0);
|
||||||
|
|
||||||
|
const count = countApprovedToolsInGroups(mcpGroups, approved);
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 0 when nothing is approved', () => {
|
||||||
|
const tooling = makeTooling([
|
||||||
|
makeMcpServer('git', 'Git MCP', ['git.status']),
|
||||||
|
]);
|
||||||
|
const tools = listApprovalToolDefinitions(tooling);
|
||||||
|
const groups = groupApprovalToolsByProvider(tools, tooling);
|
||||||
|
const approved = new Set<string>();
|
||||||
|
|
||||||
|
const count = countApprovedToolsInGroups(groups, approved);
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineConfig } from 'astro/config';
|
|||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
site: 'https://aryx.app',
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
"astro": "^5.7.10",
|
"astro": "^5.7.10",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.4",
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
|
"satori": "^0.26.0",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -148,6 +152,32 @@
|
|||||||
|
|
||||||
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
|
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
|
||||||
|
|
||||||
|
"@resvg/resvg-js": ["@resvg/resvg-js@2.6.2", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.6.2", "@resvg/resvg-js-android-arm64": "2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", "@resvg/resvg-js-linux-arm64-musl": "2.6.2", "@resvg/resvg-js-linux-x64-gnu": "2.6.2", "@resvg/resvg-js-linux-x64-musl": "2.6.2", "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", "@resvg/resvg-js-win32-x64-msvc": "2.6.2" } }, "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q=="],
|
||||||
|
|
||||||
|
"@resvg/resvg-js-android-arm-eabi": ["@resvg/resvg-js-android-arm-eabi@2.6.2", "", { "os": "android", "cpu": "arm" }, "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA=="],
|
||||||
|
|
||||||
|
"@resvg/resvg-js-android-arm64": ["@resvg/resvg-js-android-arm64@2.6.2", "", { "os": "android", "cpu": "arm64" }, "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ=="],
|
||||||
|
|
||||||
|
"@resvg/resvg-js-darwin-arm64": ["@resvg/resvg-js-darwin-arm64@2.6.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A=="],
|
||||||
|
|
||||||
|
"@resvg/resvg-js-darwin-x64": ["@resvg/resvg-js-darwin-x64@2.6.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw=="],
|
||||||
|
|
||||||
|
"@resvg/resvg-js-linux-arm-gnueabihf": ["@resvg/resvg-js-linux-arm-gnueabihf@2.6.2", "", { "os": "linux", "cpu": "arm" }, "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw=="],
|
||||||
|
|
||||||
|
"@resvg/resvg-js-linux-arm64-gnu": ["@resvg/resvg-js-linux-arm64-gnu@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg=="],
|
||||||
|
|
||||||
|
"@resvg/resvg-js-linux-arm64-musl": ["@resvg/resvg-js-linux-arm64-musl@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg=="],
|
||||||
|
|
||||||
|
"@resvg/resvg-js-linux-x64-gnu": ["@resvg/resvg-js-linux-x64-gnu@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw=="],
|
||||||
|
|
||||||
|
"@resvg/resvg-js-linux-x64-musl": ["@resvg/resvg-js-linux-x64-musl@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ=="],
|
||||||
|
|
||||||
|
"@resvg/resvg-js-win32-arm64-msvc": ["@resvg/resvg-js-win32-arm64-msvc@2.6.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ=="],
|
||||||
|
|
||||||
|
"@resvg/resvg-js-win32-ia32-msvc": ["@resvg/resvg-js-win32-ia32-msvc@2.6.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w=="],
|
||||||
|
|
||||||
|
"@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ=="],
|
||||||
|
|
||||||
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
|
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A=="],
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A=="],
|
||||||
@@ -214,6 +244,8 @@
|
|||||||
|
|
||||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||||
|
|
||||||
|
"@shuding/opentype.js": ["@shuding/opentype.js@1.4.0-beta.0", "", { "dependencies": { "fflate": "^0.7.3", "string.prototype.codepointat": "^0.2.1" }, "bin": { "ot": "bin/ot" } }, "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA=="],
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
|
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
|
||||||
@@ -284,12 +316,16 @@
|
|||||||
|
|
||||||
"base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="],
|
"base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="],
|
||||||
|
|
||||||
|
"base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
|
||||||
|
|
||||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||||
|
|
||||||
"boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="],
|
"boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="],
|
||||||
|
|
||||||
"camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="],
|
"camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="],
|
||||||
|
|
||||||
|
"camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="],
|
||||||
|
|
||||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||||
|
|
||||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||||
@@ -308,6 +344,8 @@
|
|||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||||
|
|
||||||
"commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
"commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||||
@@ -320,8 +358,18 @@
|
|||||||
|
|
||||||
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
|
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
|
||||||
|
|
||||||
|
"css-background-parser": ["css-background-parser@0.1.0", "", {}, "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA=="],
|
||||||
|
|
||||||
|
"css-box-shadow": ["css-box-shadow@1.0.0-3", "", {}, "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg=="],
|
||||||
|
|
||||||
|
"css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="],
|
||||||
|
|
||||||
|
"css-gradient-parser": ["css-gradient-parser@0.0.17", "", {}, "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg=="],
|
||||||
|
|
||||||
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||||
|
|
||||||
|
"css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="],
|
||||||
|
|
||||||
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
|
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
|
||||||
|
|
||||||
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
|
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
|
||||||
@@ -364,6 +412,8 @@
|
|||||||
|
|
||||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||||
|
|
||||||
|
"emoji-regex-xs": ["emoji-regex-xs@2.0.1", "", {}, "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g=="],
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
||||||
|
|
||||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||||
@@ -372,6 +422,8 @@
|
|||||||
|
|
||||||
"esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
|
"esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
|
||||||
|
|
||||||
|
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||||
|
|
||||||
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||||
|
|
||||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||||
@@ -382,6 +434,8 @@
|
|||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="],
|
||||||
|
|
||||||
"flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="],
|
"flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="],
|
||||||
|
|
||||||
"fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
|
"fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
|
||||||
@@ -418,6 +472,8 @@
|
|||||||
|
|
||||||
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
||||||
|
|
||||||
|
"hex-rgb": ["hex-rgb@4.3.0", "", {}, "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw=="],
|
||||||
|
|
||||||
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
|
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
|
||||||
|
|
||||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||||
@@ -468,6 +524,8 @@
|
|||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||||
|
|
||||||
|
"linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="],
|
||||||
|
|
||||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||||
|
|
||||||
"lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
|
"lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
|
||||||
@@ -596,6 +654,10 @@
|
|||||||
|
|
||||||
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
||||||
|
|
||||||
|
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
||||||
|
|
||||||
|
"parse-css-color": ["parse-css-color@0.2.1", "", { "dependencies": { "color-name": "^1.1.4", "hex-rgb": "^4.1.0" } }, "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg=="],
|
||||||
|
|
||||||
"parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="],
|
"parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="],
|
||||||
|
|
||||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||||
@@ -608,6 +670,8 @@
|
|||||||
|
|
||||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||||
|
|
||||||
|
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||||
|
|
||||||
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
|
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
|
||||||
|
|
||||||
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
|
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
|
||||||
@@ -652,6 +716,8 @@
|
|||||||
|
|
||||||
"rollup": ["rollup@4.60.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.0", "@rollup/rollup-android-arm64": "4.60.0", "@rollup/rollup-darwin-arm64": "4.60.0", "@rollup/rollup-darwin-x64": "4.60.0", "@rollup/rollup-freebsd-arm64": "4.60.0", "@rollup/rollup-freebsd-x64": "4.60.0", "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", "@rollup/rollup-linux-arm-musleabihf": "4.60.0", "@rollup/rollup-linux-arm64-gnu": "4.60.0", "@rollup/rollup-linux-arm64-musl": "4.60.0", "@rollup/rollup-linux-loong64-gnu": "4.60.0", "@rollup/rollup-linux-loong64-musl": "4.60.0", "@rollup/rollup-linux-ppc64-gnu": "4.60.0", "@rollup/rollup-linux-ppc64-musl": "4.60.0", "@rollup/rollup-linux-riscv64-gnu": "4.60.0", "@rollup/rollup-linux-riscv64-musl": "4.60.0", "@rollup/rollup-linux-s390x-gnu": "4.60.0", "@rollup/rollup-linux-x64-gnu": "4.60.0", "@rollup/rollup-linux-x64-musl": "4.60.0", "@rollup/rollup-openbsd-x64": "4.60.0", "@rollup/rollup-openharmony-arm64": "4.60.0", "@rollup/rollup-win32-arm64-msvc": "4.60.0", "@rollup/rollup-win32-ia32-msvc": "4.60.0", "@rollup/rollup-win32-x64-gnu": "4.60.0", "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ=="],
|
"rollup": ["rollup@4.60.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.0", "@rollup/rollup-android-arm64": "4.60.0", "@rollup/rollup-darwin-arm64": "4.60.0", "@rollup/rollup-darwin-x64": "4.60.0", "@rollup/rollup-freebsd-arm64": "4.60.0", "@rollup/rollup-freebsd-x64": "4.60.0", "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", "@rollup/rollup-linux-arm-musleabihf": "4.60.0", "@rollup/rollup-linux-arm64-gnu": "4.60.0", "@rollup/rollup-linux-arm64-musl": "4.60.0", "@rollup/rollup-linux-loong64-gnu": "4.60.0", "@rollup/rollup-linux-loong64-musl": "4.60.0", "@rollup/rollup-linux-ppc64-gnu": "4.60.0", "@rollup/rollup-linux-ppc64-musl": "4.60.0", "@rollup/rollup-linux-riscv64-gnu": "4.60.0", "@rollup/rollup-linux-riscv64-musl": "4.60.0", "@rollup/rollup-linux-s390x-gnu": "4.60.0", "@rollup/rollup-linux-x64-gnu": "4.60.0", "@rollup/rollup-linux-x64-musl": "4.60.0", "@rollup/rollup-openbsd-x64": "4.60.0", "@rollup/rollup-openharmony-arm64": "4.60.0", "@rollup/rollup-win32-arm64-msvc": "4.60.0", "@rollup/rollup-win32-ia32-msvc": "4.60.0", "@rollup/rollup-win32-x64-gnu": "4.60.0", "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ=="],
|
||||||
|
|
||||||
|
"satori": ["satori@0.26.0", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-tkMFrfIs3l2mQ2JEcyW0ADTy3zGggFRFzi6Ef8YozQSFsFKEqaSO1Y8F9wJg4//PJGQauMalHGTUEkPrFwhVPA=="],
|
||||||
|
|
||||||
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
|
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
|
||||||
|
|
||||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
@@ -670,6 +736,8 @@
|
|||||||
|
|
||||||
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||||
|
|
||||||
|
"string.prototype.codepointat": ["string.prototype.codepointat@0.2.1", "", {}, "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="],
|
||||||
|
|
||||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||||
|
|
||||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||||
@@ -704,6 +772,8 @@
|
|||||||
|
|
||||||
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
|
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
|
||||||
|
|
||||||
|
"unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="],
|
||||||
|
|
||||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||||
|
|
||||||
"unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
|
"unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
|
||||||
@@ -758,6 +828,8 @@
|
|||||||
|
|
||||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||||
|
|
||||||
|
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||||
|
|
||||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|
||||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||||
|
|||||||
@@ -5,12 +5,17 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"generate:og": "bun run scripts/generate-og.ts",
|
||||||
|
"build": "bun run generate:og && astro build",
|
||||||
"preview": "astro preview"
|
"preview": "astro preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^5.7.10",
|
"astro": "^5.7.10",
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
"tailwindcss": "^4.1.4"
|
"tailwindcss": "^4.1.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
|
"satori": "^0.26.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
@@ -0,0 +1,313 @@
|
|||||||
|
import satori from 'satori';
|
||||||
|
import { Resvg } from '@resvg/resvg-js';
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const scriptDir = import.meta.dir ?? fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
const websiteDir = join(scriptDir, '..');
|
||||||
|
|
||||||
|
const fontUrls = {
|
||||||
|
instrumentSerif:
|
||||||
|
'https://fonts.gstatic.com/s/instrumentserif/v5/jizBRFtNs2ka5fXjeivQ4LroWlx-2zI.ttf',
|
||||||
|
outfit400:
|
||||||
|
'https://fonts.gstatic.com/s/outfit/v15/QGYyz_MVcBeNP4NjuGObqx1XmO1I4TC1C4E.ttf',
|
||||||
|
outfit300:
|
||||||
|
'https://fonts.gstatic.com/s/outfit/v15/QGYyz_MVcBeNP4NjuGObqx1XmO1I4W61C4E.ttf',
|
||||||
|
};
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
deep: '#08080a',
|
||||||
|
border: '#2a2a34',
|
||||||
|
warm50: '#ede9e2',
|
||||||
|
warm300: '#a09a90',
|
||||||
|
brand: '#245CF9',
|
||||||
|
brandBright: '#248CFD',
|
||||||
|
accent: '#8A29E6',
|
||||||
|
};
|
||||||
|
|
||||||
|
type SatoriNode = string | SatoriElement;
|
||||||
|
interface SatoriElement {
|
||||||
|
type: string;
|
||||||
|
props: Record<string, unknown> & { children?: SatoriNode | SatoriNode[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDataUri(filePath: string, mime: string): string {
|
||||||
|
return `data:${mime};base64,${readFileSync(filePath).toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function orb(
|
||||||
|
top: number | undefined,
|
||||||
|
bottom: number | undefined,
|
||||||
|
left: number | undefined,
|
||||||
|
right: number | undefined,
|
||||||
|
size: number,
|
||||||
|
color: string,
|
||||||
|
opacity: number,
|
||||||
|
spread = 0.7,
|
||||||
|
): SatoriElement {
|
||||||
|
return {
|
||||||
|
type: 'div',
|
||||||
|
props: {
|
||||||
|
style: {
|
||||||
|
position: 'absolute',
|
||||||
|
...(top !== undefined && { top }),
|
||||||
|
...(bottom !== undefined && { bottom }),
|
||||||
|
...(left !== undefined && { left }),
|
||||||
|
...(right !== undefined && { right }),
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: `radial-gradient(circle, ${color.replace(')', `,${opacity})`).replace('rgb', 'rgba')} 0%, ${color.replace(')', ',0.02)').replace('rgb', 'rgba')} ${spread * 100}%, transparent 100%)`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dot(top: number, left: number, size: number, color: string): SatoriElement {
|
||||||
|
return {
|
||||||
|
type: 'div',
|
||||||
|
props: {
|
||||||
|
style: {
|
||||||
|
position: 'absolute',
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: color,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
console.log('Generating OG image…');
|
||||||
|
|
||||||
|
const [instrumentSerif, outfit400, outfit300] = await Promise.all(
|
||||||
|
Object.values(fontUrls).map((url) => fetch(url).then((r) => r.arrayBuffer())),
|
||||||
|
);
|
||||||
|
|
||||||
|
const logoUri = toDataUri(join(websiteDir, 'public', 'images', 'logo.png'), 'image/png');
|
||||||
|
|
||||||
|
const element: SatoriElement = {
|
||||||
|
type: 'div',
|
||||||
|
props: {
|
||||||
|
style: {
|
||||||
|
display: 'flex',
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
backgroundColor: colors.deep,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
// ── Background ambient glows (subtle, behind everything) ──
|
||||||
|
orb(-120, undefined, 60, undefined, 500, 'rgb(36,92,249)', 0.07, 0.65),
|
||||||
|
orb(undefined, -80, undefined, 200, 450, 'rgb(138,41,230)', 0.05, 0.6),
|
||||||
|
|
||||||
|
// ── Top gradient accent bar ──
|
||||||
|
{
|
||||||
|
type: 'div',
|
||||||
|
props: {
|
||||||
|
style: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 4,
|
||||||
|
background: `linear-gradient(90deg, ${colors.brand}, ${colors.accent})`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Main content ──
|
||||||
|
{
|
||||||
|
type: 'div',
|
||||||
|
props: {
|
||||||
|
style: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row' as const,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
padding: '0 80px',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
// Left column — text
|
||||||
|
{
|
||||||
|
type: 'div',
|
||||||
|
props: {
|
||||||
|
style: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as const,
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
// Logo
|
||||||
|
{
|
||||||
|
type: 'img',
|
||||||
|
props: {
|
||||||
|
src: logoUri,
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
style: { borderRadius: 14 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Product name
|
||||||
|
{
|
||||||
|
type: 'div',
|
||||||
|
props: {
|
||||||
|
style: {
|
||||||
|
fontFamily: 'Instrument Serif',
|
||||||
|
fontSize: 76,
|
||||||
|
color: colors.warm50,
|
||||||
|
marginTop: 24,
|
||||||
|
lineHeight: 1,
|
||||||
|
letterSpacing: -1,
|
||||||
|
},
|
||||||
|
children: 'Aryx',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Tagline
|
||||||
|
{
|
||||||
|
type: 'div',
|
||||||
|
props: {
|
||||||
|
style: {
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap' as const,
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: 300,
|
||||||
|
color: colors.warm300,
|
||||||
|
marginTop: 18,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
'Your control room for ',
|
||||||
|
{
|
||||||
|
type: 'span',
|
||||||
|
props: {
|
||||||
|
style: { color: colors.brand },
|
||||||
|
children: 'Copilot-powered',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
' work',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Badge pill
|
||||||
|
{
|
||||||
|
type: 'div',
|
||||||
|
props: {
|
||||||
|
style: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 30,
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.brand,
|
||||||
|
border: '1px solid rgba(36,92,249,0.2)',
|
||||||
|
background: 'rgba(36,92,249,0.06)',
|
||||||
|
borderRadius: 100,
|
||||||
|
padding: '7px 16px',
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'div',
|
||||||
|
props: {
|
||||||
|
style: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: colors.brand,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Desktop AI Workspace',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Decorative orbs (right side) ──
|
||||||
|
// Large blue orb
|
||||||
|
orb(40, undefined, undefined, 40, 380, 'rgb(36,92,249)', 0.22, 0.55),
|
||||||
|
// Large purple orb (overlapping)
|
||||||
|
orb(undefined, 20, undefined, -40, 340, 'rgb(138,41,230)', 0.18, 0.5),
|
||||||
|
// Bright blue accent orb
|
||||||
|
orb(180, undefined, undefined, 220, 140, 'rgb(36,140,253)', 0.3, 0.5),
|
||||||
|
|
||||||
|
// ── Thin ring ──
|
||||||
|
{
|
||||||
|
type: 'div',
|
||||||
|
props: {
|
||||||
|
style: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 100,
|
||||||
|
right: 80,
|
||||||
|
width: 240,
|
||||||
|
height: 240,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: '1px solid rgba(36,92,249,0.12)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Smaller ring
|
||||||
|
{
|
||||||
|
type: 'div',
|
||||||
|
props: {
|
||||||
|
style: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 80,
|
||||||
|
right: 160,
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: '1px solid rgba(138,41,230,0.10)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Accent dots ──
|
||||||
|
dot(140, 820, 8, 'rgba(36,92,249,0.4)'),
|
||||||
|
dot(340, 950, 6, 'rgba(138,41,230,0.35)'),
|
||||||
|
dot(480, 780, 5, 'rgba(36,140,253,0.3)'),
|
||||||
|
dot(200, 1050, 4, 'rgba(36,92,249,0.25)'),
|
||||||
|
dot(100, 1100, 7, 'rgba(138,41,230,0.2)'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const svg = await satori(element, {
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
fonts: [
|
||||||
|
{ name: 'Instrument Serif', data: instrumentSerif, weight: 400, style: 'normal' as const },
|
||||||
|
{ name: 'Outfit', data: outfit400, weight: 400, style: 'normal' as const },
|
||||||
|
{ name: 'Outfit', data: outfit300, weight: 300, style: 'normal' as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } });
|
||||||
|
const png = resvg.render().asPng();
|
||||||
|
|
||||||
|
const outputPath = join(websiteDir, 'public', 'images', 'og.png');
|
||||||
|
writeFileSync(outputPath, png);
|
||||||
|
|
||||||
|
const sizeKb = (png.length / 1024).toFixed(1);
|
||||||
|
console.log(`✓ OG image generated → public/images/og.png (${sizeKb} KB)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
generate().catch((err) => {
|
||||||
|
console.error('Failed to generate OG image:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -23,11 +23,15 @@ const {
|
|||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:title" content={title} />
|
<meta property="og:title" content={title} />
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
<meta property="og:image" content="/images/logo.png" />
|
<meta property="og:image" content={`${Astro.site}images/og.png`} />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta property="og:url" content={Astro.url.href} />
|
||||||
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:title" content={title} />
|
<meta name="twitter:title" content={title} />
|
||||||
<meta name="twitter:description" content={description} />
|
<meta name="twitter:description" content={description} />
|
||||||
|
<meta name="twitter:image" content={`${Astro.site}images/og.png`} />
|
||||||
|
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
@@ -159,6 +163,10 @@ const {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>by Dávid Kaya.</span>
|
<span>by Dávid Kaya.</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-4 text-center text-[11px] leading-relaxed text-warm-700">
|
||||||
|
GitHub and GitHub Copilot are trademarks of Microsoft Corporation.<br />
|
||||||
|
Aryx is an independent project, not affiliated with or endorsed by Microsoft or GitHub.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -332,6 +332,16 @@ import Base from '../layouts/Base.astro';
|
|||||||
<p class="mt-0.5 text-xs leading-relaxed text-warm-400">Copy, pin, edit-and-resend, and regenerate individual messages.</p>
|
<p class="mt-0.5 text-xs leading-relaxed text-warm-400">Copy, pin, edit-and-resend, and regenerate individual messages.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Bookmarks -->
|
||||||
|
<div class="group flex gap-3.5 rounded-xl px-3 py-2.5 transition hover:bg-card/60" data-reveal data-reveal-d="5">
|
||||||
|
<div class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand/[0.07] text-brand transition group-hover:bg-brand/[0.12]">
|
||||||
|
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"></path></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-warm-50">Bookmarks</h4>
|
||||||
|
<p class="mt-0.5 text-xs leading-relaxed text-warm-400">Browse all pinned messages across sessions in one panel via <kbd class="text-warm-200">Ctrl+Shift+B</kbd>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- System Tray -->
|
<!-- System Tray -->
|
||||||
<div class="group flex gap-3.5 rounded-xl px-3 py-2.5 transition hover:bg-card/60" data-reveal data-reveal-d="6">
|
<div class="group flex gap-3.5 rounded-xl px-3 py-2.5 transition hover:bg-card/60" data-reveal data-reveal-d="6">
|
||||||
<div class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand/[0.07] text-brand transition group-hover:bg-brand/[0.12]">
|
<div class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand/[0.07] text-brand transition group-hover:bg-brand/[0.12]">
|
||||||
|
|||||||
Reference in New Issue
Block a user