mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 21:18:40 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5946dc75ba | ||
|
|
59d3c81f9f | ||
|
|
deb5c96d58 | ||
|
|
56de8b7bd6 | ||
|
|
38dd358755 | ||
|
|
770a0f3529 | ||
|
|
e37d69bd63 | ||
|
|
c01a427d8a | ||
|
|
01b3949557 | ||
|
|
c01110979c | ||
|
|
a034b333b3 |
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
- **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
|
||||
- **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
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</p>
|
||||
|
||||
<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>
|
||||
|
||||
---
|
||||
@@ -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 search | Full-text search across all session messages, not just titles |
|
||||
| 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 |
|
||||
| Desktop notifications | Native OS alerts when runs complete, fail, or need approval |
|
||||
| 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.
|
||||
|
||||
## Trademarks
|
||||
|
||||
GitHub and GitHub Copilot are trademarks of Microsoft Corporation. Aryx is an independent project, not affiliated with or endorsed by Microsoft or GitHub.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aryx",
|
||||
"version": "0.0.13",
|
||||
"version": "0.0.17",
|
||||
"description": "Electron orchestrator for Copilot-powered agent workflows across multiple projects.",
|
||||
"private": true,
|
||||
"main": "dist-electron/main/index.js",
|
||||
|
||||
@@ -85,6 +85,7 @@ public sealed class ChatMessageDto
|
||||
public string AuthorName { get; init; } = string.Empty;
|
||||
public string Content { get; init; } = string.Empty;
|
||||
public string CreatedAt { get; init; } = string.Empty;
|
||||
public string? MessageKind { get; set; }
|
||||
public IReadOnlyList<ChatMessageAttachmentDto> Attachments { get; init; } = [];
|
||||
}
|
||||
|
||||
@@ -331,6 +332,13 @@ public sealed class TurnCompleteEventDto : SidecarEventDto
|
||||
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 string SessionId { get; init; } = string.Empty;
|
||||
@@ -376,6 +384,23 @@ public sealed class SkillInvokedEventDto : SidecarEventDto
|
||||
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 string SessionId { get; init; } = string.Empty;
|
||||
|
||||
@@ -9,11 +9,13 @@ internal sealed class CopilotTurnExecutionState
|
||||
{
|
||||
private readonly RunTurnCommandDto _command;
|
||||
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<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
|
||||
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
|
||||
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
|
||||
private int _fallbackMessageIndex;
|
||||
private string? _lastObservedMessageId;
|
||||
|
||||
public CopilotTurnExecutionState(RunTurnCommandDto command)
|
||||
{
|
||||
@@ -79,15 +81,34 @@ internal sealed class CopilotTurnExecutionState
|
||||
case AssistantMessageEvent assistantMessage when !string.IsNullOrWhiteSpace(assistantMessage.Data?.MessageId):
|
||||
RecordObservedAgentForMessage(agent, assistantMessage.Data!.MessageId);
|
||||
QueueThinkingIfNeeded(agent);
|
||||
if (assistantMessage.Data?.ToolRequests is { Length: > 0 })
|
||||
{
|
||||
QueueMessageReclassifiedIfNeeded(assistantMessage.Data.MessageId);
|
||||
}
|
||||
break;
|
||||
case ToolExecutionStartEvent toolExecutionStart
|
||||
when !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolCallId)
|
||||
&& !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolName):
|
||||
ToolNamesByCallId[toolExecutionStart.Data.ToolCallId.Trim()] = toolExecutionStart.Data.ToolName.Trim();
|
||||
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
|
||||
break;
|
||||
case AssistantReasoningDeltaEvent:
|
||||
case AssistantIntentEvent intentEvent:
|
||||
ActiveAgent = 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;
|
||||
case SubagentStartedEvent started:
|
||||
ActiveAgent = agent;
|
||||
@@ -218,6 +239,23 @@ internal sealed class CopilotTurnExecutionState
|
||||
{
|
||||
ActiveAgent = 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)
|
||||
@@ -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(
|
||||
IReadOnlyList<ChatMessage> allMessages,
|
||||
IReadOnlyList<ChatMessage> inputMessages)
|
||||
@@ -263,6 +313,14 @@ internal sealed class CopilotTurnExecutionState
|
||||
ActiveAgent);
|
||||
}
|
||||
|
||||
foreach (ChatMessageDto message in CompletedMessages)
|
||||
{
|
||||
if (_reclassifiedMessageIds.Contains(message.Id))
|
||||
{
|
||||
message.MessageKind = "thinking";
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
AgentIdentity agent,
|
||||
SkillInvokedData? data)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
@@ -83,6 +84,132 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
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]
|
||||
public async Task EmitThinkingIfNeeded_DoesNotDuplicateQueuedThinkingActivity()
|
||||
{
|
||||
@@ -119,6 +246,70 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
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]
|
||||
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()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ExitPlanModeRequestedEvent,
|
||||
MessageMode,
|
||||
McpOauthRequiredEvent,
|
||||
MessageReclassifiedEvent,
|
||||
RunTurnCustomAgentConfig,
|
||||
RunTurnToolingConfig,
|
||||
SidecarCapabilities,
|
||||
@@ -1364,6 +1365,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
async (event) => {
|
||||
await this.handleExitPlanModeRequested(workspace, session.id, event);
|
||||
},
|
||||
async (event) => {
|
||||
await this.applyMessageReclassified(workspace, session.id, event);
|
||||
},
|
||||
async (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(
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
@@ -1795,6 +1824,15 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
const pattern = this.requirePattern(workspace, session.patternId);
|
||||
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) {
|
||||
const occurredAt = nowIso();
|
||||
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.pending = false;
|
||||
} 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) =>
|
||||
upsertRunMessageEvent(run, {
|
||||
messageId: message.id,
|
||||
@@ -1822,6 +1873,15 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
authorName: message.authorName,
|
||||
content: message.content,
|
||||
});
|
||||
if (reclassifiedAsThinking) {
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'message-reclassified',
|
||||
occurredAt,
|
||||
messageId: message.id,
|
||||
messageKind: 'thinking',
|
||||
});
|
||||
}
|
||||
if (nextRun) {
|
||||
this.emitRunUpdated(sessionId, occurredAt, nextRun);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ApprovalRequestedEvent,
|
||||
ExitPlanModeRequestedEvent,
|
||||
McpOauthRequiredEvent,
|
||||
MessageReclassifiedEvent,
|
||||
TurnDeltaEvent,
|
||||
UserInputRequestedEvent,
|
||||
SubagentEvent,
|
||||
@@ -12,6 +13,8 @@ import type {
|
||||
SessionCompactionEvent,
|
||||
PendingMessagesModifiedEvent,
|
||||
AssistantUsageEvent,
|
||||
AssistantIntentEvent,
|
||||
ReasoningDeltaEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
|
||||
@@ -22,7 +25,9 @@ export type TurnScopedEvent =
|
||||
| SessionUsageEvent
|
||||
| SessionCompactionEvent
|
||||
| PendingMessagesModifiedEvent
|
||||
| AssistantUsageEvent;
|
||||
| AssistantUsageEvent
|
||||
| AssistantIntentEvent
|
||||
| ReasoningDeltaEvent;
|
||||
|
||||
export interface RunTurnPendingCommand {
|
||||
kind: 'run-turn';
|
||||
@@ -34,6 +39,7 @@ export interface RunTurnPendingCommand {
|
||||
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>;
|
||||
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>;
|
||||
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>;
|
||||
onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise<void>;
|
||||
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>;
|
||||
errored: boolean;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SidecarCapabilities,
|
||||
SidecarEvent,
|
||||
TurnDeltaEvent,
|
||||
MessageReclassifiedEvent,
|
||||
UserInputRequestedEvent,
|
||||
McpOauthRequiredEvent,
|
||||
ExitPlanModeRequestedEvent,
|
||||
@@ -134,9 +135,10 @@ export class SidecarClient {
|
||||
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>,
|
||||
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>,
|
||||
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
|
||||
onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise<void>,
|
||||
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>,
|
||||
): 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> {
|
||||
@@ -286,6 +288,7 @@ export class SidecarClient {
|
||||
onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>,
|
||||
onMcpOAuthRequired?: (event: McpOauthRequiredEvent) => void | Promise<void>,
|
||||
onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
|
||||
onMessageReclassified?: (event: MessageReclassifiedEvent) => void | Promise<void>,
|
||||
onTurnScopedEvent?: (event: TurnScopedEvent) => void | Promise<void>,
|
||||
): Promise<TResult> {
|
||||
const state = await this.ensureProcess();
|
||||
@@ -303,6 +306,7 @@ export class SidecarClient {
|
||||
onUserInput: onUserInput ?? (() => undefined),
|
||||
onMcpOAuthRequired: onMcpOAuthRequired ?? (() => undefined),
|
||||
onExitPlanMode: onExitPlanMode ?? (() => undefined),
|
||||
onMessageReclassified: onMessageReclassified ?? (() => undefined),
|
||||
onTurnScopedEvent: onTurnScopedEvent ?? (() => undefined),
|
||||
errored: false,
|
||||
});
|
||||
@@ -439,6 +443,11 @@ export class SidecarClient {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onExitPlanMode(event));
|
||||
}
|
||||
return;
|
||||
case 'message-reclassified':
|
||||
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onMessageReclassified(event));
|
||||
}
|
||||
return;
|
||||
case 'subagent-event':
|
||||
case 'skill-invoked':
|
||||
case 'hook-lifecycle':
|
||||
@@ -446,6 +455,8 @@ export class SidecarClient {
|
||||
case 'session-compaction':
|
||||
case 'pending-messages-modified':
|
||||
case 'assistant-usage':
|
||||
case 'assistant-intent':
|
||||
case 'reasoning-delta':
|
||||
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onTurnScopedEvent(event));
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ export function createMainWindow(): BrowserWindowType {
|
||||
}),
|
||||
backgroundColor: '#09090b',
|
||||
titleBarStyle: 'hidden',
|
||||
...(process.platform === 'darwin' && {
|
||||
trafficLightPosition: { x: 16, y: 22 },
|
||||
}),
|
||||
titleBarOverlay: {
|
||||
color: '#09090b',
|
||||
symbolColor: '#a1a1aa',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingMo
|
||||
import { KeyboardShortcutsPanel } from '@renderer/components/KeyboardShortcutsPanel';
|
||||
import { NewSessionModal } from '@renderer/components/NewSessionModal';
|
||||
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
|
||||
import { BookmarksPanel } from '@renderer/components/BookmarksPanel';
|
||||
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
|
||||
import { SettingsPanel } from '@renderer/components/SettingsPanel';
|
||||
import { Sidebar } from '@renderer/components/Sidebar';
|
||||
@@ -118,6 +119,7 @@ export default function App() {
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [showBookmarks, setShowBookmarks] = useState(false);
|
||||
|
||||
// Terminal state
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
@@ -324,6 +326,13 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Ctrl/Cmd+Shift+B — Bookmarks ──
|
||||
if (mod && e.shiftKey && e.key === 'B') {
|
||||
e.preventDefault();
|
||||
setShowBookmarks((prev) => !prev);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Ctrl/Cmd+, — Open settings ──
|
||||
if (mod && e.key === ',') {
|
||||
e.preventDefault();
|
||||
@@ -824,6 +833,7 @@ export default function App() {
|
||||
onOpenAppDataFolder={() => void api.openAppDataFolder()}
|
||||
onShowShortcuts={() => setShowShortcuts(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 { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill';
|
||||
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
|
||||
import { ThinkingProcess } from '@renderer/components/chat/ThinkingProcess';
|
||||
import { SubagentActivityList } from '@renderer/components/chat/SubagentActivityCard';
|
||||
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
@@ -115,12 +116,28 @@ export function ChatPane({
|
||||
const composerRef = useRef<MarkdownComposerHandle>(null);
|
||||
|
||||
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(() => {
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
if (session.messages[i].role === 'assistant') return i;
|
||||
for (let i = visibleMessages.length - 1; i >= 0; i--) {
|
||||
if (visibleMessages[i].role === 'assistant') return i;
|
||||
}
|
||||
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 queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending');
|
||||
const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length;
|
||||
@@ -358,7 +375,7 @@ export function ChatPane({
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{session.messages.map((message, index) => {
|
||||
{visibleMessages.map((message, index) => {
|
||||
const isUser = message.role === 'user';
|
||||
const isEditing = editingMessageId === message.id;
|
||||
const isLastAssistant = index === lastAssistantIndex;
|
||||
@@ -376,101 +393,122 @@ export function ChatPane({
|
||||
const phaseLabel =
|
||||
phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined;
|
||||
const showActions = !isSessionBusy && !message.pending;
|
||||
const showThinkingBefore = isLastAssistant && thinkingMessages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="message-enter group py-3" data-message-id={message.id} key={message.id}>
|
||||
<div className="flex gap-3">
|
||||
<div
|
||||
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 ? <User className="size-3.5" /> : <Bot className="size-3.5" />}
|
||||
<div key={message.id}>
|
||||
{showThinkingBefore && (
|
||||
<div className="py-2">
|
||||
<ThinkingProcess
|
||||
messages={thinkingMessages}
|
||||
isActive={isSessionBusy}
|
||||
turnStartedAt={turnStartedAt}
|
||||
/>
|
||||
</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)]">
|
||||
<span>{message.authorName}</span>
|
||||
{message.isPinned && (
|
||||
<Bookmark className="size-3 fill-[var(--color-accent-sky)] text-[var(--color-accent-sky)]" />
|
||||
)}
|
||||
{!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}`}
|
||||
>
|
||||
{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 className="message-enter group py-3" data-message-id={message.id}>
|
||||
<div className="flex gap-3">
|
||||
<div
|
||||
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 ? <User className="size-3.5" /> : <Bot className="size-3.5" />}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<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)]">
|
||||
<span>{message.authorName}</span>
|
||||
{message.isPinned && (
|
||||
<Bookmark className="size-3 fill-[var(--color-accent-sky)] text-[var(--color-accent-sky)]" />
|
||||
)}
|
||||
{!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} />
|
||||
{!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}`}
|
||||
>
|
||||
{phaseLabel}
|
||||
</span>
|
||||
)}
|
||||
{message.pending && message.content && (
|
||||
<span className="mt-1 inline-block h-4 w-[2px] animate-pulse rounded-sm bg-[var(--color-accent)]" />
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
{thinkingMessages.length > 0 && lastAssistantIndex < 0 && (
|
||||
<div className="py-2">
|
||||
<ThinkingProcess
|
||||
messages={thinkingMessages}
|
||||
isActive={isSessionBusy}
|
||||
turnStartedAt={turnStartedAt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{activeSubagents && activeSubagents.length > 0 && (
|
||||
<div className="px-6 py-1">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Archive,
|
||||
Bookmark,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
@@ -51,6 +52,7 @@ export interface CommandPaletteProps {
|
||||
onOpenAppDataFolder: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onShowSearch: () => void;
|
||||
onShowBookmarks: () => void;
|
||||
}
|
||||
|
||||
/** Score how well `query` matches `text` (and optional `keywords`). 0 = no match. */
|
||||
@@ -92,6 +94,7 @@ export function CommandPalette({
|
||||
onOpenAppDataFolder,
|
||||
onShowShortcuts,
|
||||
onShowSearch,
|
||||
onShowBookmarks,
|
||||
}: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
@@ -234,6 +237,16 @@ export function CommandPalette({
|
||||
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({
|
||||
id: 'settings',
|
||||
label: 'Open Settings',
|
||||
@@ -316,7 +329,7 @@ export function CommandPalette({
|
||||
onSelectSession, onSelectProject, onNewSession, onCreateScratchpad,
|
||||
onOpenSettings, onOpenProjectSettings, onToggleTerminal, onSetTheme,
|
||||
onDuplicateSession, onPinSession, onArchiveSession, onAddProject,
|
||||
onOpenAppDataFolder, onShowShortcuts, onShowSearch,
|
||||
onOpenAppDataFolder, onShowShortcuts, onShowSearch, onShowBookmarks,
|
||||
]);
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import appIconUrl from '../../../assets/icons/icon.png';
|
||||
import { isMac } from '@renderer/lib/platform';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Archive,
|
||||
@@ -581,7 +582,7 @@ export function Sidebar({
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 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">
|
||||
<img alt="aryx" className="size-8 rounded-xl" src={appIconUrl} />
|
||||
<div>
|
||||
|
||||
@@ -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. */
|
||||
export const MOD = isMac ? '⌘' : 'Ctrl';
|
||||
@@ -18,6 +18,7 @@ export const shortcuts: ShortcutDefinition[] = [
|
||||
// ── 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: 'bookmarks', label: 'View bookmarks', keys: `${MOD}+Shift+B`, category: 'Navigation' },
|
||||
{ id: 'settings', label: 'Open settings', keys: `${MOD}+,`, category: 'Navigation' },
|
||||
{ id: 'toggle-terminal', label: 'Toggle terminal', keys: 'Ctrl+`', category: 'Navigation' },
|
||||
{ id: 'shortcut-help', label: 'Keyboard shortcuts', keys: `${MOD}+/`, category: 'Navigation' },
|
||||
|
||||
@@ -11,6 +11,10 @@ export function getAssistantMessagePhase(
|
||||
return 'default';
|
||||
}
|
||||
|
||||
if (message.messageKind === 'thinking') {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
if (message.pending) {
|
||||
return 'thinking';
|
||||
}
|
||||
@@ -26,7 +30,7 @@ export function getAssistantMessagePhase(
|
||||
function findLastCompletedAssistantIndex(messages: ChatMessageRecord[]): number {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message.role === 'assistant' && !message.pending) {
|
||||
if (message.role === 'assistant' && !message.pending && message.messageKind !== 'thinking') {
|
||||
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);
|
||||
case 'message-complete':
|
||||
return applyMessageCompleteEvent(session, event);
|
||||
case 'message-reclassified':
|
||||
return applyMessageReclassifiedEvent(session, event);
|
||||
case 'run-updated':
|
||||
return applyRunUpdatedEvent(session, event);
|
||||
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 {
|
||||
if (!event.run) {
|
||||
return session;
|
||||
|
||||
+15
-1
@@ -626,6 +626,19 @@ body {
|
||||
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 ──────────────────────────────────── */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -637,7 +650,8 @@ body {
|
||||
.message-enter,
|
||||
.msg-actions-enter,
|
||||
.session-item-enter,
|
||||
.banner-slide-enter {
|
||||
.banner-slide-enter,
|
||||
.thinking-process-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +239,14 @@ export interface TurnCompleteEvent {
|
||||
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 interface ToolCallFileChangePreview {
|
||||
@@ -297,6 +305,25 @@ export interface SkillInvokedEvent {
|
||||
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 {
|
||||
type: 'hook-lifecycle';
|
||||
requestId: string;
|
||||
@@ -526,9 +553,12 @@ export type SidecarEvent =
|
||||
| PatternValidationEvent
|
||||
| TurnDeltaEvent
|
||||
| TurnCompleteEvent
|
||||
| MessageReclassifiedEvent
|
||||
| AgentActivityEvent
|
||||
| SubagentEvent
|
||||
| SkillInvokedEvent
|
||||
| AssistantIntentEvent
|
||||
| ReasoningDeltaEvent
|
||||
| HookLifecycleEvent
|
||||
| SessionUsageEvent
|
||||
| SessionCompactionEvent
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
||||
import type { ChatMessageKind } from '@shared/domain/session';
|
||||
|
||||
import type { QuotaSnapshot, ToolCallFileChangePreview } from '@shared/contracts/sidecar';
|
||||
|
||||
@@ -8,6 +9,7 @@ export type SessionEventKind =
|
||||
| 'status'
|
||||
| 'message-delta'
|
||||
| 'message-complete'
|
||||
| 'message-reclassified'
|
||||
| 'agent-activity'
|
||||
| 'run-updated'
|
||||
| 'error'
|
||||
@@ -27,6 +29,7 @@ export interface SessionEventRecord {
|
||||
occurredAt: string;
|
||||
status?: 'idle' | 'running' | 'error';
|
||||
messageId?: string;
|
||||
messageKind?: ChatMessageKind;
|
||||
authorName?: string;
|
||||
contentDelta?: string;
|
||||
content?: string;
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||
import type { InteractionMode } from '@shared/contracts/sidecar';
|
||||
|
||||
export type ChatRole = 'system' | 'user' | 'assistant';
|
||||
export type ChatMessageKind = 'response' | 'thinking';
|
||||
export type SessionStatus = 'idle' | 'running' | 'error';
|
||||
export type SessionTitleSource = 'auto' | 'manual';
|
||||
export type SessionBranchOriginAction = 'branch' | 'regenerate' | 'edit-and-resend';
|
||||
@@ -33,6 +34,7 @@ export interface ChatMessageRecord {
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
messageKind?: ChatMessageKind;
|
||||
isPinned?: boolean;
|
||||
pending?: boolean;
|
||||
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[] {
|
||||
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]));
|
||||
|
||||
@@ -19,6 +19,7 @@ describe('run turn pending helpers', () => {
|
||||
onUserInput: () => undefined,
|
||||
onExitPlanMode: () => undefined,
|
||||
onMcpOAuthRequired: () => undefined,
|
||||
onMessageReclassified: () => undefined,
|
||||
onTurnScopedEvent: () => undefined,
|
||||
errored: false,
|
||||
};
|
||||
@@ -45,6 +46,7 @@ describe('run turn pending helpers', () => {
|
||||
onUserInput: () => undefined,
|
||||
onExitPlanMode: () => undefined,
|
||||
onMcpOAuthRequired: () => undefined,
|
||||
onMessageReclassified: () => undefined,
|
||||
onTurnScopedEvent: () => undefined,
|
||||
errored: false,
|
||||
};
|
||||
|
||||
@@ -85,4 +85,59 @@ describe('assistant message phase', () => {
|
||||
|
||||
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),
|
||||
).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 { defineConfig } from 'astro/config';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://aryx.app',
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
"astro": "^5.7.10",
|
||||
"tailwindcss": "^4.1.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"satori": "^0.26.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
@@ -148,6 +152,32 @@
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
@@ -308,6 +344,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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-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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"hex-rgb": ["hex-rgb@4.3.0", "", {}, "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw=="],
|
||||
|
||||
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"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=="],
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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.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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -5,12 +5,17 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"generate:og": "bun run scripts/generate-og.ts",
|
||||
"build": "bun run generate:og && astro build",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.7.10",
|
||||
"@tailwindcss/vite": "^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:title" content={title} />
|
||||
<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:title" content={title} />
|
||||
<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="preconnect" href="https://fonts.googleapis.com" />
|
||||
@@ -159,6 +163,10 @@ const {
|
||||
</svg>
|
||||
<span>by Dávid Kaya.</span>
|
||||
</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>
|
||||
</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>
|
||||
</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 -->
|
||||
<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]">
|
||||
|
||||
Reference in New Issue
Block a user