mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 13:08:36 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc69f8bf04 | ||
|
|
6f7cf60aa9 | ||
|
|
423d45fa1b | ||
|
|
13bcc44f1a | ||
|
|
1ceb3d5669 | ||
|
|
1dd13588a0 | ||
|
|
235ddf7e56 | ||
|
|
d7004ec2a9 | ||
|
|
0aed6240b9 | ||
|
|
11b36827f5 | ||
|
|
b434dd86b4 | ||
|
|
c2a691774a | ||
|
|
78949c5efd | ||
|
|
fb3e80ec47 | ||
|
|
5a71539705 | ||
|
|
906433f408 | ||
|
|
44d0ab07db | ||
|
|
49933f218b | ||
|
|
63acd9e87e | ||
|
|
ebb506a359 | ||
|
|
b25da56f5e | ||
|
|
30ff81009a | ||
|
|
9a261780c6 | ||
|
|
9b7e4dd6e9 | ||
|
|
4bc2c327f7 | ||
|
|
36126f1c74 | ||
|
|
bec50da2b4 | ||
|
|
535adc64be | ||
|
|
772c84fed3 | ||
|
|
9647b5fdb5 |
@@ -0,0 +1,11 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "daily"
|
||||
+12
-3
@@ -55,7 +55,7 @@ flowchart LR
|
||||
| --- | --- | --- | --- |
|
||||
| Renderer | Screens, interaction, local view composition, theme application | Filesystem, process spawning, raw Electron access, Copilot runtime | Typed preload API and pushed events |
|
||||
| Preload | Narrow bridge between browser context and Electron IPC | Business logic, persistence, orchestration | `ipcRenderer` / `ipcMain` |
|
||||
| Main process | Workspace mutation, persistence, git inspection, session lifecycle, native window state, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes |
|
||||
| Main process | Workspace mutation, persistence, git inspection/write operations, run change attribution, commit workflow orchestration, session lifecycle, native window state, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes |
|
||||
| Sidecar | Capability discovery, pattern validation, run execution, streaming deltas and activity | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio |
|
||||
| External systems | Git data, Copilot account/model access, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar |
|
||||
|
||||
@@ -89,7 +89,7 @@ sequenceDiagram
|
||||
R->>P: Invoke typed API
|
||||
P->>M: IPC request
|
||||
M->>M: Append user message
|
||||
M->>M: Create run record and mark session running
|
||||
M->>M: Capture pre-run git snapshot, create run record, and mark session running
|
||||
M->>S: run-turn command
|
||||
S->>C: Execute workflow
|
||||
C-->>S: Partial output / tool activity / handoffs / input requests
|
||||
@@ -97,7 +97,7 @@ sequenceDiagram
|
||||
M-->>R: Push session events and workspace updates
|
||||
C-->>S: Final messages or turn boundary
|
||||
S-->>M: Completion or error
|
||||
M->>M: Finalize run and persist state
|
||||
M->>M: Finalize run, compute post-run git summary, refresh project git state, and persist state
|
||||
M-->>R: Final workspace snapshot
|
||||
```
|
||||
|
||||
@@ -126,6 +126,8 @@ The scratchpad is modeled inside the same workspace system instead of as a separ
|
||||
|
||||
Project-backed entries also persist scanned Copilot customization metadata discovered from repository files such as `.github/copilot-instructions.md`, `AGENTS.md`, `.github/agents/*.agent.md`, and `.github/prompts/*.prompt.md`. The main process owns that scan step and stores the normalized results on the project record so repo instructions and enabled custom agent profiles can participate in later run execution without turning the renderer into a filesystem crawler.
|
||||
|
||||
For git-backed projects, the main process also owns background git refreshes, captures a structured pre-run working-tree snapshot on each run record, and persists a post-run git change summary after project-backed turns complete. It also owns all git write operations exposed by Aryx — selective discard, staging, commit, push/pull/fetch, and branch lifecycle actions — so the renderer never shells out directly or manipulates repository state on its own.
|
||||
|
||||
### Patterns
|
||||
|
||||
Patterns describe how agents collaborate. The architecture supports:
|
||||
@@ -172,6 +174,7 @@ Each user turn becomes a **run**. A run is more than the final assistant output;
|
||||
- which activity happened during the turn
|
||||
- partial streaming output
|
||||
- success or failure
|
||||
- optional git baselines and post-run change summaries for project-backed execution
|
||||
|
||||
That run model is what enables the activity panel and historical timeline instead of forcing the user to infer execution from message text alone.
|
||||
|
||||
@@ -221,6 +224,8 @@ The protocol also carries **turn-scoped lifecycle events** alongside output delt
|
||||
- **Session compaction events**: start and complete, with token-reduction metrics when infinite sessions trigger context trimming
|
||||
- **Session usage events**: current token count and context-window limit from `session.usage_info` for context-bar rendering
|
||||
- **Pending-messages-modified events**: emitted when mid-turn steering changes the pending message queue
|
||||
- **Workflow diagnostic events**: normalized warnings and errors from Agent Framework (`WorkflowWarningEvent`, `WorkflowErrorEvent`, `ExecutorFailedEvent`) with optional executor or subworkflow metadata for richer debugging surfaces
|
||||
- **Workflow checkpoint events**: emitted at Agent Framework superstep boundaries with workflow session ID, checkpoint ID, step number, and checkpoint-store path so the main process can prepare crash-recovery state
|
||||
|
||||
These events flow through a single `onTurnScopedEvent` callback on the `runTurn` command, avoiding per-event-type callback proliferation. The main process maps each event to a `SessionEventRecord` and pushes it to the renderer, where lightweight state maps (activity, usage, turn-event log) consume them without touching the persisted workspace.
|
||||
|
||||
@@ -232,6 +237,8 @@ For project-backed sessions, the sidecar also discovers GitHub Copilot CLI hook
|
||||
|
||||
The `run-turn` command now also carries a project-instruction payload derived from scanned repo customization files. The main process composes that payload from repo-level instruction files and merges enabled discovered custom agent profiles into the primary pattern agent's Copilot configuration before sending the command across the stdio boundary. The sidecar then folds those project instructions into the final SDK system message alongside the agent's own instructions and runtime guidance.
|
||||
|
||||
For handoff workflows, the sidecar now also enables Agent Framework JSON checkpointing backed by a per-turn filesystem store under local app data. Each saved checkpoint is surfaced to the main process, which pairs the durable Agent Framework checkpoint with an in-memory rollback snapshot of `session.messages` and the active run timeline events. If the sidecar child process exits unexpectedly during the same app lifetime, Aryx restores the latest snapshot, clears pending approval/user-input/MCP-auth state for that run, and retries the `run-turn` request once with `resumeFromCheckpoint`. Checkpoint directories are deleted after the turn completes, cancels, or fails. This recovery path is intentionally scoped to same-app sidecar restarts; full app-restart workflow rehydration would require durable rollback snapshots in addition to the Agent Framework checkpoint payloads.
|
||||
|
||||
## Security model
|
||||
|
||||
Security in this system is mostly about **desktop trust boundaries**.
|
||||
@@ -299,6 +306,8 @@ This lets the application treat tooling as reusable workspace capability while s
|
||||
|
||||
Project-backed sessions can carry repository context such as branch and dirty state, while scratchpad sessions omit git context but still support MCP, LSP, and runtime tooling. Scratchpad execution uses a per-session working directory instead of a single shared scratchpad folder, so file-based context and generated artifacts stay scoped to the active scratchpad session. Both session kinds share the same tooling selection and approval model. This keeps the architecture grounded in real codebases without forcing every conversation to be project-heavy, while still letting scratchpad sessions leverage configured tools when useful.
|
||||
|
||||
For git-backed projects, the renderer surfaces three specialized components. `RunChangeSummaryCard` appears inline in the run timeline after each completed run, showing the files changed during that run with per-file diff previews, origin attribution (run-created vs. pre-existing), and selective discard actions. `CommitComposer` is a slide-over panel for staging files, editing an AI-suggested commit message, selecting a conventional commit type, and committing (with optional push). `GitPanel` is embedded in the tabbed bottom panel (alongside the terminal) and provides branch management, push/pull/fetch network operations, working-tree change inspection, and recent commit history. The bottom panel uses a shared resize handle and tab bar so the terminal and git views coexist without competing for screen real estate. All git write operations flow through IPC to the main process; the renderer never runs git commands directly.
|
||||
|
||||
### Execution observability
|
||||
|
||||
The architecture treats execution as observable by design:
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
noble_pinhole.0g@icloud.com.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
@@ -0,0 +1 @@
|
||||
ManifestDPIAware true
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "aryx",
|
||||
"version": "0.0.17",
|
||||
"description": "Electron orchestrator for Copilot-powered agent workflows across multiple projects.",
|
||||
"version": "0.0.21",
|
||||
"description": "Orchestrator for Copilot-powered agent workflows across multiple projects.",
|
||||
"private": true,
|
||||
"main": "dist-electron/main/index.js",
|
||||
"scripts": {
|
||||
@@ -126,6 +126,7 @@
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"include": "assets/installer.nsh",
|
||||
"installerIcon": "assets/icons/windows/icon.ico",
|
||||
"uninstallerIcon": "assets/icons/windows/icon.ico"
|
||||
},
|
||||
|
||||
@@ -188,6 +188,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
||||
public RunTurnToolingConfigDto? Tooling { get; init; }
|
||||
public WorkflowCheckpointResumeDto? ResumeFromCheckpoint { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CancelTurnCommandDto : SidecarCommandEnvelope
|
||||
@@ -488,6 +489,28 @@ public sealed class PendingMessagesModifiedEventDto : SidecarEventDto
|
||||
public string? AgentName { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WorkflowCheckpointSavedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string WorkflowSessionId { get; init; } = string.Empty;
|
||||
public string CheckpointId { get; init; } = string.Empty;
|
||||
public string StorePath { get; init; } = string.Empty;
|
||||
public int StepNumber { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WorkflowDiagnosticEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string Severity { get; init; } = string.Empty;
|
||||
public string DiagnosticKind { get; init; } = string.Empty;
|
||||
public string Message { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string? ExecutorId { get; init; }
|
||||
public string? SubworkflowId { get; init; }
|
||||
public string? ExceptionType { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionsListedEventDto : SidecarEventDto
|
||||
{
|
||||
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
|
||||
@@ -591,6 +614,13 @@ public sealed class ExitPlanModeRequestedEventDto : SidecarEventDto
|
||||
public string? RecommendedAction { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WorkflowCheckpointResumeDto
|
||||
{
|
||||
public string WorkflowSessionId { get; init; } = string.Empty;
|
||||
public string CheckpointId { get; init; } = string.Empty;
|
||||
public string StorePath { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CommandErrorEventDto : SidecarEventDto
|
||||
{
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
@@ -51,27 +51,7 @@ internal static class AgentInstructionComposer
|
||||
return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance, groupChatGuidance);
|
||||
}
|
||||
|
||||
if (!string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance);
|
||||
}
|
||||
|
||||
string runtimeGuidance = agentIndex == 0
|
||||
? """
|
||||
You are the routing gate for this handoff workflow.
|
||||
Your job is to classify the request and hand it off to the most appropriate specialist as soon as you know who should own the substantive work.
|
||||
For any substantive task, your next meaningful action must be the actual handoff rather than a plain-text promise to delegate later.
|
||||
Do not inspect files, call tools, draft the implementation, or produce the final user-facing answer yourself once a specialist is appropriate.
|
||||
Do not claim that you handed work off unless you actually executed the handoff.
|
||||
Only answer directly if the user is asking for pure triage or a minimal clarification that must happen before delegation.
|
||||
"""
|
||||
: """
|
||||
You are a specialist participating in a handoff workflow.
|
||||
Once the triage agent hands work to you, you own the substantive answer within your specialty and should carry it through.
|
||||
Do not push the actual work back to triage unless you are blocked or the request is clearly outside your specialty.
|
||||
""";
|
||||
|
||||
return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance, runtimeGuidance);
|
||||
return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance);
|
||||
}
|
||||
|
||||
private static string JoinInstructionBlocks(params string[] blocks)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
@@ -104,6 +105,7 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
try
|
||||
{
|
||||
Channel<AgentResponseUpdate> channel = Channel.CreateUnbounded<AgentResponseUpdate>();
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
|
||||
using IDisposable subscription = copilotSession.On(evt =>
|
||||
{
|
||||
@@ -114,9 +116,19 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
break;
|
||||
|
||||
case AssistantMessageEvent assistantMessage:
|
||||
TrackToolRequestNames(toolNamesByCallId, assistantMessage.Data?.ToolRequests);
|
||||
channel.Writer.TryWrite(ConvertToAgentResponseUpdate(assistantMessage));
|
||||
break;
|
||||
|
||||
case ToolExecutionCompleteEvent toolExecutionComplete:
|
||||
AgentResponseUpdate? toolResultUpdate = ConvertToAgentResponseUpdate(toolExecutionComplete, toolNamesByCallId);
|
||||
if (toolResultUpdate is not null)
|
||||
{
|
||||
channel.Writer.TryWrite(toolResultUpdate);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case AssistantUsageEvent usageEvent:
|
||||
channel.Writer.TryWrite(ConvertToAgentResponseUpdate(usageEvent));
|
||||
break;
|
||||
@@ -232,16 +244,6 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only project handoff tool calls as FunctionCallContent for the Agent Framework.
|
||||
// Other tool calls (ask_user, MCP tools, etc.) are resolved by the Copilot SDK
|
||||
// internally and must not be surfaced, because AIAgentHostExecutor tracks every
|
||||
// FunctionCallContent as an outstanding request. An unmatched request prevents
|
||||
// the executor from emitting a TurnToken, which stalls group-chat advancement.
|
||||
if (!IsHandoffToolName(toolRequest.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
contents.Add(new FunctionCallContent(
|
||||
toolRequest.ToolCallId,
|
||||
toolRequest.Name,
|
||||
@@ -251,6 +253,26 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
return contents;
|
||||
}
|
||||
|
||||
internal static FunctionResultContent? TryCreateToolResultContent(
|
||||
ToolExecutionCompleteEvent toolExecutionComplete,
|
||||
string? toolName = null)
|
||||
{
|
||||
// Regular Copilot tools need their result projected back into AF so the function call
|
||||
// remains part of workflow-visible history. Handoff tools are finalized separately by
|
||||
// HandoffAgentExecutor, which already injects its own "Transferred." result.
|
||||
string? toolCallId = toolExecutionComplete.Data?.ToolCallId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(toolCallId) || IsHandoffToolName(toolName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string result = ResolveToolResultText(toolExecutionComplete.Data);
|
||||
return new FunctionResultContent(toolCallId, result)
|
||||
{
|
||||
RawRepresentation = toolExecutionComplete,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsHandoffToolName(string? name)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(name)
|
||||
@@ -441,6 +463,36 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
};
|
||||
}
|
||||
|
||||
private AgentResponseUpdate? ConvertToAgentResponseUpdate(
|
||||
ToolExecutionCompleteEvent toolExecutionComplete,
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId)
|
||||
{
|
||||
string? toolCallId = toolExecutionComplete.Data?.ToolCallId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(toolCallId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? toolName = null;
|
||||
if (toolNamesByCallId.TryRemove(toolCallId, out string? trackedToolName))
|
||||
{
|
||||
toolName = trackedToolName;
|
||||
}
|
||||
|
||||
FunctionResultContent? toolResult = TryCreateToolResultContent(toolExecutionComplete, toolName);
|
||||
if (toolResult is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AgentResponseUpdate(ChatRole.Tool, [toolResult])
|
||||
{
|
||||
AgentId = Id,
|
||||
MessageId = toolCallId,
|
||||
CreatedAt = toolExecutionComplete.Timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(SessionEvent sessionEvent)
|
||||
{
|
||||
AIContent content = new()
|
||||
@@ -455,6 +507,45 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
};
|
||||
}
|
||||
|
||||
private static void TrackToolRequestNames(
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId,
|
||||
AssistantMessageDataToolRequestsItem[]? toolRequests)
|
||||
{
|
||||
if (toolRequests is not { Length: > 0 })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (AssistantMessageDataToolRequestsItem toolRequest in toolRequests)
|
||||
{
|
||||
string? toolCallId = toolRequest.ToolCallId?.Trim();
|
||||
string? toolName = toolRequest.Name?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(toolCallId) || string.IsNullOrWhiteSpace(toolName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
toolNamesByCallId[toolCallId] = toolName;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveToolResultText(ToolExecutionCompleteData? toolExecutionCompleteData)
|
||||
{
|
||||
if (toolExecutionCompleteData is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (toolExecutionCompleteData.Success)
|
||||
{
|
||||
return toolExecutionCompleteData.Result?.Content
|
||||
?? toolExecutionCompleteData.Result?.DetailedContent
|
||||
?? string.Empty;
|
||||
}
|
||||
|
||||
return toolExecutionCompleteData.Error?.Message ?? string.Empty;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?>? ParseToolArguments(object? arguments)
|
||||
{
|
||||
if (arguments is null)
|
||||
|
||||
@@ -189,9 +189,9 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
{
|
||||
return pattern.Mode switch
|
||||
{
|
||||
"single" => AgentWorkflowBuilder.BuildSequential(pattern.Name, ResolveOrderedAgents(pattern)),
|
||||
"sequential" => AgentWorkflowBuilder.BuildSequential(pattern.Name, ResolveOrderedAgents(pattern)),
|
||||
"concurrent" => AgentWorkflowBuilder.BuildConcurrent(pattern.Name, ResolveOrderedAgents(pattern)),
|
||||
"single" => BuildSequentialWorkflow(pattern),
|
||||
"sequential" => BuildSequentialWorkflow(pattern),
|
||||
"concurrent" => BuildConcurrentWorkflow(pattern),
|
||||
"handoff" => BuildHandoffWorkflow(pattern),
|
||||
"group-chat" => BuildGroupChatWorkflow(pattern),
|
||||
"magentic" => throw new NotSupportedException(
|
||||
@@ -222,8 +222,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
: pattern.Agents.FirstOrDefault()?.Id ?? topology.EntryAgentId;
|
||||
AIAgent entryAgent = agentMap.GetValueOrDefault(entryAgentId) ?? Agents[0];
|
||||
|
||||
HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
|
||||
.WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
||||
HandoffsWorkflowBuilder builder = CreateHandoffWorkflowBuilder(entryAgent);
|
||||
|
||||
foreach (PatternHandoffRoute route in topology.Routes)
|
||||
{
|
||||
@@ -250,20 +249,136 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
internal static AIAgentHostOptions CreateAgentHostOptions()
|
||||
{
|
||||
return new AIAgentHostOptions
|
||||
{
|
||||
// Aryx controls per-turn streaming with TurnToken(emitEvents: true), so keep this
|
||||
// null to preserve that behavior while making the host defaults explicit in code.
|
||||
EmitAgentUpdateEvents = null,
|
||||
// Aryx already projects streamed transcript state itself; enabling this would add
|
||||
// extra response events that need separate reconciliation first.
|
||||
EmitAgentResponseEvents = false,
|
||||
InterceptUserInputRequests = false,
|
||||
InterceptUnterminatedFunctionCalls = false,
|
||||
ReassignOtherAgentsAsUsers = true,
|
||||
ForwardIncomingMessages = true,
|
||||
};
|
||||
}
|
||||
|
||||
internal static HandoffsWorkflowBuilder CreateHandoffWorkflowBuilder(AIAgent entryAgent)
|
||||
{
|
||||
return AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
|
||||
// Preserve normal tool-call history across handoffs while still hiding the
|
||||
// workflow's handoff plumbing. Make this explicit so AF default changes
|
||||
// cannot silently alter Aryx handoff behavior.
|
||||
.WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.HandoffOnly)
|
||||
.WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
||||
}
|
||||
|
||||
private Workflow BuildSequentialWorkflow(PatternDefinitionDto pattern)
|
||||
{
|
||||
IReadOnlyList<AIAgent> agents = ResolveOrderedAgents(pattern);
|
||||
List<ExecutorBinding> agentExecutors = agents
|
||||
.Select(CreateAgentExecutorBinding)
|
||||
.ToList();
|
||||
|
||||
ExecutorBinding previous = agentExecutors[0];
|
||||
WorkflowBuilder builder = new(previous);
|
||||
|
||||
foreach (ExecutorBinding next in agentExecutors.Skip(1))
|
||||
{
|
||||
builder.AddEdge(previous, next);
|
||||
previous = next;
|
||||
}
|
||||
|
||||
WorkflowOutputMessagesExecutor end = new();
|
||||
builder = builder.AddEdge(previous, end).WithOutputFrom(end);
|
||||
|
||||
if (pattern.Name is not null)
|
||||
{
|
||||
builder = builder.WithName(pattern.Name);
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private Workflow BuildConcurrentWorkflow(PatternDefinitionDto pattern)
|
||||
{
|
||||
IReadOnlyList<AIAgent> agents = ResolveOrderedAgents(pattern);
|
||||
ChatForwardingExecutor start = new("Start");
|
||||
WorkflowBuilder builder = new(start);
|
||||
|
||||
ExecutorBinding[] agentExecutors = agents
|
||||
.Select(CreateAgentExecutorBinding)
|
||||
.ToArray();
|
||||
ExecutorBinding[] accumulators = agentExecutors
|
||||
.Select(executor => CreateAggregateMessagesExecutorBinding($"Batcher/{executor.Id}"))
|
||||
.ToArray();
|
||||
|
||||
builder.AddFanOutEdge(start, agentExecutors);
|
||||
|
||||
for (int index = 0; index < agentExecutors.Length; index++)
|
||||
{
|
||||
builder.AddEdge(agentExecutors[index], accumulators[index]);
|
||||
}
|
||||
|
||||
Func<string, string, ValueTask<WorkflowConcurrentEndExecutor>> endFactory =
|
||||
(_, __) => new(new WorkflowConcurrentEndExecutor(agentExecutors.Length, AggregateConcurrentResults));
|
||||
ExecutorBinding end = endFactory.BindExecutor(WorkflowConcurrentEndExecutor.ExecutorId);
|
||||
|
||||
builder.AddFanInBarrierEdge(accumulators, end);
|
||||
builder = builder.WithOutputFrom(end);
|
||||
|
||||
if (pattern.Name is not null)
|
||||
{
|
||||
builder = builder.WithName(pattern.Name);
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private Workflow BuildGroupChatWorkflow(PatternDefinitionDto pattern)
|
||||
{
|
||||
int maximumIterations = pattern.MaxIterations <= 0 ? 5 : pattern.MaxIterations;
|
||||
AIAgent[] agents = ResolveOrderedAgents(pattern).ToArray();
|
||||
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(
|
||||
agent => agent,
|
||||
CreateAgentExecutorBinding);
|
||||
|
||||
return AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents =>
|
||||
new RoundRobinGroupChatManager(agents)
|
||||
{
|
||||
MaximumIterationCount = maximumIterations,
|
||||
})
|
||||
.AddParticipants(ResolveOrderedAgents(pattern).ToArray())
|
||||
.Build();
|
||||
Func<string, string, ValueTask<WorkflowRoundRobinGroupChatHost>> groupChatHostFactory =
|
||||
(id, _) => new(new WorkflowRoundRobinGroupChatHost(
|
||||
id,
|
||||
agents,
|
||||
agentMap,
|
||||
maximumIterations));
|
||||
|
||||
ExecutorBinding host = groupChatHostFactory.BindExecutor("GroupChatHost");
|
||||
WorkflowBuilder builder = new(host);
|
||||
|
||||
foreach (ExecutorBinding participant in agentMap.Values)
|
||||
{
|
||||
builder
|
||||
.AddEdge(host, participant)
|
||||
.AddEdge(participant, host);
|
||||
}
|
||||
|
||||
return builder.WithOutputFrom(host).Build();
|
||||
}
|
||||
|
||||
private static ExecutorBinding CreateAgentExecutorBinding(AIAgent agent)
|
||||
=> agent.BindAsExecutor(CreateAgentHostOptions());
|
||||
|
||||
private static ExecutorBinding CreateAggregateMessagesExecutorBinding(string id)
|
||||
{
|
||||
Func<string, string, ValueTask<WorkflowAggregateTurnMessagesExecutor>> factory =
|
||||
(_, __) => new(new WorkflowAggregateTurnMessagesExecutor(id));
|
||||
return factory.BindExecutor(id);
|
||||
}
|
||||
|
||||
private static List<ChatMessage> AggregateConcurrentResults(IList<List<ChatMessage>> lists)
|
||||
=> [.. from list in lists where list.Count > 0 select list.Last()];
|
||||
|
||||
private IReadOnlyList<AIAgent> ResolveOrderedAgents(PatternDefinitionDto pattern)
|
||||
{
|
||||
Dictionary<string, AIAgent> agentMap = BuildAgentMap(pattern);
|
||||
|
||||
@@ -98,7 +98,7 @@ internal sealed class CopilotApprovalCoordinator
|
||||
{
|
||||
string? toolName = ResolveApprovalToolName(request, toolNamesByCallId);
|
||||
string? autoApprovedToolName = ResolveAutoApprovedToolName(request);
|
||||
string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request);
|
||||
string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request, command.Tooling?.McpServers);
|
||||
string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName);
|
||||
|
||||
AgentActivityEventDto? fileChangeActivity = BuildToolCallFileChangeActivity(command, agent, request, toolName);
|
||||
@@ -155,18 +155,7 @@ internal sealed class CopilotApprovalCoordinator
|
||||
string approvalId,
|
||||
string? toolName)
|
||||
{
|
||||
string permissionKind = string.IsNullOrWhiteSpace(request.Kind)
|
||||
? "tool access"
|
||||
: request.Kind.Trim();
|
||||
|
||||
if (request is PermissionRequestHook hook)
|
||||
{
|
||||
string? resolvedCategory = ResolveHookToolCategory(hook.ToolName);
|
||||
if (resolvedCategory is not null)
|
||||
{
|
||||
permissionKind = resolvedCategory;
|
||||
}
|
||||
}
|
||||
string permissionKind = ResolvePermissionKind(request, command.Tooling?.McpServers);
|
||||
|
||||
string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
|
||||
string? sessionId = NormalizeOptionalString(invocation.SessionId);
|
||||
@@ -208,7 +197,7 @@ internal sealed class CopilotApprovalCoordinator
|
||||
PermissionKind = permissionKind,
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
PermissionDetail = BuildPermissionDetail(request),
|
||||
PermissionDetail = BuildPermissionDetail(request, command.Tooling?.McpServers),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,7 +241,9 @@ internal sealed class CopilotApprovalCoordinator
|
||||
};
|
||||
}
|
||||
|
||||
internal static PermissionDetailDto BuildPermissionDetail(PermissionRequest request)
|
||||
internal static PermissionDetailDto BuildPermissionDetail(
|
||||
PermissionRequest request,
|
||||
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
@@ -309,12 +300,7 @@ internal sealed class CopilotApprovalCoordinator
|
||||
ToolDescription = NormalizeOptionalString(customTool.ToolDescription),
|
||||
Args = customTool.Args,
|
||||
},
|
||||
PermissionRequestHook hook => new PermissionDetailDto
|
||||
{
|
||||
Kind = HookPermissionKind,
|
||||
Args = hook.ToolArgs,
|
||||
HookMessage = NormalizeOptionalString(hook.HookMessage),
|
||||
},
|
||||
PermissionRequestHook hook => BuildHookPermissionDetail(hook, configuredMcpServers),
|
||||
_ => new PermissionDetailDto
|
||||
{
|
||||
Kind = NormalizeOptionalString(request.Kind) ?? "unknown",
|
||||
@@ -430,15 +416,45 @@ internal sealed class CopilotApprovalCoordinator
|
||||
|
||||
private const string McpServerApprovalPrefix = "mcp_server:";
|
||||
|
||||
private static string? ResolveMcpServerApprovalKey(PermissionRequest request)
|
||||
private static string? ResolveMcpServerApprovalKey(
|
||||
PermissionRequest request,
|
||||
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
|
||||
{
|
||||
if (request is not PermissionRequestMcp mcp)
|
||||
return request switch
|
||||
{
|
||||
PermissionRequestMcp mcp => BuildMcpServerApprovalKey(mcp.ServerName),
|
||||
PermissionRequestHook hook => ResolveHookMcpServerApprovalKey(hook.ToolName, configuredMcpServers),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
internal static string? BuildMcpServerApprovalKey(string? serverName)
|
||||
{
|
||||
string? normalizedServerName = NormalizeOptionalString(serverName);
|
||||
return normalizedServerName is not null ? $"{McpServerApprovalPrefix}{normalizedServerName}" : null;
|
||||
}
|
||||
|
||||
internal static string? ResolveHookMcpServerApprovalKey(
|
||||
string? toolName,
|
||||
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
|
||||
=> BuildMcpServerApprovalKey(ResolveHookMcpServerName(toolName, configuredMcpServers));
|
||||
|
||||
internal static string? ResolveHookMcpServerName(
|
||||
string? toolName,
|
||||
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
|
||||
{
|
||||
string? normalizedToolName = NormalizeOptionalString(toolName);
|
||||
if (normalizedToolName is null || configuredMcpServers is null || configuredMcpServers.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? serverName = NormalizeOptionalString(mcp.ServerName);
|
||||
return serverName is not null ? $"{McpServerApprovalPrefix}{serverName}" : null;
|
||||
return configuredMcpServers
|
||||
.Select(ResolveConfiguredMcpServerName)
|
||||
.OfType<string>()
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderByDescending(static serverName => serverName.Length)
|
||||
.FirstOrDefault(serverName => MatchesHookMcpServerToolName(normalizedToolName, serverName));
|
||||
}
|
||||
|
||||
private static string? ResolveApprovalCacheKey(
|
||||
@@ -520,6 +536,87 @@ internal sealed class CopilotApprovalCoordinator
|
||||
return HookToolCategories.TryGetValue(normalized, out string? category) ? category : null;
|
||||
}
|
||||
|
||||
private static string ResolvePermissionKind(
|
||||
PermissionRequest request,
|
||||
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
|
||||
{
|
||||
string permissionKind = string.IsNullOrWhiteSpace(request.Kind)
|
||||
? "tool access"
|
||||
: request.Kind.Trim();
|
||||
|
||||
if (request is not PermissionRequestHook hook)
|
||||
{
|
||||
return permissionKind;
|
||||
}
|
||||
|
||||
string? resolvedCategory = ResolveHookToolCategory(hook.ToolName);
|
||||
if (resolvedCategory is not null)
|
||||
{
|
||||
return resolvedCategory;
|
||||
}
|
||||
|
||||
return ResolveHookMcpServerName(hook.ToolName, configuredMcpServers) is not null
|
||||
? McpPermissionKind
|
||||
: permissionKind;
|
||||
}
|
||||
|
||||
private static PermissionDetailDto BuildHookPermissionDetail(
|
||||
PermissionRequestHook hook,
|
||||
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
|
||||
{
|
||||
string? serverName = ResolveHookMcpServerName(hook.ToolName, configuredMcpServers);
|
||||
if (serverName is null)
|
||||
{
|
||||
return new PermissionDetailDto
|
||||
{
|
||||
Kind = HookPermissionKind,
|
||||
Args = hook.ToolArgs,
|
||||
HookMessage = NormalizeOptionalString(hook.HookMessage),
|
||||
};
|
||||
}
|
||||
|
||||
return new PermissionDetailDto
|
||||
{
|
||||
Kind = McpPermissionKind,
|
||||
ServerName = serverName,
|
||||
ToolTitle = ResolveHookMcpToolTitle(hook.ToolName, serverName),
|
||||
Args = hook.ToolArgs,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveConfiguredMcpServerName(RunTurnMcpServerConfigDto configuredServer)
|
||||
=> NormalizeOptionalString(configuredServer.Name) ?? NormalizeOptionalString(configuredServer.Id);
|
||||
|
||||
private static bool MatchesHookMcpServerToolName(string toolName, string serverName)
|
||||
{
|
||||
if (string.Equals(toolName, serverName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return toolName.StartsWith($"{serverName}-", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string? ResolveHookMcpToolTitle(string? toolName, string serverName)
|
||||
{
|
||||
string? normalizedToolName = NormalizeOptionalString(toolName);
|
||||
if (normalizedToolName is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string prefix = $"{serverName}-";
|
||||
if (!normalizedToolName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return normalizedToolName;
|
||||
}
|
||||
|
||||
string strippedToolName = normalizedToolName[prefix.Length..];
|
||||
return string.IsNullOrWhiteSpace(strippedToolName)
|
||||
? normalizedToolName
|
||||
: strippedToolName;
|
||||
}
|
||||
|
||||
private static bool MatchesAutoApprovedTool(
|
||||
IReadOnlyList<string> autoApprovedToolNames,
|
||||
string? toolName,
|
||||
|
||||
@@ -249,12 +249,16 @@ internal static class CopilotSessionHooks
|
||||
}
|
||||
|
||||
string? autoApprovedToolName = CopilotApprovalCoordinator.ResolveHookToolCategory(toolName) ?? toolName;
|
||||
string? mcpServerApprovalKey = CopilotApprovalCoordinator.ResolveHookMcpServerApprovalKey(
|
||||
toolName,
|
||||
command.Tooling?.McpServers);
|
||||
|
||||
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||
command.Pattern.ApprovalPolicy,
|
||||
agentDefinition.Id,
|
||||
toolName,
|
||||
autoApprovedToolName);
|
||||
autoApprovedToolName,
|
||||
mcpServerApprovalKey);
|
||||
|
||||
return new PreToolUseHookOutput
|
||||
{
|
||||
|
||||
@@ -89,7 +89,16 @@ internal sealed class CopilotTurnExecutionState
|
||||
case ToolExecutionStartEvent toolExecutionStart
|
||||
when !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolCallId)
|
||||
&& !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolName):
|
||||
ToolNamesByCallId[toolExecutionStart.Data.ToolCallId.Trim()] = toolExecutionStart.Data.ToolName.Trim();
|
||||
string toolCallId = toolExecutionStart.Data.ToolCallId.Trim();
|
||||
string toolName = toolExecutionStart.Data.ToolName.Trim();
|
||||
ToolNamesByCallId[toolCallId] = toolName;
|
||||
ActiveAgent = agent;
|
||||
AgentActivityEventDto? toolActivity = CreateToolCallingActivity(agent, toolName, toolCallId);
|
||||
if (toolActivity is not null)
|
||||
{
|
||||
_pendingEvents.Enqueue(toolActivity);
|
||||
}
|
||||
|
||||
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
|
||||
break;
|
||||
case AssistantIntentEvent intentEvent:
|
||||
@@ -278,6 +287,29 @@ internal sealed class CopilotTurnExecutionState
|
||||
};
|
||||
}
|
||||
|
||||
private AgentActivityEventDto? CreateToolCallingActivity(
|
||||
AgentIdentity agent,
|
||||
string toolName,
|
||||
string toolCallId)
|
||||
{
|
||||
if (toolName.StartsWith("handoff_to_", StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
ActivityType = "tool-calling",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolName = toolName,
|
||||
ToolCallId = toolCallId,
|
||||
};
|
||||
}
|
||||
|
||||
private MessageReclassifiedEventDto CreateMessageReclassifiedEvent(string messageId)
|
||||
{
|
||||
return new MessageReclassifiedEventDto
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
@@ -81,7 +83,16 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
||||
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
|
||||
|
||||
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false);
|
||||
using FileSystemJsonCheckpointStore? checkpointStore = CreateCheckpointStore(command);
|
||||
CheckpointManager? checkpointManager = checkpointStore is not null
|
||||
? CheckpointManager.CreateJson(checkpointStore)
|
||||
: null;
|
||||
|
||||
await using StreamingRun run = await OpenWorkflowRunAsync(
|
||||
command,
|
||||
workflow,
|
||||
inputMessages,
|
||||
checkpointManager).ConfigureAwait(false);
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(runCancellation.Token).ConfigureAwait(false))
|
||||
@@ -120,6 +131,62 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
}
|
||||
}
|
||||
|
||||
internal static FileSystemJsonCheckpointStore? CreateCheckpointStore(RunTurnCommandDto command)
|
||||
{
|
||||
if (!ShouldEnableWorkflowCheckpointing(command))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
DirectoryInfo checkpointDirectory = new(GetCheckpointStorePath(command));
|
||||
return new FileSystemJsonCheckpointStore(checkpointDirectory);
|
||||
}
|
||||
|
||||
internal static bool ShouldEnableWorkflowCheckpointing(RunTurnCommandDto command)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
return string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
internal static string GetCheckpointStorePath(RunTurnCommandDto command)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(command.ResumeFromCheckpoint?.StorePath))
|
||||
{
|
||||
return command.ResumeFromCheckpoint.StorePath;
|
||||
}
|
||||
|
||||
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
return Path.Combine(localAppData, "Aryx", "workflow-checkpoints", command.SessionId, command.RequestId);
|
||||
}
|
||||
|
||||
private static ValueTask<StreamingRun> OpenWorkflowRunAsync(
|
||||
RunTurnCommandDto command,
|
||||
Workflow workflow,
|
||||
IReadOnlyList<ChatMessage> inputMessages,
|
||||
CheckpointManager? checkpointManager)
|
||||
{
|
||||
if (checkpointManager is not null && command.ResumeFromCheckpoint is { } resumeFromCheckpoint)
|
||||
{
|
||||
return InProcessExecution.ResumeStreamingAsync(
|
||||
workflow,
|
||||
new CheckpointInfo(resumeFromCheckpoint.WorkflowSessionId, resumeFromCheckpoint.CheckpointId),
|
||||
checkpointManager);
|
||||
}
|
||||
|
||||
if (checkpointManager is not null)
|
||||
{
|
||||
return InProcessExecution.RunStreamingAsync(
|
||||
workflow,
|
||||
inputMessages.ToList(),
|
||||
checkpointManager,
|
||||
sessionId: command.RequestId);
|
||||
}
|
||||
|
||||
return InProcessExecution.RunStreamingAsync(workflow, inputMessages.ToList());
|
||||
}
|
||||
|
||||
internal static void ConfigureHookLifecycleEventSuppression(
|
||||
CopilotTurnExecutionState state,
|
||||
CopilotAgentBundle bundle)
|
||||
@@ -211,6 +278,18 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryCreateWorkflowCheckpointSavedEvent(command, evt, out WorkflowCheckpointSavedEventDto? checkpointSaved))
|
||||
{
|
||||
await onEvent(checkpointSaved).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryCreateWorkflowDiagnosticEvent(command, evt, state, out WorkflowDiagnosticEventDto? diagnostic))
|
||||
{
|
||||
await onEvent(diagnostic).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (evt is AgentResponseUpdateEvent update)
|
||||
{
|
||||
await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onEvent).ConfigureAwait(false);
|
||||
@@ -274,6 +353,14 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
updateAgent = resolvedUpdateAgent;
|
||||
authorName = resolvedUpdateAgent.AgentName;
|
||||
}
|
||||
else if (state.ActiveAgent is AgentIdentity activeAgent)
|
||||
{
|
||||
updateAgent = activeAgent;
|
||||
authorName = activeAgent.AgentName;
|
||||
TraceHandoff(
|
||||
command,
|
||||
$"Agent response update fell back to active agent {activeAgent.AgentName} ({activeAgent.AgentId}) for executor '{update.ExecutorId}' and message '{update.Update.MessageId ?? "<none>"}'.");
|
||||
}
|
||||
|
||||
if (updateAgent.HasValue)
|
||||
{
|
||||
@@ -341,6 +428,121 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryCreateWorkflowCheckpointSavedEvent(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowEvent evt,
|
||||
out WorkflowCheckpointSavedEventDto checkpointSaved)
|
||||
{
|
||||
checkpointSaved = default!;
|
||||
|
||||
if (!ShouldEnableWorkflowCheckpointing(command)
|
||||
|| evt is not SuperStepCompletedEvent superStepCompleted
|
||||
|| superStepCompleted.CompletionInfo?.Checkpoint is not CheckpointInfo checkpoint)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
checkpointSaved = new WorkflowCheckpointSavedEventDto
|
||||
{
|
||||
Type = "workflow-checkpoint-saved",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
WorkflowSessionId = checkpoint.SessionId,
|
||||
CheckpointId = checkpoint.CheckpointId,
|
||||
StorePath = GetCheckpointStorePath(command),
|
||||
StepNumber = superStepCompleted.StepNumber,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryCreateWorkflowDiagnosticEvent(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowEvent evt,
|
||||
CopilotTurnExecutionState state,
|
||||
out WorkflowDiagnosticEventDto diagnostic)
|
||||
{
|
||||
diagnostic = default!;
|
||||
|
||||
switch (evt)
|
||||
{
|
||||
case ExecutorFailedEvent executorFailed:
|
||||
{
|
||||
AgentIdentity? agent = AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
command.Pattern,
|
||||
executorFailed.ExecutorId,
|
||||
state.ActiveAgent,
|
||||
out AgentIdentity resolvedAgent)
|
||||
? resolvedAgent
|
||||
: null;
|
||||
Exception? exception = executorFailed.Data;
|
||||
diagnostic = new WorkflowDiagnosticEventDto
|
||||
{
|
||||
Type = "workflow-diagnostic",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
Severity = "error",
|
||||
DiagnosticKind = "executor-failed",
|
||||
Message = ResolveDiagnosticMessage(exception, "Executor failed."),
|
||||
AgentId = agent?.AgentId,
|
||||
AgentName = agent?.AgentName,
|
||||
ExecutorId = executorFailed.ExecutorId,
|
||||
ExceptionType = exception?.GetBaseException().GetType().Name,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
case WorkflowWarningEvent workflowWarning:
|
||||
diagnostic = new WorkflowDiagnosticEventDto
|
||||
{
|
||||
Type = "workflow-diagnostic",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
Severity = "warning",
|
||||
DiagnosticKind = workflowWarning is SubworkflowWarningEvent
|
||||
? "subworkflow-warning"
|
||||
: "workflow-warning",
|
||||
Message = ResolveDiagnosticMessage(workflowWarning.Data as string, "Workflow warning."),
|
||||
SubworkflowId = workflowWarning is SubworkflowWarningEvent subworkflowWarning
|
||||
? subworkflowWarning.SubWorkflowId
|
||||
: null,
|
||||
};
|
||||
return true;
|
||||
case WorkflowErrorEvent workflowError:
|
||||
{
|
||||
Exception? exception = workflowError.Exception;
|
||||
diagnostic = new WorkflowDiagnosticEventDto
|
||||
{
|
||||
Type = "workflow-diagnostic",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
Severity = "error",
|
||||
DiagnosticKind = workflowError is SubworkflowErrorEvent
|
||||
? "subworkflow-error"
|
||||
: "workflow-error",
|
||||
Message = ResolveDiagnosticMessage(exception, "Workflow failed."),
|
||||
SubworkflowId = workflowError is SubworkflowErrorEvent subworkflowError
|
||||
? subworkflowError.SubworkflowId
|
||||
: null,
|
||||
ExceptionType = exception?.GetBaseException().GetType().Name,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveDiagnosticMessage(Exception? exception, string fallback)
|
||||
{
|
||||
return ResolveDiagnosticMessage(
|
||||
exception?.GetBaseException().Message,
|
||||
fallback);
|
||||
}
|
||||
|
||||
private static string ResolveDiagnosticMessage(string? message, string fallback)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(message) ? fallback : message;
|
||||
}
|
||||
|
||||
private static bool IsHandoffFunctionName(string? candidate)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(candidate)
|
||||
|
||||
@@ -8,11 +8,15 @@ internal static class HandoffWorkflowGuidance
|
||||
{
|
||||
return """
|
||||
This workflow uses explicit handoffs to transfer ownership between agents.
|
||||
If you are acting as the routing or triage agent, classify the request and hand it off to the best specialist as soon as ownership is clear.
|
||||
If another agent should do the substantive work, perform an actual handoff instead of answering as though the handoff already happened.
|
||||
For any substantive task, your next meaningful action must be the actual handoff rather than a plain-text promise to delegate later.
|
||||
Do not claim that you delegated unless you actually executed the handoff.
|
||||
The triage agent should route to the best specialist promptly once ownership is clear.
|
||||
In a specialist workflow, the triage agent should hand off before inspecting files, calling tools, or drafting the substantive implementation.
|
||||
If a specialist is appropriate, do not inspect files, call tools, draft the implementation, or produce the final user-facing answer before handing off.
|
||||
Only answer directly when the request is pure triage or a minimal clarification is required before delegation.
|
||||
Do not narrate a handoff in plain text without executing the handoff itself.
|
||||
If you receive work as a specialist, own the substantive answer within your specialty and carry it through.
|
||||
Do not push the work back to triage unless you are blocked or the request is clearly outside your specialty.
|
||||
Specialists should complete the substantive work after handoff and only hand control back when the task needs re-routing, broader coordination, or is outside their specialty.
|
||||
""";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class WorkflowOutputMessagesExecutor(ChatProtocolExecutorOptions? options = null)
|
||||
: ChatProtocolExecutor(ExecutorId, options, declareCrossRunShareable: true), IResettableExecutor
|
||||
{
|
||||
public const string ExecutorId = "OutputMessages";
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> base.ConfigureProtocol(protocolBuilder)
|
||||
.YieldsOutput<List<ChatMessage>>();
|
||||
|
||||
protected override ValueTask TakeTurnAsync(
|
||||
List<ChatMessage> messages,
|
||||
IWorkflowContext context,
|
||||
bool? emitEvents,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> context.YieldOutputAsync(messages, cancellationToken);
|
||||
|
||||
ValueTask IResettableExecutor.ResetAsync() => default;
|
||||
}
|
||||
|
||||
internal sealed class WorkflowAggregateTurnMessagesExecutor(string id)
|
||||
: ChatProtocolExecutor(id, s_options, declareCrossRunShareable: true), IResettableExecutor
|
||||
{
|
||||
private static readonly ChatProtocolExecutorOptions s_options = new() { AutoSendTurnToken = false };
|
||||
|
||||
protected override ValueTask TakeTurnAsync(
|
||||
List<ChatMessage> messages,
|
||||
IWorkflowContext context,
|
||||
bool? emitEvents,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> context.SendMessageAsync(messages, cancellationToken: cancellationToken);
|
||||
|
||||
ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync();
|
||||
}
|
||||
|
||||
internal sealed class WorkflowConcurrentEndExecutor : Executor, IResettableExecutor
|
||||
{
|
||||
public const string ExecutorId = "ConcurrentEnd";
|
||||
|
||||
private readonly int _expectedInputs;
|
||||
private readonly Func<IList<List<ChatMessage>>, List<ChatMessage>> _aggregator;
|
||||
private List<List<ChatMessage>> _allResults;
|
||||
private int _remaining;
|
||||
|
||||
public WorkflowConcurrentEndExecutor(
|
||||
int expectedInputs,
|
||||
Func<IList<List<ChatMessage>>, List<ChatMessage>> aggregator)
|
||||
: base(ExecutorId)
|
||||
{
|
||||
_expectedInputs = expectedInputs;
|
||||
_aggregator = aggregator;
|
||||
_allResults = new List<List<ChatMessage>>(expectedInputs);
|
||||
_remaining = expectedInputs;
|
||||
}
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
protocolBuilder.RouteBuilder.AddHandler<List<ChatMessage>>(async (messages, context, cancellationToken) =>
|
||||
{
|
||||
bool done;
|
||||
lock (_allResults)
|
||||
{
|
||||
_allResults.Add(messages);
|
||||
done = --_remaining == 0;
|
||||
}
|
||||
|
||||
if (!done)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_remaining = _expectedInputs;
|
||||
List<List<ChatMessage>> results = _allResults;
|
||||
_allResults = new List<List<ChatMessage>>(_expectedInputs);
|
||||
await context.YieldOutputAsync(_aggregator(results), cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return protocolBuilder.YieldsOutput<List<ChatMessage>>();
|
||||
}
|
||||
|
||||
public ValueTask ResetAsync()
|
||||
{
|
||||
_allResults = new List<List<ChatMessage>>(_expectedInputs);
|
||||
_remaining = _expectedInputs;
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WorkflowRoundRobinGroupChatHost(
|
||||
string id,
|
||||
AIAgent[] agents,
|
||||
Dictionary<AIAgent, ExecutorBinding> agentMap,
|
||||
int maximumIterations)
|
||||
: ChatProtocolExecutor(id, s_options), IResettableExecutor
|
||||
{
|
||||
private static readonly ChatProtocolExecutorOptions s_options = new()
|
||||
{
|
||||
StringMessageChatRole = ChatRole.User,
|
||||
AutoSendTurnToken = false,
|
||||
};
|
||||
|
||||
private readonly AIAgent[] _agents = agents;
|
||||
private readonly Dictionary<AIAgent, ExecutorBinding> _agentMap = agentMap;
|
||||
private readonly int _maximumIterations = maximumIterations;
|
||||
private int _iterationCount;
|
||||
private int _nextIndex;
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> base.ConfigureProtocol(protocolBuilder).YieldsOutput<List<ChatMessage>>();
|
||||
|
||||
protected override async ValueTask TakeTurnAsync(
|
||||
List<ChatMessage> messages,
|
||||
IWorkflowContext context,
|
||||
bool? emitEvents,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_iterationCount < _maximumIterations)
|
||||
{
|
||||
AIAgent nextAgent = _agents[_nextIndex];
|
||||
_nextIndex = (_nextIndex + 1) % _agents.Length;
|
||||
|
||||
if (_agentMap.TryGetValue(nextAgent, out ExecutorBinding? executor))
|
||||
{
|
||||
_iterationCount++;
|
||||
await context.SendMessageAsync(messages, executor.Id, cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(new TurnToken(emitEvents), executor.Id, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_iterationCount = 0;
|
||||
_nextIndex = 0;
|
||||
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected override ValueTask ResetAsync()
|
||||
{
|
||||
_iterationCount = 0;
|
||||
_nextIndex = 0;
|
||||
return base.ResetAsync();
|
||||
}
|
||||
|
||||
ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync();
|
||||
}
|
||||
@@ -57,12 +57,17 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
};
|
||||
}
|
||||
|
||||
private static AgentActivityEventDto CreateToolCallingActivity(
|
||||
private static AgentActivityEventDto? CreateToolCallingActivity(
|
||||
RunTurnCommandDto command,
|
||||
AgentIdentity activeAgent,
|
||||
ToolRequestInterpretation tool,
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId)
|
||||
{
|
||||
if (tool.ToolCallId is not null && toolNamesByCallId.ContainsKey(tool.ToolCallId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
TrackToolCallId(toolNamesByCallId, tool.ToolCallId, tool.ToolName);
|
||||
|
||||
return new AgentActivityEventDto
|
||||
|
||||
@@ -54,7 +54,7 @@ public sealed class AgentInstructionComposerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_StrengthensHandoffTriageInstructions()
|
||||
public void Compose_LeavesHandoffTriagePromptFocusedOnAgentInstructions()
|
||||
{
|
||||
PatternDefinitionDto pattern = new()
|
||||
{
|
||||
@@ -70,14 +70,13 @@ public sealed class AgentInstructionComposerTests
|
||||
|
||||
string instructions = AgentInstructionComposer.Compose(pattern, triage, agentIndex: 0);
|
||||
|
||||
Assert.Contains("routing gate", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Do not inspect files", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("actual handoff", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Do not claim that you handed work off", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("You triage requests and must hand them off to the most appropriate specialist.", instructions);
|
||||
Assert.DoesNotContain("routing", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("actual handoff", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_StrengthensHandoffSpecialistInstructions()
|
||||
public void Compose_LeavesHandoffSpecialistPromptFocusedOnAgentInstructions()
|
||||
{
|
||||
PatternDefinitionDto pattern = new()
|
||||
{
|
||||
@@ -93,8 +92,9 @@ public sealed class AgentInstructionComposerTests
|
||||
|
||||
string instructions = AgentInstructionComposer.Compose(pattern, specialist, agentIndex: 1);
|
||||
|
||||
Assert.Contains("Once the triage agent hands work to you", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("own the substantive answer", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("You focus on navigation, UX, and interaction details.", instructions);
|
||||
Assert.DoesNotContain("triage agent", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("substantive answer", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -4,6 +4,7 @@ using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Aryx.AgentHost.Services;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
@@ -111,6 +112,54 @@ public sealed class CopilotAgentBundleTests
|
||||
Assert.Throws<NotSupportedException>(() => AryxCopilotAgent.CreateConfiguredSessionConfig(new SessionConfig(), options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHandoffWorkflowBuilder_ExplicitlyUsesHandoffOnlyFiltering()
|
||||
{
|
||||
ChatClientAgent entryAgent = CreateChatClientAgent("agent-1", "Primary");
|
||||
|
||||
HandoffsWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder(entryAgent);
|
||||
|
||||
FieldInfo field = typeof(HandoffsWorkflowBuilder).GetField(
|
||||
"_toolCallFilteringBehavior",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("Expected HandoffsWorkflowBuilder to expose a filtering field.");
|
||||
|
||||
HandoffToolCallFilteringBehavior behavior = Assert.IsType<HandoffToolCallFilteringBehavior>(field.GetValue(builder));
|
||||
|
||||
Assert.Equal(HandoffToolCallFilteringBehavior.HandoffOnly, behavior);
|
||||
Assert.Equal(HandoffWorkflowGuidance.CreateWorkflowInstructions(), builder.HandoffInstructions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("single", 1)]
|
||||
[InlineData("sequential", 2)]
|
||||
[InlineData("concurrent", 2)]
|
||||
[InlineData("group-chat", 2)]
|
||||
public void BuildWorkflow_ExplicitlyConfiguresAgentHostOptions(string mode, int agentCount)
|
||||
{
|
||||
CopilotAgentBundle bundle = new(CreateAgents(agentCount), hasConfiguredHooks: false);
|
||||
PatternDefinitionDto pattern = CreatePattern(mode, agentCount);
|
||||
|
||||
Workflow workflow = bundle.BuildWorkflow(pattern);
|
||||
|
||||
AIAgentBinding[] bindings = workflow.ReflectExecutors().Values
|
||||
.OfType<AIAgentBinding>()
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(agentCount, bindings.Length);
|
||||
|
||||
foreach (AIAgentBinding binding in bindings)
|
||||
{
|
||||
AIAgentHostOptions options = Assert.IsType<AIAgentHostOptions>(binding.Options);
|
||||
Assert.Null(options.EmitAgentUpdateEvents);
|
||||
Assert.False(options.EmitAgentResponseEvents);
|
||||
Assert.False(options.InterceptUserInputRequests);
|
||||
Assert.False(options.InterceptUnterminatedFunctionCalls);
|
||||
Assert.True(options.ReassignOtherAgentsAsUsers);
|
||||
Assert.True(options.ForwardIncomingMessages);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToolRequestsToFunctionCalls_MapsCallIdsNamesAndArguments()
|
||||
{
|
||||
@@ -136,7 +185,7 @@ public sealed class CopilotAgentBundleTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToolRequestsToFunctionCalls_SkipsNonHandoffToolCalls()
|
||||
public void ConvertToolRequestsToFunctionCalls_MapsNonHandoffToolCalls()
|
||||
{
|
||||
AssistantMessageDataToolRequestsItem[] toolRequests =
|
||||
{
|
||||
@@ -148,9 +197,99 @@ public sealed class CopilotAgentBundleTests
|
||||
|
||||
IReadOnlyList<FunctionCallContent> result = AryxCopilotAgent.ConvertToolRequestsToFunctionCalls(toolRequests);
|
||||
|
||||
FunctionCallContent single = Assert.Single(result);
|
||||
Assert.Equal("call-003", single.CallId);
|
||||
Assert.Equal("handoff_to_reviewer", single.Name);
|
||||
Assert.Collection(
|
||||
result,
|
||||
functionCall =>
|
||||
{
|
||||
Assert.Equal("call-001", functionCall.CallId);
|
||||
Assert.Equal("ask_user", functionCall.Name);
|
||||
},
|
||||
functionCall =>
|
||||
{
|
||||
Assert.Equal("call-002", functionCall.CallId);
|
||||
Assert.Equal("web_fetch", functionCall.Name);
|
||||
},
|
||||
functionCall =>
|
||||
{
|
||||
Assert.Equal("call-003", functionCall.CallId);
|
||||
Assert.Equal("handoff_to_reviewer", functionCall.Name);
|
||||
},
|
||||
functionCall =>
|
||||
{
|
||||
Assert.Equal("call-004", functionCall.CallId);
|
||||
Assert.Equal("grep", functionCall.Name);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateToolResultContent_UsesSdkResultContentForNonHandoffTools()
|
||||
{
|
||||
ToolExecutionCompleteEvent toolExecutionComplete = new()
|
||||
{
|
||||
Data = new ToolExecutionCompleteData
|
||||
{
|
||||
ToolCallId = "call-123",
|
||||
Success = true,
|
||||
Result = new ToolExecutionCompleteDataResult
|
||||
{
|
||||
Content = "Search complete.",
|
||||
DetailedContent = "Search complete with extra context.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
FunctionResultContent? toolResult = AryxCopilotAgent.TryCreateToolResultContent(toolExecutionComplete, "rg");
|
||||
|
||||
Assert.NotNull(toolResult);
|
||||
Assert.Equal("call-123", toolResult.CallId);
|
||||
Assert.Equal("Search complete.", Assert.IsType<string>(toolResult.Result));
|
||||
Assert.Same(toolExecutionComplete, toolResult.RawRepresentation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateToolResultContent_UsesSdkErrorMessageForFailedTools()
|
||||
{
|
||||
ToolExecutionCompleteEvent toolExecutionComplete = new()
|
||||
{
|
||||
Data = new ToolExecutionCompleteData
|
||||
{
|
||||
ToolCallId = "call-456",
|
||||
Success = false,
|
||||
Error = new ToolExecutionCompleteDataError
|
||||
{
|
||||
Message = "Permission denied.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
FunctionResultContent? toolResult = AryxCopilotAgent.TryCreateToolResultContent(toolExecutionComplete, "view");
|
||||
|
||||
Assert.NotNull(toolResult);
|
||||
Assert.Equal("call-456", toolResult.CallId);
|
||||
Assert.Equal("Permission denied.", Assert.IsType<string>(toolResult.Result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateToolResultContent_SkipsHandoffTools()
|
||||
{
|
||||
ToolExecutionCompleteEvent toolExecutionComplete = new()
|
||||
{
|
||||
Data = new ToolExecutionCompleteData
|
||||
{
|
||||
ToolCallId = "call-789",
|
||||
Success = true,
|
||||
Result = new ToolExecutionCompleteDataResult
|
||||
{
|
||||
Content = "Transferred.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
FunctionResultContent? toolResult = AryxCopilotAgent.TryCreateToolResultContent(
|
||||
toolExecutionComplete,
|
||||
"handoff_to_reviewer");
|
||||
|
||||
Assert.Null(toolResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -369,8 +508,75 @@ public sealed class CopilotAgentBundleTests
|
||||
CreateTool().JsonSchema);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AIAgent> CreateAgents(int count)
|
||||
=> Enumerable.Range(1, count)
|
||||
.Select(index => (AIAgent)CreateChatClientAgent($"agent-{index}", $"Agent {index}"))
|
||||
.ToArray();
|
||||
|
||||
private static PatternDefinitionDto CreatePattern(string mode, int agentCount)
|
||||
{
|
||||
return new PatternDefinitionDto
|
||||
{
|
||||
Id = $"pattern-{mode}",
|
||||
Name = $"Pattern {mode}",
|
||||
Mode = mode,
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
.. Enumerable.Range(1, agentCount).Select(index => new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = $"agent-{index}",
|
||||
Name = $"Agent {index}",
|
||||
Description = $"Agent {index} description.",
|
||||
Instructions = $"Agent {index} instructions.",
|
||||
Model = "gpt-5.4",
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatClientAgent CreateChatClientAgent(string id, string name)
|
||||
{
|
||||
return new ChatClientAgent(
|
||||
new StubChatClient(),
|
||||
id,
|
||||
name,
|
||||
"Stub agent for handoff builder tests.",
|
||||
[],
|
||||
null!,
|
||||
null!);
|
||||
}
|
||||
|
||||
private sealed class ToolTarget
|
||||
{
|
||||
public string Echo() => "ok";
|
||||
}
|
||||
|
||||
private sealed class StubChatClient : IChatClient
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,40 @@ public sealed class CopilotSessionHooksTests
|
||||
Assert.Equal("allow", decision?.PermissionDecision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PreToolUseAutoAllowsWhenMcpServerIsApproved()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithConfiguredMcpServers(
|
||||
["icm-mcp"],
|
||||
["mcp_server:icm-mcp"]);
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
ToolName = "icm-mcp-get_incident_details_by_id",
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("allow", decision?.PermissionDecision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PreToolUseRequiresApprovalWhenMcpServerIsNotApproved()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithConfiguredMcpServers(["icm-mcp"]);
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
ToolName = "icm-mcp-get_incident_details_by_id",
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("ask", decision?.PermissionDecision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_RunsConfiguredNonPreToolHooks()
|
||||
{
|
||||
@@ -368,6 +402,43 @@ public sealed class CopilotSessionHooksTests
|
||||
};
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateCommandWithConfiguredMcpServers(
|
||||
IReadOnlyList<string> serverNames,
|
||||
IReadOnlyList<string>? autoApprovedToolNames = null)
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ProjectPath = command.ProjectPath,
|
||||
Tooling = new RunTurnToolingConfigDto
|
||||
{
|
||||
McpServers = [.. serverNames.Select(CreateMcpServerConfig)],
|
||||
},
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = command.Pattern.Id,
|
||||
Name = command.Pattern.Name,
|
||||
Mode = command.Pattern.Mode,
|
||||
Availability = command.Pattern.Availability,
|
||||
ApprovalPolicy = new ApprovalPolicyDto
|
||||
{
|
||||
Rules = command.Pattern.ApprovalPolicy?.Rules ?? [],
|
||||
AutoApprovedToolNames = autoApprovedToolNames ?? [],
|
||||
},
|
||||
Agents = command.Pattern.Agents,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static RunTurnMcpServerConfigDto CreateMcpServerConfig(string serverName)
|
||||
=> new()
|
||||
{
|
||||
Id = serverName,
|
||||
Name = serverName,
|
||||
};
|
||||
|
||||
private static HookCommandDefinition CreateHookCommand(string name)
|
||||
=> new()
|
||||
{
|
||||
|
||||
@@ -60,7 +60,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallId()
|
||||
public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallIdAndQueuesToolActivity()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
@@ -68,22 +68,32 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "tool.execution_start",
|
||||
"data": {
|
||||
"toolCallId": "tool-call-1",
|
||||
"toolName": "view"
|
||||
},
|
||||
"id": "33333333-3333-3333-3333-333333333333",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"view"},"id":"33333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}"""));
|
||||
|
||||
AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.Equal("tool-calling", toolActivity.ActivityType);
|
||||
Assert.Equal("view", toolActivity.ToolName);
|
||||
Assert.Equal("tool-call-1", toolActivity.ToolCallId);
|
||||
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName));
|
||||
Assert.Equal("view", toolName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_ToolExecutionStart_DoesNotQueueToolActivityForHandoffTools()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"handoff_to_specialist"},"id":"1ce9d1dc-68f1-4df5-9728-f97017233279","timestamp":"2026-03-27T00:00:00Z"}"""));
|
||||
|
||||
Assert.Empty(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName));
|
||||
Assert.Equal("handoff_to_specialist", toolName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_AssistantMessageWithToolRequests_QueuesMessageReclassifiedEvent()
|
||||
{
|
||||
@@ -178,6 +188,10 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
|
||||
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
|
||||
|
||||
AgentActivityEventDto[] toolActivities = [.. pending.OfType<AgentActivityEventDto>().Where(activity => activity.ActivityType == "tool-calling")];
|
||||
Assert.Equal(2, toolActivities.Length);
|
||||
Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-1" && activity.ToolName == "rg");
|
||||
Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-2" && activity.ToolName == "view");
|
||||
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
|
||||
Assert.Equal("msg-3", reclassified.MessageId);
|
||||
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? firstToolName));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
@@ -798,6 +799,216 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWorkflowEventAsync_FallsBackToActiveAgentForUnresolvedStreamingUpdates()
|
||||
{
|
||||
RunTurnCommandDto command = CreateHandoffCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
state.ObserveSessionEvent(
|
||||
CreateAgent("agent-handoff-ux", "UX Specialist"),
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "assistant.reasoning_delta",
|
||||
"data": {
|
||||
"reasoningId": "reasoning-1",
|
||||
"deltaContent": "Polishing the UI."
|
||||
},
|
||||
"id": "77777777-7777-7777-7777-777777777777",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
_ = state.DrainPendingEvents();
|
||||
List<TurnDeltaEventDto> deltas = [];
|
||||
|
||||
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
|
||||
"HandleWorkflowEventAsync",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
|
||||
null,
|
||||
[
|
||||
command,
|
||||
new AgentResponseUpdateEvent(
|
||||
"copilot-executor-ux",
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "The button is ready.")
|
||||
{
|
||||
MessageId = "msg-ux-1",
|
||||
}),
|
||||
Array.Empty<ChatMessage>(),
|
||||
state,
|
||||
(Func<TurnDeltaEventDto, Task>)(delta =>
|
||||
{
|
||||
deltas.Add(delta);
|
||||
return Task.CompletedTask;
|
||||
}),
|
||||
(Func<SidecarEventDto, Task>)(_ => Task.CompletedTask),
|
||||
])!;
|
||||
|
||||
bool shouldEndTurn = await handleTask;
|
||||
|
||||
Assert.False(shouldEndTurn);
|
||||
TurnDeltaEventDto delta = Assert.Single(deltas);
|
||||
Assert.Equal("msg-ux-1", delta.MessageId);
|
||||
Assert.Equal("UX Specialist", delta.AuthorName);
|
||||
Assert.Equal("The button is ready.", delta.ContentDelta);
|
||||
Assert.Equal("The button is ready.", delta.Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWorkflowEventAsync_EmitsWorkflowCheckpointSavedEvent()
|
||||
{
|
||||
RunTurnCommandDto command = CreateHandoffCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
List<WorkflowCheckpointSavedEventDto> checkpoints = [];
|
||||
|
||||
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
|
||||
"HandleWorkflowEventAsync",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
|
||||
null,
|
||||
[
|
||||
command,
|
||||
new SuperStepCompletedEvent(
|
||||
3,
|
||||
new SuperStepCompletionInfo([])
|
||||
{
|
||||
Checkpoint = new CheckpointInfo(command.RequestId, "checkpoint-1"),
|
||||
}),
|
||||
Array.Empty<ChatMessage>(),
|
||||
state,
|
||||
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
|
||||
(Func<SidecarEventDto, Task>)(sidecarEvent =>
|
||||
{
|
||||
checkpoints.Add(Assert.IsType<WorkflowCheckpointSavedEventDto>(sidecarEvent));
|
||||
return Task.CompletedTask;
|
||||
}),
|
||||
])!;
|
||||
|
||||
bool shouldEndTurn = await handleTask;
|
||||
|
||||
Assert.False(shouldEndTurn);
|
||||
WorkflowCheckpointSavedEventDto checkpoint = Assert.Single(checkpoints);
|
||||
Assert.Equal("workflow-checkpoint-saved", checkpoint.Type);
|
||||
Assert.Equal(command.SessionId, checkpoint.SessionId);
|
||||
Assert.Equal(command.RequestId, checkpoint.WorkflowSessionId);
|
||||
Assert.Equal("checkpoint-1", checkpoint.CheckpointId);
|
||||
Assert.Equal(3, checkpoint.StepNumber);
|
||||
Assert.EndsWith(
|
||||
Path.Combine("Aryx", "workflow-checkpoints", command.SessionId, command.RequestId),
|
||||
checkpoint.StorePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWorkflowEventAsync_EmitsExecutorFailedDiagnostic()
|
||||
{
|
||||
RunTurnCommandDto command = CreateApprovalCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
List<WorkflowDiagnosticEventDto> diagnostics = [];
|
||||
|
||||
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
|
||||
"HandleWorkflowEventAsync",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
|
||||
null,
|
||||
[
|
||||
command,
|
||||
new ExecutorFailedEvent("agent-1", new InvalidOperationException("Tool crashed.")),
|
||||
Array.Empty<ChatMessage>(),
|
||||
state,
|
||||
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
|
||||
(Func<SidecarEventDto, Task>)(sidecarEvent =>
|
||||
{
|
||||
diagnostics.Add(Assert.IsType<WorkflowDiagnosticEventDto>(sidecarEvent));
|
||||
return Task.CompletedTask;
|
||||
}),
|
||||
])!;
|
||||
|
||||
bool shouldEndTurn = await handleTask;
|
||||
|
||||
Assert.False(shouldEndTurn);
|
||||
WorkflowDiagnosticEventDto diagnostic = Assert.Single(diagnostics);
|
||||
Assert.Equal("workflow-diagnostic", diagnostic.Type);
|
||||
Assert.Equal("error", diagnostic.Severity);
|
||||
Assert.Equal("executor-failed", diagnostic.DiagnosticKind);
|
||||
Assert.Equal("Tool crashed.", diagnostic.Message);
|
||||
Assert.Equal("agent-1", diagnostic.AgentId);
|
||||
Assert.Equal("Primary", diagnostic.AgentName);
|
||||
Assert.Equal("agent-1", diagnostic.ExecutorId);
|
||||
Assert.Equal("InvalidOperationException", diagnostic.ExceptionType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWorkflowEventAsync_EmitsWorkflowWarningDiagnostic()
|
||||
{
|
||||
RunTurnCommandDto command = CreateApprovalCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
List<WorkflowDiagnosticEventDto> diagnostics = [];
|
||||
|
||||
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
|
||||
"HandleWorkflowEventAsync",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
|
||||
null,
|
||||
[
|
||||
command,
|
||||
new WorkflowWarningEvent("Token budget is nearly exhausted."),
|
||||
Array.Empty<ChatMessage>(),
|
||||
state,
|
||||
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
|
||||
(Func<SidecarEventDto, Task>)(sidecarEvent =>
|
||||
{
|
||||
diagnostics.Add(Assert.IsType<WorkflowDiagnosticEventDto>(sidecarEvent));
|
||||
return Task.CompletedTask;
|
||||
}),
|
||||
])!;
|
||||
|
||||
bool shouldEndTurn = await handleTask;
|
||||
|
||||
Assert.False(shouldEndTurn);
|
||||
WorkflowDiagnosticEventDto diagnostic = Assert.Single(diagnostics);
|
||||
Assert.Equal("warning", diagnostic.Severity);
|
||||
Assert.Equal("workflow-warning", diagnostic.DiagnosticKind);
|
||||
Assert.Equal("Token budget is nearly exhausted.", diagnostic.Message);
|
||||
Assert.Null(diagnostic.SubworkflowId);
|
||||
Assert.Null(diagnostic.ExceptionType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWorkflowEventAsync_EmitsSubworkflowErrorDiagnostic()
|
||||
{
|
||||
RunTurnCommandDto command = CreateApprovalCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
List<WorkflowDiagnosticEventDto> diagnostics = [];
|
||||
|
||||
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
|
||||
"HandleWorkflowEventAsync",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
|
||||
null,
|
||||
[
|
||||
command,
|
||||
new SubworkflowErrorEvent("subworkflow-review", new InvalidOperationException("Reviewer agent failed.")),
|
||||
Array.Empty<ChatMessage>(),
|
||||
state,
|
||||
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
|
||||
(Func<SidecarEventDto, Task>)(sidecarEvent =>
|
||||
{
|
||||
diagnostics.Add(Assert.IsType<WorkflowDiagnosticEventDto>(sidecarEvent));
|
||||
return Task.CompletedTask;
|
||||
}),
|
||||
])!;
|
||||
|
||||
bool shouldEndTurn = await handleTask;
|
||||
|
||||
Assert.False(shouldEndTurn);
|
||||
WorkflowDiagnosticEventDto diagnostic = Assert.Single(diagnostics);
|
||||
Assert.Equal("error", diagnostic.Severity);
|
||||
Assert.Equal("subworkflow-error", diagnostic.DiagnosticKind);
|
||||
Assert.Equal("Reviewer agent failed.", diagnostic.Message);
|
||||
Assert.Equal("subworkflow-review", diagnostic.SubworkflowId);
|
||||
Assert.Equal("InvalidOperationException", diagnostic.ExceptionType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiresToolCallApproval_HonorsAutoApprovedToolNames()
|
||||
{
|
||||
@@ -1325,6 +1536,29 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
Assert.Equal("https://example.com", args["url"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPermissionDetail_MapsConfiguredMcpHookToMcpDetail()
|
||||
{
|
||||
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
|
||||
new PermissionRequestHook
|
||||
{
|
||||
Kind = "hook",
|
||||
ToolName = "icm-mcp-get_incident_details_by_id",
|
||||
ToolArgs = new Dictionary<string, object?>
|
||||
{
|
||||
["incidentId"] = 769904783,
|
||||
},
|
||||
},
|
||||
[CreateMcpServerConfig("icm-mcp")]);
|
||||
|
||||
Assert.Equal("mcp", detail.Kind);
|
||||
Assert.Equal("icm-mcp", detail.ServerName);
|
||||
Assert.Equal("get_incident_details_by_id", detail.ToolTitle);
|
||||
|
||||
Dictionary<string, object?> args = Assert.IsType<Dictionary<string, object?>>(detail.Args);
|
||||
Assert.Equal(769904783, args["incidentId"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("view", "read")]
|
||||
[InlineData("glob", "read")]
|
||||
@@ -1362,6 +1596,16 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveHookMcpServerApprovalKey_PrefersLongestConfiguredServerName()
|
||||
{
|
||||
string? approvalKey = CopilotApprovalCoordinator.ResolveHookMcpServerApprovalKey(
|
||||
"icm-mcp-get_on_call_schedule",
|
||||
[CreateMcpServerConfig("icm"), CreateMcpServerConfig("icm-mcp")]);
|
||||
|
||||
Assert.Equal("mcp_server:icm-mcp", approvalKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetApprovalToolName_ResolvesHookToolToCategory()
|
||||
{
|
||||
@@ -1448,6 +1692,41 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
Assert.Contains("read permission", approvalEvent.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPermissionApprovalEvent_UsesMcpKindForConfiguredMcpHookTools()
|
||||
{
|
||||
ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent(
|
||||
new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Tooling = new RunTurnToolingConfigDto
|
||||
{
|
||||
McpServers = [CreateMcpServerConfig("icm-mcp")],
|
||||
},
|
||||
},
|
||||
CreateAgent("agent-1", "Primary"),
|
||||
new PermissionRequestHook
|
||||
{
|
||||
Kind = "hook",
|
||||
ToolName = "icm-mcp-get_schedule",
|
||||
ToolArgs = """{"teamIds":[91982]}""",
|
||||
},
|
||||
new PermissionInvocation
|
||||
{
|
||||
SessionId = "copilot-session-1",
|
||||
},
|
||||
"approval-1",
|
||||
"icm-mcp-get_schedule");
|
||||
|
||||
Assert.Equal("mcp", approvalEvent.PermissionKind);
|
||||
Assert.Contains("mcp permission", approvalEvent.Detail);
|
||||
Assert.NotNull(approvalEvent.PermissionDetail);
|
||||
Assert.Equal("mcp", approvalEvent.PermissionDetail!.Kind);
|
||||
Assert.Equal("icm-mcp", approvalEvent.PermissionDetail.ServerName);
|
||||
Assert.Equal("get_schedule", approvalEvent.PermissionDetail.ToolTitle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPermissionApprovalEvent_KeepsHookKindForUnknownHookTools()
|
||||
{
|
||||
@@ -1613,6 +1892,43 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
Assert.Equal(PermissionRequestResultKind.Approved, result.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestApprovalAsync_AutoApprovesHookRequestsForApprovedMcpServer()
|
||||
{
|
||||
CopilotApprovalCoordinator coordinator = new();
|
||||
bool sawApproval = false;
|
||||
RunTurnCommandDto command = CreateApprovalCommand(
|
||||
autoApprovedToolNames: ["mcp_server:icm-mcp"],
|
||||
mcpServers: [CreateMcpServerConfig("icm-mcp")]);
|
||||
|
||||
PermissionRequestResult result = await coordinator.RequestApprovalAsync(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
new PermissionRequestHook
|
||||
{
|
||||
Kind = "hook",
|
||||
ToolName = "icm-mcp-get_incident_details_by_id",
|
||||
ToolArgs = new Dictionary<string, object?>
|
||||
{
|
||||
["incidentId"] = 769904783,
|
||||
},
|
||||
},
|
||||
new PermissionInvocation
|
||||
{
|
||||
SessionId = "copilot-session-1",
|
||||
},
|
||||
new Dictionary<string, string>(StringComparer.Ordinal),
|
||||
approval =>
|
||||
{
|
||||
sawApproval = true;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(sawApproval);
|
||||
Assert.Equal(PermissionRequestResultKind.Approved, result.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestApprovalAsync_AlwaysApproveCachesRuntimeApprovalForCurrentTurn()
|
||||
{
|
||||
@@ -1853,12 +2169,21 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
null!);
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateApprovalCommand(string requestId = "turn-1")
|
||||
private static RunTurnCommandDto CreateApprovalCommand(
|
||||
string requestId = "turn-1",
|
||||
IReadOnlyList<string>? autoApprovedToolNames = null,
|
||||
IReadOnlyList<RunTurnMcpServerConfigDto>? mcpServers = null)
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = requestId,
|
||||
SessionId = "session-1",
|
||||
Tooling = mcpServers is null
|
||||
? null
|
||||
: new RunTurnToolingConfigDto
|
||||
{
|
||||
McpServers = [.. mcpServers],
|
||||
},
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
@@ -1875,7 +2200,9 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
AgentIds = ["agent-1"],
|
||||
},
|
||||
],
|
||||
AutoApprovedToolNames = ["web_fetch"],
|
||||
AutoApprovedToolNames = autoApprovedToolNames is null
|
||||
? ["web_fetch"]
|
||||
: [.. autoApprovedToolNames],
|
||||
},
|
||||
Agents =
|
||||
[
|
||||
@@ -1885,6 +2212,13 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
};
|
||||
}
|
||||
|
||||
private static RunTurnMcpServerConfigDto CreateMcpServerConfig(string serverName)
|
||||
=> new()
|
||||
{
|
||||
Id = serverName,
|
||||
Name = serverName,
|
||||
};
|
||||
|
||||
private sealed class StubChatClient : IChatClient
|
||||
{
|
||||
public Task<ChatResponse> GetResponseAsync(
|
||||
|
||||
@@ -11,8 +11,12 @@ public sealed class HandoffWorkflowGuidanceTests
|
||||
string instructions = HandoffWorkflowGuidance.CreateWorkflowInstructions();
|
||||
|
||||
Assert.Contains("explicit handoffs", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("routing or triage agent", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("best specialist", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Do not inspect files", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Do not claim that you delegated", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Do not narrate a handoff", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("own the substantive answer", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Specialists should complete the substantive work", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
|
||||
@@ -232,6 +232,77 @@ public sealed class SidecarProtocolHostTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunTurnCommand_ReturnsWorkflowDiagnosticEventsAndCompletion()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
await onActivity(new WorkflowDiagnosticEventDto
|
||||
{
|
||||
Type = "workflow-diagnostic",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
Severity = "error",
|
||||
DiagnosticKind = "executor-failed",
|
||||
Message = "Tool crashed.",
|
||||
AgentId = "agent-1",
|
||||
AgentName = "Primary",
|
||||
ExecutorId = "agent-1",
|
||||
ExceptionType = "InvalidOperationException",
|
||||
});
|
||||
|
||||
return [];
|
||||
}));
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new RunTurnCommandDto
|
||||
{
|
||||
Type = "run-turn",
|
||||
RequestId = "turn-diagnostic",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = "C:\\workspace\\project",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(name: "Primary"),
|
||||
],
|
||||
},
|
||||
Messages = [],
|
||||
},
|
||||
host);
|
||||
|
||||
Assert.Collection(
|
||||
events,
|
||||
diagnosticEvent =>
|
||||
{
|
||||
Assert.Equal("workflow-diagnostic", diagnosticEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("turn-diagnostic", diagnosticEvent.GetProperty("requestId").GetString());
|
||||
Assert.Equal("session-1", diagnosticEvent.GetProperty("sessionId").GetString());
|
||||
Assert.Equal("error", diagnosticEvent.GetProperty("severity").GetString());
|
||||
Assert.Equal("executor-failed", diagnosticEvent.GetProperty("diagnosticKind").GetString());
|
||||
Assert.Equal("Tool crashed.", diagnosticEvent.GetProperty("message").GetString());
|
||||
Assert.Equal("agent-1", diagnosticEvent.GetProperty("executorId").GetString());
|
||||
},
|
||||
completionEvent =>
|
||||
{
|
||||
Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("session-1", completionEvent.GetProperty("sessionId").GetString());
|
||||
Assert.False(completionEvent.GetProperty("cancelled").GetBoolean());
|
||||
},
|
||||
commandCompleteEvent =>
|
||||
{
|
||||
Assert.Equal("command-complete", commandCompleteEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("turn-diagnostic", commandCompleteEvent.GetProperty("requestId").GetString());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunTurnCommand_DeserializesInteractionMode()
|
||||
{
|
||||
|
||||
@@ -86,6 +86,26 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
||||
Assert.Empty(toolNamesByCallId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIds()
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal)
|
||||
{
|
||||
["call-1"] = "view",
|
||||
};
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>()));
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateSingleAgentCommand(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
toolNamesByCallId);
|
||||
|
||||
Assert.Null(activity);
|
||||
Assert.Equal("view", toolNamesByCallId["call-1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_ReturnsHandoffActivityForKnownTargets()
|
||||
{
|
||||
|
||||
+554
-19
@@ -12,10 +12,13 @@ import type {
|
||||
McpOauthRequiredEvent,
|
||||
MessageReclassifiedEvent,
|
||||
RunTurnCustomAgentConfig,
|
||||
RunTurnCommand,
|
||||
RunTurnToolingConfig,
|
||||
SidecarCapabilities,
|
||||
UserInputRequestedEvent,
|
||||
TurnDeltaEvent,
|
||||
WorkflowCheckpointResume,
|
||||
WorkflowCheckpointSavedEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { TurnScopedEvent } from '@main/sidecar/runTurnPending';
|
||||
import {
|
||||
@@ -62,7 +65,14 @@ import {
|
||||
type PendingApprovalMessageRecord,
|
||||
type PendingApprovalRecord,
|
||||
} from '@shared/domain/approval';
|
||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||
import {
|
||||
isScratchpadProject,
|
||||
type ProjectGitCommitMessageSuggestion,
|
||||
type ProjectGitDetails,
|
||||
type ProjectGitDiffPreview,
|
||||
type ProjectGitFileReference,
|
||||
type ProjectRecord,
|
||||
} from '@shared/domain/project';
|
||||
import {
|
||||
branchSessionRecord,
|
||||
duplicateSessionRecord,
|
||||
@@ -93,9 +103,11 @@ import {
|
||||
completeSessionRunRecord,
|
||||
createSessionRunRecord,
|
||||
failSessionRunRecord,
|
||||
setSessionRunGitSummary,
|
||||
upsertRunApprovalEvent,
|
||||
upsertRunMessageEvent,
|
||||
upsertSessionRunRecord,
|
||||
type RunTimelineEventRecord,
|
||||
type SessionRunRecord,
|
||||
} from '@shared/domain/runTimeline';
|
||||
import {
|
||||
@@ -130,6 +142,7 @@ import {
|
||||
SidecarClient,
|
||||
} from '@main/sidecar/sidecarProcess';
|
||||
import { TurnCancelledError } from '@main/sidecar/turnCancelledError';
|
||||
import { buildProjectGitCommitMessageSuggestion } from '@main/git/gitCommitMessageSuggestion';
|
||||
import { GitService } from '@main/git/gitService';
|
||||
import {
|
||||
buildRunTurnToolingConfig as buildSessionToolingConfig,
|
||||
@@ -161,6 +174,15 @@ type PendingUserInputHandle = {
|
||||
resolve: (answer: string, wasFreeform: boolean) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type WorkflowCheckpointRecoveryState = {
|
||||
workflowSessionId: string;
|
||||
checkpointId: string;
|
||||
storePath: string;
|
||||
stepNumber: number;
|
||||
sessionMessages: ChatMessageRecord[];
|
||||
runEvents: RunTimelineEventRecord[];
|
||||
};
|
||||
|
||||
type DiscoveredToolingResolution = 'accept' | 'dismiss';
|
||||
|
||||
function isBuiltinPattern(patternId: string): boolean {
|
||||
@@ -181,10 +203,22 @@ function isSidecarStoppedBeforeCompletionError(error: unknown): error is Error {
|
||||
return error instanceof Error && error.message === SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE;
|
||||
}
|
||||
|
||||
function isUnexpectedSidecarTerminationError(error: unknown): error is Error {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { message } = error;
|
||||
return isSidecarStoppedBeforeCompletionError(error)
|
||||
|| message.startsWith('The .NET sidecar exited unexpectedly with code ');
|
||||
}
|
||||
|
||||
const INTERRUPTED_RUN_ERROR =
|
||||
'This session was interrupted because Aryx restarted while a run was in progress.';
|
||||
const INTERRUPTED_APPROVAL_ERROR =
|
||||
'Pending approval was interrupted because Aryx restarted before a decision was recorded.';
|
||||
const GIT_REFRESH_DEBOUNCE_MS = 750;
|
||||
const GIT_REFRESH_INTERVAL_MS = 60_000;
|
||||
|
||||
export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
private readonly workspaceRepository = new WorkspaceRepository();
|
||||
@@ -197,11 +231,18 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
private readonly ptyManager = new PtyManager();
|
||||
private readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>();
|
||||
private readonly pendingUserInputHandles = new Map<string, PendingUserInputHandle>();
|
||||
private readonly workflowCheckpointRecoveries = new Map<string, WorkflowCheckpointRecoveryState>();
|
||||
private workspace?: WorkspaceState;
|
||||
private sidecarCapabilities?: SidecarCapabilities;
|
||||
private sidecarCapabilitiesPromise?: Promise<SidecarCapabilities>;
|
||||
private didScheduleInitialProjectGitRefresh = false;
|
||||
private didStartPeriodicProjectGitRefresh = false;
|
||||
private mcpProbeUpdateQueue = Promise.resolve();
|
||||
private pendingProjectGitRefreshIds = new Set<string>();
|
||||
private pendingRefreshAllProjects = false;
|
||||
private projectGitRefreshTimer?: ReturnType<typeof setTimeout>;
|
||||
private periodicProjectGitRefreshTimer?: ReturnType<typeof setInterval>;
|
||||
private runningProjectGitRefresh?: Promise<void>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -252,6 +293,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
|
||||
if (!this.didScheduleInitialProjectGitRefresh) {
|
||||
this.didScheduleInitialProjectGitRefresh = true;
|
||||
if (this.workspace.settings.gitAutoRefreshEnabled !== false) {
|
||||
this.startPeriodicProjectGitRefresh();
|
||||
}
|
||||
void this.refreshProjectGitContext().catch((error) => {
|
||||
console.error('[aryx git]', error);
|
||||
});
|
||||
@@ -270,11 +314,42 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.projectGitRefreshTimer) {
|
||||
clearTimeout(this.projectGitRefreshTimer);
|
||||
this.projectGitRefreshTimer = undefined;
|
||||
}
|
||||
if (this.periodicProjectGitRefreshTimer) {
|
||||
clearInterval(this.periodicProjectGitRefreshTimer);
|
||||
this.periodicProjectGitRefreshTimer = undefined;
|
||||
}
|
||||
this.ptyManager.dispose();
|
||||
await this.sidecar.dispose();
|
||||
void this.secretStore;
|
||||
}
|
||||
|
||||
isGitAutoRefreshEnabled(): boolean {
|
||||
return this.workspace?.settings.gitAutoRefreshEnabled !== false;
|
||||
}
|
||||
|
||||
scheduleProjectGitRefresh(projectId?: string): void {
|
||||
if (projectId) {
|
||||
this.pendingProjectGitRefreshIds.add(projectId);
|
||||
} else {
|
||||
this.pendingRefreshAllProjects = true;
|
||||
this.pendingProjectGitRefreshIds.clear();
|
||||
}
|
||||
|
||||
if (this.projectGitRefreshTimer) {
|
||||
clearTimeout(this.projectGitRefreshTimer);
|
||||
}
|
||||
|
||||
this.projectGitRefreshTimer = setTimeout(() => {
|
||||
this.projectGitRefreshTimer = undefined;
|
||||
void this.flushScheduledProjectGitRefresh();
|
||||
}, GIT_REFRESH_DEBOUNCE_MS);
|
||||
this.projectGitRefreshTimer.unref?.();
|
||||
}
|
||||
|
||||
async openAppDataFolder(): Promise<void> {
|
||||
const appDataPath = dirname(this.workspaceRepository.filePath);
|
||||
await shell.openPath(appDataPath);
|
||||
@@ -527,6 +602,19 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async setGitAutoRefreshEnabled(enabled: boolean): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
workspace.settings.gitAutoRefreshEnabled = enabled;
|
||||
|
||||
if (enabled) {
|
||||
this.startPeriodicProjectGitRefresh();
|
||||
} else {
|
||||
this.stopPeriodicProjectGitRefresh();
|
||||
}
|
||||
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async describeTerminal(): Promise<TerminalSnapshot | undefined> {
|
||||
return this.ptyManager.getSnapshot();
|
||||
}
|
||||
@@ -1302,6 +1390,16 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
): Promise<void> {
|
||||
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
|
||||
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
|
||||
const runWorkingDirectory = session.cwd ?? project.path;
|
||||
const preRunGitSnapshot = workspaceKind === 'project'
|
||||
? await this.gitService.captureWorkingTreeSnapshot(runWorkingDirectory, occurredAt)
|
||||
: undefined;
|
||||
const preRunGitBaselineFiles = workspaceKind === 'project' && preRunGitSnapshot
|
||||
? await this.gitService.captureWorkingTreeBaseline(runWorkingDirectory, preRunGitSnapshot)
|
||||
: undefined;
|
||||
if (workspaceKind === 'project' && project.git?.status === 'ready' && !preRunGitSnapshot) {
|
||||
console.warn(`[aryx git] Failed to capture pre-run git snapshot for project "${project.id}".`);
|
||||
}
|
||||
|
||||
session.title = resolveSessionTitle(session, effectivePattern, session.messages);
|
||||
session.status = 'running';
|
||||
@@ -1313,10 +1411,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
createSessionRunRecord({
|
||||
requestId,
|
||||
project,
|
||||
workingDirectory: runWorkingDirectory,
|
||||
workspaceKind,
|
||||
pattern: effectivePattern,
|
||||
triggerMessageId,
|
||||
startedAt: occurredAt,
|
||||
preRunGitSnapshot,
|
||||
preRunGitBaselineFiles,
|
||||
}),
|
||||
...session.runs,
|
||||
];
|
||||
@@ -1330,21 +1431,29 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
});
|
||||
|
||||
try {
|
||||
const responseMessages = await this.sidecar.runTurn(
|
||||
{
|
||||
type: 'run-turn',
|
||||
requestId,
|
||||
sessionId: session.id,
|
||||
projectPath: session.cwd ?? project.path,
|
||||
workspaceKind,
|
||||
mode: session.interactionMode ?? 'interactive',
|
||||
messageMode,
|
||||
projectInstructions,
|
||||
pattern: effectivePattern,
|
||||
messages: session.messages,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
tooling: this.buildRunTurnToolingConfig(workspace, session),
|
||||
},
|
||||
const createRunTurnCommand = (
|
||||
resumeFromCheckpoint?: WorkflowCheckpointResume,
|
||||
): RunTurnCommand => ({
|
||||
type: 'run-turn',
|
||||
requestId,
|
||||
sessionId: session.id,
|
||||
projectPath: runWorkingDirectory,
|
||||
workspaceKind,
|
||||
mode: session.interactionMode ?? 'interactive',
|
||||
messageMode,
|
||||
projectInstructions,
|
||||
pattern: effectivePattern,
|
||||
messages: session.messages,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
tooling: this.buildRunTurnToolingConfig(workspace, session),
|
||||
resumeFromCheckpoint,
|
||||
});
|
||||
|
||||
const responseMessages = await this.runSidecarTurnWithCheckpointRecovery(
|
||||
workspace,
|
||||
session,
|
||||
requestId,
|
||||
createRunTurnCommand,
|
||||
async (event) => {
|
||||
await this.applyTurnDelta(workspace, session.id, requestId, event);
|
||||
},
|
||||
@@ -1375,11 +1484,31 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
|
||||
await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages);
|
||||
this.finalizeTurn(workspace, session.id, requestId, responseMessages);
|
||||
if (workspaceKind === 'project') {
|
||||
const completedRun = await this.refreshSessionRunGitSummary(session, project, requestId, nowIso());
|
||||
if (completedRun) {
|
||||
this.emitRunUpdated(session.id, nowIso(), completedRun);
|
||||
}
|
||||
}
|
||||
await this.persistAndBroadcast(workspace);
|
||||
await this.cleanupWorkflowCheckpointRecovery(requestId);
|
||||
if (workspaceKind === 'project') {
|
||||
this.scheduleProjectGitRefresh(project.id);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TurnCancelledError) {
|
||||
this.finalizeCancelledTurn(workspace, session, requestId);
|
||||
if (workspaceKind === 'project') {
|
||||
const cancelledRun = await this.refreshSessionRunGitSummary(session, project, requestId, nowIso());
|
||||
if (cancelledRun) {
|
||||
this.emitRunUpdated(session.id, nowIso(), cancelledRun);
|
||||
}
|
||||
}
|
||||
await this.persistAndBroadcast(workspace);
|
||||
await this.cleanupWorkflowCheckpointRecovery(requestId);
|
||||
if (workspaceKind === 'project') {
|
||||
this.scheduleProjectGitRefresh(project.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1401,7 +1530,18 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
this.emitRunUpdated(session.id, failedAt, failedRun);
|
||||
}
|
||||
|
||||
if (workspaceKind === 'project') {
|
||||
const summarizedRun = await this.refreshSessionRunGitSummary(session, project, requestId, failedAt);
|
||||
if (summarizedRun) {
|
||||
this.emitRunUpdated(session.id, failedAt, summarizedRun);
|
||||
}
|
||||
}
|
||||
|
||||
await this.persistAndBroadcast(workspace);
|
||||
await this.cleanupWorkflowCheckpointRecovery(requestId);
|
||||
if (workspaceKind === 'project') {
|
||||
this.scheduleProjectGitRefresh(project.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1486,9 +1626,164 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
|
||||
async refreshProjectGitContext(projectId?: string): Promise<WorkspaceState> {
|
||||
return this.refreshProjectGitContexts(projectId ? [projectId] : undefined);
|
||||
}
|
||||
|
||||
async getProjectGitDetails(projectId: string, commitLimit = 20): Promise<ProjectGitDetails> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const projects = projectId
|
||||
? [this.requireProject(workspace, projectId)]
|
||||
const project = this.requireProject(workspace, projectId);
|
||||
return this.gitService.describeProjectGitDetails(project.path, nowIso(), commitLimit);
|
||||
}
|
||||
|
||||
async getProjectGitFilePreview(
|
||||
projectId: string,
|
||||
file: ProjectGitFileReference,
|
||||
): Promise<ProjectGitDiffPreview | undefined> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const project = this.requireProject(workspace, projectId);
|
||||
return this.gitService.getWorkingTreeFilePreview(project.path, file);
|
||||
}
|
||||
|
||||
async discardSessionRunGitChanges(
|
||||
sessionId: string,
|
||||
runId: string,
|
||||
files?: ProjectGitFileReference[],
|
||||
): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
const run = this.requireSessionRun(session, runId);
|
||||
if (run.workspaceKind !== 'project') {
|
||||
throw new Error('Run change review is only available for project-backed sessions.');
|
||||
}
|
||||
|
||||
if (!run.postRunGitSummary) {
|
||||
throw new Error('This run does not have any tracked git changes to discard.');
|
||||
}
|
||||
|
||||
await this.gitService.discardRunChanges(
|
||||
this.resolveRunWorkingDirectory(session, project, run),
|
||||
{
|
||||
summary: run.postRunGitSummary,
|
||||
preRunBaselineFiles: run.preRunGitBaselineFiles,
|
||||
files,
|
||||
},
|
||||
);
|
||||
|
||||
await this.refreshProjectGitContexts([project.id]);
|
||||
const refreshedWorkspace = await this.loadWorkspace();
|
||||
const refreshedSession = this.requireSession(refreshedWorkspace, sessionId);
|
||||
const refreshedProject = this.requireProject(refreshedWorkspace, refreshedSession.projectId);
|
||||
const nextRun = await this.refreshSessionRunGitSummary(
|
||||
refreshedSession,
|
||||
refreshedProject,
|
||||
run.requestId,
|
||||
nowIso(),
|
||||
);
|
||||
if (nextRun) {
|
||||
this.emitRunUpdated(refreshedSession.id, nowIso(), nextRun);
|
||||
}
|
||||
|
||||
return this.persistAndBroadcast(refreshedWorkspace);
|
||||
}
|
||||
|
||||
async stageProjectGitFiles(projectId: string, files: ProjectGitFileReference[]): Promise<WorkspaceState> {
|
||||
return this.runProjectGitMutation(projectId, async (project) => {
|
||||
await this.gitService.stageFiles(project.path, files);
|
||||
});
|
||||
}
|
||||
|
||||
async unstageProjectGitFiles(projectId: string, files: ProjectGitFileReference[]): Promise<WorkspaceState> {
|
||||
return this.runProjectGitMutation(projectId, async (project) => {
|
||||
await this.gitService.unstageFiles(project.path, files);
|
||||
});
|
||||
}
|
||||
|
||||
async suggestProjectGitCommitMessage(
|
||||
sessionId: string,
|
||||
runId?: string,
|
||||
conventionalType?: ProjectGitCommitMessageSuggestion['type'],
|
||||
): Promise<ProjectGitCommitMessageSuggestion> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const run = runId
|
||||
? this.requireSessionRun(session, runId)
|
||||
: session.runs[0];
|
||||
if (!run) {
|
||||
throw new Error('This session does not have a run to summarize into a commit message.');
|
||||
}
|
||||
|
||||
return buildProjectGitCommitMessageSuggestion({
|
||||
session,
|
||||
run,
|
||||
summary: run.postRunGitSummary,
|
||||
conventionalType,
|
||||
});
|
||||
}
|
||||
|
||||
async commitProjectGitChanges(
|
||||
projectId: string,
|
||||
message: string,
|
||||
files?: ProjectGitFileReference[],
|
||||
push = false,
|
||||
): Promise<WorkspaceState> {
|
||||
return this.runProjectGitMutation(projectId, async (project) => {
|
||||
if (files && files.length > 0) {
|
||||
await this.gitService.stageFiles(project.path, files);
|
||||
}
|
||||
|
||||
await this.gitService.commit(project.path, message);
|
||||
if (push) {
|
||||
await this.gitService.push(project.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async pushProjectGit(projectId: string): Promise<WorkspaceState> {
|
||||
return this.runProjectGitMutation(projectId, async (project) => {
|
||||
await this.gitService.push(project.path);
|
||||
});
|
||||
}
|
||||
|
||||
async fetchProjectGit(projectId: string): Promise<WorkspaceState> {
|
||||
return this.runProjectGitMutation(projectId, async (project) => {
|
||||
await this.gitService.fetch(project.path);
|
||||
});
|
||||
}
|
||||
|
||||
async pullProjectGit(projectId: string, rebase = false): Promise<WorkspaceState> {
|
||||
return this.runProjectGitMutation(projectId, async (project) => {
|
||||
await this.gitService.pull(project.path, rebase);
|
||||
});
|
||||
}
|
||||
|
||||
async createProjectGitBranch(
|
||||
projectId: string,
|
||||
name: string,
|
||||
startPoint?: string,
|
||||
checkout = true,
|
||||
): Promise<WorkspaceState> {
|
||||
return this.runProjectGitMutation(projectId, async (project) => {
|
||||
await this.gitService.createBranch(project.path, name, startPoint, checkout);
|
||||
});
|
||||
}
|
||||
|
||||
async switchProjectGitBranch(projectId: string, name: string): Promise<WorkspaceState> {
|
||||
return this.runProjectGitMutation(projectId, async (project) => {
|
||||
await this.gitService.switchBranch(project.path, name);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProjectGitBranch(projectId: string, name: string, force = false): Promise<WorkspaceState> {
|
||||
return this.runProjectGitMutation(projectId, async (project) => {
|
||||
await this.gitService.deleteBranch(project.path, name, force);
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshProjectGitContexts(projectIds?: readonly string[]): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const projects = projectIds?.length
|
||||
? projectIds.map((currentProjectId) => this.requireProject(workspace, currentProjectId))
|
||||
: workspace.projects;
|
||||
|
||||
let didRefreshGit = false;
|
||||
@@ -1570,6 +1865,61 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return project;
|
||||
}
|
||||
|
||||
private requireSessionRun(session: SessionRecord, runId: string): SessionRunRecord {
|
||||
const run = session.runs.find((candidate) => candidate.id === runId);
|
||||
if (!run) {
|
||||
throw new Error(`Run "${runId}" was not found for session "${session.id}".`);
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
private resolveRunWorkingDirectory(
|
||||
session: SessionRecord,
|
||||
project: ProjectRecord,
|
||||
run: SessionRunRecord,
|
||||
): string {
|
||||
return run.workingDirectory ?? session.cwd ?? run.projectPath ?? project.path;
|
||||
}
|
||||
|
||||
private async refreshSessionRunGitSummary(
|
||||
session: SessionRecord,
|
||||
project: ProjectRecord,
|
||||
requestId: string,
|
||||
occurredAt: string,
|
||||
): Promise<SessionRunRecord | undefined> {
|
||||
const run = session.runs.find((candidate) => candidate.requestId === requestId);
|
||||
if (!run || run.workspaceKind !== 'project' || !run.preRunGitSnapshot) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const summary = await this.gitService.computeRunChangeSummary(
|
||||
this.resolveRunWorkingDirectory(session, project, run),
|
||||
{
|
||||
generatedAt: occurredAt,
|
||||
preRunSnapshot: run.preRunGitSnapshot,
|
||||
preRunBaselineFiles: run.preRunGitBaselineFiles,
|
||||
},
|
||||
);
|
||||
|
||||
return this.updateSessionRun(session, requestId, (currentRun) =>
|
||||
setSessionRunGitSummary(currentRun, summary));
|
||||
}
|
||||
|
||||
private async runProjectGitMutation(
|
||||
projectId: string,
|
||||
mutation: (project: ProjectRecord) => Promise<void>,
|
||||
): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const project = this.requireProject(workspace, projectId);
|
||||
if (isScratchpadProject(project)) {
|
||||
throw new Error('Git operations are not available for the Scratchpad project.');
|
||||
}
|
||||
|
||||
await mutation(project);
|
||||
return this.refreshProjectGitContexts([project.id]);
|
||||
}
|
||||
|
||||
private resolveTerminalWorkingDirectory(workspace: WorkspaceState): string {
|
||||
const selectedSession = workspace.selectedSessionId
|
||||
? workspace.sessions.find((session) => session.id === workspace.selectedSessionId)
|
||||
@@ -1603,6 +1953,54 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return true;
|
||||
}
|
||||
|
||||
private startPeriodicProjectGitRefresh(): void {
|
||||
if (this.didStartPeriodicProjectGitRefresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.didStartPeriodicProjectGitRefresh = true;
|
||||
this.periodicProjectGitRefreshTimer = setInterval(() => {
|
||||
this.scheduleProjectGitRefresh();
|
||||
}, GIT_REFRESH_INTERVAL_MS);
|
||||
this.periodicProjectGitRefreshTimer.unref?.();
|
||||
}
|
||||
|
||||
private stopPeriodicProjectGitRefresh(): void {
|
||||
if (this.periodicProjectGitRefreshTimer) {
|
||||
clearInterval(this.periodicProjectGitRefreshTimer);
|
||||
this.periodicProjectGitRefreshTimer = undefined;
|
||||
}
|
||||
this.didStartPeriodicProjectGitRefresh = false;
|
||||
}
|
||||
|
||||
private async flushScheduledProjectGitRefresh(): Promise<void> {
|
||||
if (this.runningProjectGitRefresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectIds = this.pendingRefreshAllProjects
|
||||
? undefined
|
||||
: [...this.pendingProjectGitRefreshIds];
|
||||
this.pendingRefreshAllProjects = false;
|
||||
this.pendingProjectGitRefreshIds.clear();
|
||||
|
||||
this.runningProjectGitRefresh = this.refreshProjectGitContexts(projectIds).then(
|
||||
() => undefined,
|
||||
(error) => {
|
||||
console.error('[aryx git]', error);
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await this.runningProjectGitRefresh;
|
||||
} finally {
|
||||
this.runningProjectGitRefresh = undefined;
|
||||
if (this.pendingRefreshAllProjects || this.pendingProjectGitRefreshIds.size > 0) {
|
||||
this.scheduleProjectGitRefresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private requirePattern(workspace: WorkspaceState, patternId: string): PatternDefinition {
|
||||
const pattern = workspace.patterns.find((current) => current.id === patternId);
|
||||
if (!pattern) {
|
||||
@@ -2058,13 +2456,22 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
|
||||
private handleTurnScopedEvent(
|
||||
_workspace: WorkspaceState,
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
event: TurnScopedEvent,
|
||||
): void {
|
||||
const occurredAt = nowIso();
|
||||
|
||||
switch (event.type) {
|
||||
case 'workflow-checkpoint-saved': {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const run = session.runs.find((candidate) => candidate.requestId === event.requestId);
|
||||
if (run) {
|
||||
this.recordWorkflowCheckpointRecovery(session, run, event);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
case 'subagent-event':
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
@@ -2141,6 +2548,21 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
agentName: event.agentName,
|
||||
});
|
||||
return;
|
||||
case 'workflow-diagnostic':
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'workflow-diagnostic',
|
||||
occurredAt,
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
diagnosticSeverity: event.severity,
|
||||
diagnosticKind: event.diagnosticKind,
|
||||
diagnosticMessage: event.message,
|
||||
executorId: event.executorId,
|
||||
subworkflowId: event.subworkflowId,
|
||||
exceptionType: event.exceptionType,
|
||||
});
|
||||
return;
|
||||
case 'assistant-usage':
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
@@ -2162,6 +2584,119 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
private async runSidecarTurnWithCheckpointRecovery(
|
||||
workspace: WorkspaceState,
|
||||
session: SessionRecord,
|
||||
requestId: string,
|
||||
createCommand: (resumeFromCheckpoint?: WorkflowCheckpointResume) => RunTurnCommand,
|
||||
onDelta: (event: TurnDeltaEvent) => void | Promise<void>,
|
||||
onActivity: (event: AgentActivityEvent) => void | Promise<void>,
|
||||
onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>,
|
||||
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[]> {
|
||||
const invokeTurn = (resumeFromCheckpoint?: WorkflowCheckpointResume) => this.sidecar.runTurn(
|
||||
createCommand(resumeFromCheckpoint),
|
||||
onDelta,
|
||||
onActivity,
|
||||
onApproval,
|
||||
onUserInput,
|
||||
onMcpOAuthRequired,
|
||||
onExitPlanMode,
|
||||
onMessageReclassified,
|
||||
onTurnScopedEvent,
|
||||
);
|
||||
|
||||
try {
|
||||
return await invokeTurn();
|
||||
} catch (error) {
|
||||
const recovery = this.workflowCheckpointRecoveries.get(requestId);
|
||||
if (!isUnexpectedSidecarTerminationError(error) || !recovery) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const restoredRun = this.restoreWorkflowCheckpointRecovery(session, requestId, recovery);
|
||||
await this.persistAndBroadcast(workspace);
|
||||
if (restoredRun) {
|
||||
this.emitRunUpdated(session.id, session.updatedAt, restoredRun);
|
||||
}
|
||||
|
||||
return invokeTurn({
|
||||
workflowSessionId: recovery.workflowSessionId,
|
||||
checkpointId: recovery.checkpointId,
|
||||
storePath: recovery.storePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private recordWorkflowCheckpointRecovery(
|
||||
session: SessionRecord,
|
||||
run: SessionRunRecord,
|
||||
event: WorkflowCheckpointSavedEvent,
|
||||
): void {
|
||||
this.workflowCheckpointRecoveries.set(event.requestId, {
|
||||
workflowSessionId: event.workflowSessionId,
|
||||
checkpointId: event.checkpointId,
|
||||
storePath: event.storePath,
|
||||
stepNumber: event.stepNumber,
|
||||
sessionMessages: structuredClone(session.messages),
|
||||
runEvents: structuredClone(run.events),
|
||||
});
|
||||
}
|
||||
|
||||
private restoreWorkflowCheckpointRecovery(
|
||||
session: SessionRecord,
|
||||
requestId: string,
|
||||
recovery: WorkflowCheckpointRecoveryState,
|
||||
): SessionRunRecord | undefined {
|
||||
session.messages = structuredClone(recovery.sessionMessages);
|
||||
session.status = 'running';
|
||||
session.lastError = undefined;
|
||||
session.updatedAt = nowIso();
|
||||
this.clearPendingRunState(session, requestId);
|
||||
|
||||
return this.updateSessionRun(session, requestId, (run) => ({
|
||||
...run,
|
||||
events: structuredClone(recovery.runEvents),
|
||||
}));
|
||||
}
|
||||
|
||||
private clearPendingRunState(session: SessionRecord, requestId: string): void {
|
||||
this.setSessionPendingApprovalState(session, {});
|
||||
session.pendingUserInput = undefined;
|
||||
session.pendingPlanReview = undefined;
|
||||
session.pendingMcpAuth = undefined;
|
||||
|
||||
for (const [approvalId, handle] of this.pendingApprovalHandles.entries()) {
|
||||
if (handle.sessionId === session.id && handle.requestId === requestId) {
|
||||
this.pendingApprovalHandles.delete(approvalId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [userInputId, handle] of this.pendingUserInputHandles.entries()) {
|
||||
if (handle.sessionId === session.id && handle.requestId === requestId) {
|
||||
this.pendingUserInputHandles.delete(userInputId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupWorkflowCheckpointRecovery(requestId: string): Promise<void> {
|
||||
const recovery = this.workflowCheckpointRecoveries.get(requestId);
|
||||
this.workflowCheckpointRecoveries.delete(requestId);
|
||||
if (!recovery) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(recovery.storePath, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.warn('[aryx workflow-checkpoint] Failed to clean checkpoint store:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord {
|
||||
return {
|
||||
id: event.approvalId,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { basename } from 'node:path';
|
||||
|
||||
import type {
|
||||
ProjectGitCommitMessageSuggestion,
|
||||
ProjectGitConventionalCommitType,
|
||||
ProjectGitRunChangeSummary,
|
||||
} from '@shared/domain/project';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
||||
|
||||
interface BuildCommitMessageSuggestionInput {
|
||||
session: Pick<SessionRecord, 'messages' | 'title'>;
|
||||
run: Pick<SessionRunRecord, 'triggerMessageId'>;
|
||||
summary?: ProjectGitRunChangeSummary;
|
||||
conventionalType?: ProjectGitConventionalCommitType;
|
||||
}
|
||||
|
||||
const COMMIT_TYPES: readonly ProjectGitConventionalCommitType[] = [
|
||||
'feat',
|
||||
'fix',
|
||||
'refactor',
|
||||
'docs',
|
||||
'test',
|
||||
'chore',
|
||||
];
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function isCommitType(value: string | undefined): value is ProjectGitConventionalCommitType {
|
||||
return value !== undefined && COMMIT_TYPES.includes(value as ProjectGitConventionalCommitType);
|
||||
}
|
||||
|
||||
function findTriggerMessageContent(
|
||||
session: Pick<SessionRecord, 'messages'>,
|
||||
triggerMessageId: string,
|
||||
): string | undefined {
|
||||
return session.messages.find((message) => message.id === triggerMessageId)?.content;
|
||||
}
|
||||
|
||||
function inferCommitTypeFromSummary(
|
||||
prompt: string | undefined,
|
||||
summary: ProjectGitRunChangeSummary | undefined,
|
||||
): ProjectGitConventionalCommitType {
|
||||
const promptText = normalizeWhitespace(prompt?.toLowerCase() ?? '');
|
||||
const files = summary?.files ?? [];
|
||||
const filePaths = files.map((file) => file.path.toLowerCase());
|
||||
if (filePaths.length > 0 && filePaths.every((path) => path.endsWith('.md') || path.includes('readme'))) {
|
||||
return 'docs';
|
||||
}
|
||||
|
||||
if (filePaths.length > 0 && filePaths.every((path) => path.includes('test') || path.endsWith('.snap'))) {
|
||||
return 'test';
|
||||
}
|
||||
|
||||
if (/\b(fix|bug|error|issue|regression|broken|failure)\b/.test(promptText)) {
|
||||
return 'fix';
|
||||
}
|
||||
|
||||
if (/\b(refactor|cleanup|restructure|rename|simplify)\b/.test(promptText)) {
|
||||
return 'refactor';
|
||||
}
|
||||
|
||||
if (/\b(doc|readme|documentation)\b/.test(promptText)) {
|
||||
return 'docs';
|
||||
}
|
||||
|
||||
if (/\b(test|coverage|assertion)\b/.test(promptText)) {
|
||||
return 'test';
|
||||
}
|
||||
|
||||
if (/\b(chore|config|build|deps|dependency|tooling)\b/.test(promptText)) {
|
||||
return 'chore';
|
||||
}
|
||||
|
||||
return 'feat';
|
||||
}
|
||||
|
||||
function stripPromptLead(text: string): string {
|
||||
return text
|
||||
.replace(/^[`"'“”‘’]+|[`"'“”‘’]+$/g, '')
|
||||
.replace(/^(please\s+)?(can|could|would)\s+you\s+/i, '')
|
||||
.replace(/^(implement|add|create|build|make|update|improve|refactor|fix|support|handle)\s+/i, '')
|
||||
.replace(/[.?!:;]+$/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function summarizeFiles(summary: ProjectGitRunChangeSummary | undefined): string | undefined {
|
||||
const firstFile = summary?.files[0];
|
||||
if (!summary || summary.files.length === 0 || !firstFile) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (summary.files.length === 1) {
|
||||
return basename(firstFile.path).replace(/\.[^.]+$/, '');
|
||||
}
|
||||
|
||||
return `${summary.fileCount} files`;
|
||||
}
|
||||
|
||||
function buildSubject(
|
||||
prompt: string | undefined,
|
||||
summary: ProjectGitRunChangeSummary | undefined,
|
||||
): string {
|
||||
const normalizedPrompt = normalizeWhitespace(prompt ?? '');
|
||||
if (normalizedPrompt) {
|
||||
const firstSentence = normalizedPrompt.split(/[\r\n.?!]/, 1)[0] ?? normalizedPrompt;
|
||||
const stripped = stripPromptLead(firstSentence);
|
||||
if (stripped) {
|
||||
return stripped
|
||||
.replace(/\b(the|a|an)\s+/gi, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = summarizeFiles(summary);
|
||||
if (fallback) {
|
||||
return `update ${fallback}`.toLowerCase();
|
||||
}
|
||||
|
||||
return 'update project changes';
|
||||
}
|
||||
|
||||
export function buildProjectGitCommitMessageSuggestion(
|
||||
input: BuildCommitMessageSuggestionInput,
|
||||
): ProjectGitCommitMessageSuggestion {
|
||||
const prompt = findTriggerMessageContent(input.session, input.run.triggerMessageId);
|
||||
const type = isCommitType(input.conventionalType)
|
||||
? input.conventionalType
|
||||
: inferCommitTypeFromSummary(prompt, input.summary);
|
||||
const subject = buildSubject(prompt, input.summary);
|
||||
|
||||
return {
|
||||
type,
|
||||
subject,
|
||||
message: `${type}: ${subject}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { isUtf8 } from 'node:buffer';
|
||||
|
||||
import type {
|
||||
ProjectGitBaselineFile,
|
||||
ProjectGitDiffPreview,
|
||||
ProjectGitRunChangeCounts,
|
||||
ProjectGitRunChangeKind,
|
||||
ProjectGitRunChangeSummary,
|
||||
ProjectGitRunChangedFile,
|
||||
ProjectGitWorkingTreeFile,
|
||||
ProjectGitWorkingTreeSnapshot,
|
||||
} from '@shared/domain/project';
|
||||
|
||||
interface DiffStats {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
interface BuildProjectGitRunChangeSummaryInput {
|
||||
generatedAt: string;
|
||||
preRunSnapshot?: ProjectGitWorkingTreeSnapshot;
|
||||
preRunBaselineFiles?: readonly ProjectGitBaselineFile[];
|
||||
postRunSnapshot?: ProjectGitWorkingTreeSnapshot;
|
||||
postRunBaselineFiles?: readonly ProjectGitBaselineFile[];
|
||||
}
|
||||
|
||||
function parseDiffStats(diff: string | undefined): DiffStats {
|
||||
if (!diff) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
for (const line of diff.split('\n')) {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
additions += 1;
|
||||
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
deletions += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { additions, deletions };
|
||||
}
|
||||
|
||||
function isBinaryDiff(diff: string | undefined): boolean {
|
||||
if (!diff) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return diff.includes('GIT binary patch') || diff.includes('Binary files ');
|
||||
}
|
||||
|
||||
function decodeUtf8FromBase64(value: string | undefined): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(value, 'base64');
|
||||
return isUtf8(buffer) ? buffer.toString('utf8') : undefined;
|
||||
}
|
||||
|
||||
function canRestoreBaseline(
|
||||
baseline: ProjectGitBaselineFile | undefined,
|
||||
): baseline is ProjectGitBaselineFile {
|
||||
return baseline !== undefined
|
||||
&& (baseline.untrackedContentBase64 !== undefined || baseline.combinedDiff !== undefined);
|
||||
}
|
||||
|
||||
function previewFromBaselineFile(
|
||||
file: Pick<ProjectGitWorkingTreeFile, 'path'>,
|
||||
baseline: ProjectGitBaselineFile | undefined,
|
||||
): ProjectGitDiffPreview | undefined {
|
||||
if (!baseline) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (baseline.untrackedContentBase64 !== undefined) {
|
||||
return {
|
||||
path: file.path,
|
||||
previousPath: baseline.previousPath,
|
||||
newFileContents: decodeUtf8FromBase64(baseline.untrackedContentBase64),
|
||||
...(baseline.isBinary ? { isBinary: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (baseline.combinedDiff === undefined && baseline.isBinary !== true) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
path: file.path,
|
||||
previousPath: baseline.previousPath,
|
||||
...(baseline.combinedDiff && !isBinaryDiff(baseline.combinedDiff)
|
||||
? { diff: baseline.combinedDiff }
|
||||
: {}),
|
||||
...(baseline.isBinary || isBinaryDiff(baseline.combinedDiff) ? { isBinary: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sameWorkingTreeFile(
|
||||
left: ProjectGitWorkingTreeFile,
|
||||
right: ProjectGitWorkingTreeFile,
|
||||
): boolean {
|
||||
return (
|
||||
left.path === right.path
|
||||
&& left.previousPath === right.previousPath
|
||||
&& left.stagedStatus === right.stagedStatus
|
||||
&& left.unstagedStatus === right.unstagedStatus
|
||||
&& left.isConflicted === right.isConflicted
|
||||
);
|
||||
}
|
||||
|
||||
function sameBaselineFile(
|
||||
left: ProjectGitBaselineFile | undefined,
|
||||
right: ProjectGitBaselineFile | undefined,
|
||||
): boolean {
|
||||
if (!left && !right) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!left || !right) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
left.path === right.path
|
||||
&& left.previousPath === right.previousPath
|
||||
&& left.combinedDiff === right.combinedDiff
|
||||
&& left.untrackedContentBase64 === right.untrackedContentBase64
|
||||
&& left.isBinary === right.isBinary
|
||||
);
|
||||
}
|
||||
|
||||
function createRunChangeCounts(): ProjectGitRunChangeCounts {
|
||||
return {
|
||||
added: 0,
|
||||
modified: 0,
|
||||
deleted: 0,
|
||||
renamed: 0,
|
||||
copied: 0,
|
||||
typeChanged: 0,
|
||||
unmerged: 0,
|
||||
untracked: 0,
|
||||
cleaned: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function incrementRunChangeCount(
|
||||
counts: ProjectGitRunChangeCounts,
|
||||
kind: ProjectGitRunChangeKind,
|
||||
): void {
|
||||
switch (kind) {
|
||||
case 'added':
|
||||
counts.added += 1;
|
||||
break;
|
||||
case 'modified':
|
||||
counts.modified += 1;
|
||||
break;
|
||||
case 'deleted':
|
||||
counts.deleted += 1;
|
||||
break;
|
||||
case 'renamed':
|
||||
counts.renamed += 1;
|
||||
break;
|
||||
case 'copied':
|
||||
counts.copied += 1;
|
||||
break;
|
||||
case 'type-changed':
|
||||
counts.typeChanged += 1;
|
||||
break;
|
||||
case 'unmerged':
|
||||
counts.unmerged += 1;
|
||||
break;
|
||||
case 'untracked':
|
||||
counts.untracked += 1;
|
||||
break;
|
||||
case 'cleaned':
|
||||
counts.cleaned += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRunChangeKind(file: ProjectGitWorkingTreeFile): ProjectGitRunChangeKind {
|
||||
if (file.isConflicted) {
|
||||
return 'unmerged';
|
||||
}
|
||||
|
||||
return file.unstagedStatus ?? file.stagedStatus ?? 'modified';
|
||||
}
|
||||
|
||||
function sortRunChangedFiles(
|
||||
left: ProjectGitRunChangedFile,
|
||||
right: ProjectGitRunChangedFile,
|
||||
): number {
|
||||
if (left.origin !== right.origin) {
|
||||
return left.origin === 'run-created' ? -1 : 1;
|
||||
}
|
||||
|
||||
return left.path.localeCompare(right.path);
|
||||
}
|
||||
|
||||
export function buildProjectGitRunChangeSummary(
|
||||
input: BuildProjectGitRunChangeSummaryInput,
|
||||
): ProjectGitRunChangeSummary | undefined {
|
||||
const {
|
||||
generatedAt,
|
||||
preRunSnapshot,
|
||||
preRunBaselineFiles,
|
||||
postRunSnapshot,
|
||||
postRunBaselineFiles,
|
||||
} = input;
|
||||
|
||||
if (!preRunSnapshot || !postRunSnapshot) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const preRunFilesByPath = new Map(
|
||||
preRunSnapshot.files.map((file) => [file.path, file] satisfies [string, ProjectGitWorkingTreeFile]),
|
||||
);
|
||||
const preRunBaselineByPath = new Map(
|
||||
(preRunBaselineFiles ?? []).map((file) => [file.path, file] satisfies [string, ProjectGitBaselineFile]),
|
||||
);
|
||||
const postRunBaselineByPath = new Map(
|
||||
(postRunBaselineFiles ?? []).map((file) => [file.path, file] satisfies [string, ProjectGitBaselineFile]),
|
||||
);
|
||||
const matchedPreRunPaths = new Set<string>();
|
||||
const files: ProjectGitRunChangedFile[] = [];
|
||||
|
||||
for (const postRunFile of postRunSnapshot.files) {
|
||||
const matchedPreRunFile = preRunFilesByPath.get(postRunFile.path)
|
||||
?? (postRunFile.previousPath ? preRunFilesByPath.get(postRunFile.previousPath) : undefined);
|
||||
const matchedPreRunPath = matchedPreRunFile?.path;
|
||||
const postRunBaseline = postRunBaselineByPath.get(postRunFile.path);
|
||||
if (!matchedPreRunFile || !matchedPreRunPath) {
|
||||
const preview = previewFromBaselineFile(postRunFile, postRunBaseline);
|
||||
const stats = parseDiffStats(preview?.diff);
|
||||
files.push({
|
||||
path: postRunFile.path,
|
||||
previousPath: postRunFile.previousPath,
|
||||
kind: resolveRunChangeKind(postRunFile),
|
||||
origin: 'run-created',
|
||||
stagedStatus: postRunFile.stagedStatus,
|
||||
unstagedStatus: postRunFile.unstagedStatus,
|
||||
...(postRunFile.isConflicted ? { isConflicted: true } : {}),
|
||||
additions: stats.additions,
|
||||
deletions: stats.deletions,
|
||||
canRevert: true,
|
||||
...(preview ? { preview } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
matchedPreRunPaths.add(matchedPreRunPath);
|
||||
|
||||
const preRunBaseline = preRunBaselineByPath.get(matchedPreRunPath);
|
||||
if (sameWorkingTreeFile(matchedPreRunFile, postRunFile) && sameBaselineFile(preRunBaseline, postRunBaseline)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const preview = previewFromBaselineFile(postRunFile, postRunBaseline);
|
||||
const stats = parseDiffStats(preview?.diff);
|
||||
files.push({
|
||||
path: postRunFile.path,
|
||||
previousPath: postRunFile.previousPath,
|
||||
kind: resolveRunChangeKind(postRunFile),
|
||||
origin: 'pre-existing',
|
||||
stagedStatus: postRunFile.stagedStatus,
|
||||
unstagedStatus: postRunFile.unstagedStatus,
|
||||
...(postRunFile.isConflicted ? { isConflicted: true } : {}),
|
||||
additions: stats.additions,
|
||||
deletions: stats.deletions,
|
||||
canRevert: canRestoreBaseline(preRunBaseline),
|
||||
...(preview ? { preview } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
for (const preRunFile of preRunSnapshot.files) {
|
||||
if (matchedPreRunPaths.has(preRunFile.path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const preRunBaseline = preRunBaselineByPath.get(preRunFile.path);
|
||||
const preview = previewFromBaselineFile(preRunFile, preRunBaseline);
|
||||
const stats = parseDiffStats(preview?.diff);
|
||||
files.push({
|
||||
path: preRunFile.path,
|
||||
previousPath: preRunFile.previousPath,
|
||||
kind: 'cleaned',
|
||||
origin: 'pre-existing',
|
||||
stagedStatus: preRunFile.stagedStatus,
|
||||
unstagedStatus: preRunFile.unstagedStatus,
|
||||
...(preRunFile.isConflicted ? { isConflicted: true } : {}),
|
||||
additions: stats.additions,
|
||||
deletions: stats.deletions,
|
||||
canRevert: canRestoreBaseline(preRunBaseline),
|
||||
...(preview ? { preview } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const branchChanged = preRunSnapshot.branch !== postRunSnapshot.branch;
|
||||
if (files.length === 0 && !branchChanged) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
files.sort(sortRunChangedFiles);
|
||||
const counts = createRunChangeCounts();
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
for (const file of files) {
|
||||
incrementRunChangeCount(counts, file.kind);
|
||||
additions += file.additions;
|
||||
deletions += file.deletions;
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt,
|
||||
branchAtStart: preRunSnapshot.branch,
|
||||
branchAtEnd: postRunSnapshot.branch,
|
||||
...(branchChanged ? { branchChanged: true } : {}),
|
||||
fileCount: files.length,
|
||||
additions,
|
||||
deletions,
|
||||
counts,
|
||||
files,
|
||||
};
|
||||
}
|
||||
+604
-7
@@ -1,9 +1,30 @@
|
||||
import { isUtf8 } from 'node:buffer';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import type { ProjectGitChangeSummary, ProjectGitCommitSummary, ProjectGitContext } from '@shared/domain/project';
|
||||
import type {
|
||||
ProjectGitBaselineFile,
|
||||
ProjectGitBranchSummary,
|
||||
ProjectGitChangeSummary,
|
||||
ProjectGitCommitLogEntry,
|
||||
ProjectGitCommitSummary,
|
||||
ProjectGitContext,
|
||||
ProjectGitDetails,
|
||||
ProjectGitDiffPreview,
|
||||
ProjectGitFileReference,
|
||||
ProjectGitRunChangeSummary,
|
||||
ProjectGitRunChangedFile,
|
||||
ProjectGitWorkingTreeFile,
|
||||
ProjectGitWorkingTreeFileStatus,
|
||||
ProjectGitWorkingTreeSnapshot,
|
||||
} from '@shared/domain/project';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
|
||||
import { buildProjectGitRunChangeSummary } from '@main/git/gitRunChangeSummary';
|
||||
|
||||
type ExecFileException = import('node:child_process').ExecFileException;
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -73,6 +94,11 @@ function isNotRepository(error: GitCommandFailure): boolean {
|
||||
return detail.includes('not a git repository');
|
||||
}
|
||||
|
||||
function isUnknownPathspec(error: GitCommandFailure): boolean {
|
||||
const detail = `${error.message}\n${error.stderr ?? ''}`.toLowerCase();
|
||||
return detail.includes('did not match any file') || detail.includes('pathspec');
|
||||
}
|
||||
|
||||
function summarizeGitFailure(error: GitCommandFailure): string {
|
||||
return error.stderr?.trim() || error.message;
|
||||
}
|
||||
@@ -102,9 +128,46 @@ function isConflictedStatus(x: string, y: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function parseChangeSummary(stdout: string): {
|
||||
function parseWorkingTreeFileStatus(value: string): ProjectGitWorkingTreeFileStatus | undefined {
|
||||
switch (value) {
|
||||
case 'A':
|
||||
return 'added';
|
||||
case 'M':
|
||||
return 'modified';
|
||||
case 'D':
|
||||
return 'deleted';
|
||||
case 'R':
|
||||
return 'renamed';
|
||||
case 'C':
|
||||
return 'copied';
|
||||
case 'T':
|
||||
return 'type-changed';
|
||||
case 'U':
|
||||
return 'unmerged';
|
||||
case '?':
|
||||
return 'untracked';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseWorkingTreePath(rawPath: string): Pick<ProjectGitWorkingTreeFile, 'path' | 'previousPath'> {
|
||||
const separator = ' -> ';
|
||||
const separatorIndex = rawPath.indexOf(separator);
|
||||
if (separatorIndex < 0) {
|
||||
return { path: rawPath };
|
||||
}
|
||||
|
||||
return {
|
||||
previousPath: rawPath.slice(0, separatorIndex).trim(),
|
||||
path: rawPath.slice(separatorIndex + separator.length).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorkingTree(stdout: string): {
|
||||
changedFileCount: number;
|
||||
changes: ProjectGitChangeSummary;
|
||||
files: ProjectGitWorkingTreeFile[];
|
||||
} {
|
||||
const summary: ProjectGitChangeSummary = {
|
||||
staged: 0,
|
||||
@@ -112,6 +175,7 @@ function parseChangeSummary(stdout: string): {
|
||||
untracked: 0,
|
||||
conflicted: 0,
|
||||
};
|
||||
const files: ProjectGitWorkingTreeFile[] = [];
|
||||
|
||||
const lines = stdout
|
||||
.split(/\r?\n/)
|
||||
@@ -122,20 +186,42 @@ function parseChangeSummary(stdout: string): {
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('??')) {
|
||||
const path = line.slice(3).trim();
|
||||
if (!path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.untracked += 1;
|
||||
changedFileCount += 1;
|
||||
files.push({
|
||||
path,
|
||||
unstagedStatus: 'untracked',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.length < 2) {
|
||||
if (line.length < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = line[0];
|
||||
const y = line[1];
|
||||
changedFileCount += 1;
|
||||
const rawPath = line.slice(3).trim();
|
||||
if (!rawPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isConflictedStatus(x, y)) {
|
||||
changedFileCount += 1;
|
||||
const isConflicted = isConflictedStatus(x, y);
|
||||
const pathInfo = parseWorkingTreePath(rawPath);
|
||||
files.push({
|
||||
...pathInfo,
|
||||
stagedStatus: parseWorkingTreeFileStatus(x),
|
||||
unstagedStatus: parseWorkingTreeFileStatus(y),
|
||||
...(isConflicted ? { isConflicted: true } : {}),
|
||||
});
|
||||
|
||||
if (isConflicted) {
|
||||
summary.conflicted += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -152,6 +238,7 @@ function parseChangeSummary(stdout: string): {
|
||||
return {
|
||||
changedFileCount,
|
||||
changes: summary,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,6 +260,122 @@ function parseHead(stdout: string): ProjectGitCommitSummary | undefined {
|
||||
};
|
||||
}
|
||||
|
||||
function parseBranchList(stdout: string): ProjectGitBranchSummary[] {
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.flatMap((line) => {
|
||||
const [name, currentMarker, upstream] = line.split('\0');
|
||||
const trimmedName = name?.trim();
|
||||
if (!trimmedName) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
name: trimmedName,
|
||||
isCurrent: currentMarker?.trim() === '*',
|
||||
upstream: upstream?.trim() || undefined,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function parseCommitLog(stdout: string): ProjectGitCommitLogEntry[] {
|
||||
return stdout
|
||||
.split('\x1e')
|
||||
.map((record) => record.trim())
|
||||
.filter(Boolean)
|
||||
.flatMap((record) => {
|
||||
const [hash, shortHash, authorName, subject, committedAt, refNames] = record.split('\0');
|
||||
if (!hash || !shortHash || !authorName || !subject || !committedAt) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
hash: hash.trim(),
|
||||
shortHash: shortHash.trim(),
|
||||
authorName: authorName.trim(),
|
||||
subject: subject.trim(),
|
||||
committedAt: committedAt.trim(),
|
||||
refNames: refNames?.trim() || undefined,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function isPureUntrackedFile(file: ProjectGitWorkingTreeFile): boolean {
|
||||
return file.stagedStatus === undefined && file.unstagedStatus === 'untracked';
|
||||
}
|
||||
|
||||
function buildGitPaths(file: ProjectGitFileReference): string[] {
|
||||
const paths = new Set<string>();
|
||||
if (file.previousPath?.trim()) {
|
||||
paths.add(file.previousPath.trim());
|
||||
}
|
||||
if (file.path.trim()) {
|
||||
paths.add(file.path.trim());
|
||||
}
|
||||
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
function uniqueGitPaths(files: readonly ProjectGitFileReference[]): string[] {
|
||||
const paths = new Set<string>();
|
||||
for (const file of files) {
|
||||
for (const path of buildGitPaths(file)) {
|
||||
paths.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
function isBinaryDiff(diff: string | undefined): boolean {
|
||||
if (!diff) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return diff.includes('GIT binary patch') || diff.includes('Binary files ');
|
||||
}
|
||||
|
||||
function canRestoreBaseline(
|
||||
baseline: ProjectGitBaselineFile | undefined,
|
||||
): baseline is ProjectGitBaselineFile {
|
||||
return baseline !== undefined
|
||||
&& (baseline.untrackedContentBase64 !== undefined || baseline.combinedDiff !== undefined);
|
||||
}
|
||||
|
||||
function baselineToPreview(
|
||||
file: Pick<ProjectGitFileReference, 'path' | 'previousPath'>,
|
||||
baseline: ProjectGitBaselineFile | undefined,
|
||||
): ProjectGitDiffPreview | undefined {
|
||||
if (!baseline) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (baseline.untrackedContentBase64 !== undefined) {
|
||||
const contentBuffer = Buffer.from(baseline.untrackedContentBase64, 'base64');
|
||||
return {
|
||||
path: file.path,
|
||||
previousPath: file.previousPath,
|
||||
...(isUtf8(contentBuffer) ? { newFileContents: contentBuffer.toString('utf8') } : {}),
|
||||
...(baseline.isBinary ? { isBinary: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (baseline.combinedDiff === undefined && baseline.isBinary !== true) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
path: file.path,
|
||||
previousPath: file.previousPath,
|
||||
...(baseline.combinedDiff && !isBinaryDiff(baseline.combinedDiff)
|
||||
? { diff: baseline.combinedDiff }
|
||||
: {}),
|
||||
...(baseline.isBinary || isBinaryDiff(baseline.combinedDiff) ? { isBinary: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export class GitService {
|
||||
constructor(private readonly runGitCommand: GitCommandRunner = defaultGitCommandRunner) {}
|
||||
|
||||
@@ -219,7 +422,7 @@ export class GitService {
|
||||
this.tryRun(projectPath, ['log', '-1', '--format=%H%n%h%n%s%n%cI']),
|
||||
]);
|
||||
|
||||
const { changedFileCount, changes } = parseChangeSummary(statusResult.stdout);
|
||||
const { changedFileCount, changes } = parseWorkingTree(statusResult.stdout);
|
||||
const upstream = upstreamResult.ok ? upstreamResult.stdout.trim() || undefined : undefined;
|
||||
const aheadBehind = countsResult.ok ? parseAheadBehind(countsResult.stdout) : {};
|
||||
|
||||
@@ -238,11 +441,405 @@ export class GitService {
|
||||
};
|
||||
}
|
||||
|
||||
async describeProjectGitDetails(
|
||||
projectPath: string,
|
||||
scannedAt = nowIso(),
|
||||
commitLimit = 20,
|
||||
): Promise<ProjectGitDetails> {
|
||||
const context = await this.describeProject(projectPath, scannedAt);
|
||||
if (context.status !== 'ready') {
|
||||
return {
|
||||
scannedAt,
|
||||
context,
|
||||
branches: [],
|
||||
recentCommits: [],
|
||||
};
|
||||
}
|
||||
|
||||
const [workingTree, branches, recentCommits] = await Promise.all([
|
||||
this.captureWorkingTreeSnapshot(projectPath, scannedAt),
|
||||
this.listBranches(projectPath),
|
||||
this.listRecentCommits(projectPath, commitLimit),
|
||||
]);
|
||||
|
||||
return {
|
||||
scannedAt,
|
||||
context,
|
||||
workingTree: workingTree ?? undefined,
|
||||
branches,
|
||||
recentCommits,
|
||||
};
|
||||
}
|
||||
|
||||
async captureWorkingTreeSnapshot(
|
||||
projectPath: string,
|
||||
scannedAt = nowIso(),
|
||||
): Promise<ProjectGitWorkingTreeSnapshot | undefined> {
|
||||
const repoRootResult = await this.tryRun(projectPath, ['rev-parse', '--show-toplevel']);
|
||||
if (!repoRootResult.ok) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const statusResult = await this.tryRun(projectPath, ['status', '--porcelain=1', '--untracked-files=all']);
|
||||
if (!statusResult.ok) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const branchResult = await this.tryRun(projectPath, ['branch', '--show-current']);
|
||||
const { changedFileCount, changes, files } = parseWorkingTree(statusResult.stdout);
|
||||
|
||||
return {
|
||||
scannedAt,
|
||||
repoRoot: repoRootResult.stdout.trim(),
|
||||
branch: branchResult.ok ? parseBranch(branchResult.stdout) : undefined,
|
||||
changedFileCount,
|
||||
changes,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
async captureWorkingTreeBaseline(
|
||||
projectPath: string,
|
||||
snapshot?: ProjectGitWorkingTreeSnapshot,
|
||||
): Promise<ProjectGitBaselineFile[]> {
|
||||
const effectiveSnapshot = snapshot ?? await this.captureWorkingTreeSnapshot(projectPath);
|
||||
if (!effectiveSnapshot || effectiveSnapshot.files.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const baselineFiles = await Promise.all(
|
||||
effectiveSnapshot.files.map((file) => this.captureBaselineFile(projectPath, file)),
|
||||
);
|
||||
|
||||
return baselineFiles.flatMap((file) => (file ? [file] : []));
|
||||
}
|
||||
|
||||
async computeRunChangeSummary(
|
||||
projectPath: string,
|
||||
options: {
|
||||
generatedAt?: string;
|
||||
preRunSnapshot?: ProjectGitWorkingTreeSnapshot;
|
||||
preRunBaselineFiles?: readonly ProjectGitBaselineFile[];
|
||||
},
|
||||
): Promise<ProjectGitRunChangeSummary | undefined> {
|
||||
const generatedAt = options.generatedAt ?? nowIso();
|
||||
const postRunSnapshot = await this.captureWorkingTreeSnapshot(projectPath, generatedAt);
|
||||
if (!options.preRunSnapshot || !postRunSnapshot) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const postRunBaselineFiles = await this.captureWorkingTreeBaseline(projectPath, postRunSnapshot);
|
||||
return buildProjectGitRunChangeSummary({
|
||||
generatedAt,
|
||||
preRunSnapshot: options.preRunSnapshot,
|
||||
preRunBaselineFiles: options.preRunBaselineFiles,
|
||||
postRunSnapshot,
|
||||
postRunBaselineFiles,
|
||||
});
|
||||
}
|
||||
|
||||
async getWorkingTreeFilePreview(
|
||||
projectPath: string,
|
||||
file: ProjectGitFileReference,
|
||||
): Promise<ProjectGitDiffPreview | undefined> {
|
||||
const snapshot = await this.captureWorkingTreeSnapshot(projectPath);
|
||||
if (!snapshot) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchedFile = snapshot.files.find((candidate) =>
|
||||
candidate.path === file.path
|
||||
|| candidate.previousPath === file.path
|
||||
|| (file.previousPath !== undefined && candidate.path === file.previousPath)
|
||||
|| (file.previousPath !== undefined && candidate.previousPath === file.previousPath));
|
||||
if (!matchedFile) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const baseline = await this.captureBaselineFile(projectPath, matchedFile);
|
||||
return baselineToPreview(matchedFile, baseline);
|
||||
}
|
||||
|
||||
async stageFiles(projectPath: string, files: readonly ProjectGitFileReference[]): Promise<void> {
|
||||
const paths = uniqueGitPaths(files);
|
||||
if (paths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.run(projectPath, ['add', '--', ...paths]);
|
||||
}
|
||||
|
||||
async unstageFiles(projectPath: string, files: readonly ProjectGitFileReference[]): Promise<void> {
|
||||
const paths = uniqueGitPaths(files);
|
||||
if (paths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.run(projectPath, ['restore', '--staged', '--', ...paths]);
|
||||
}
|
||||
|
||||
async commit(projectPath: string, message: string): Promise<ProjectGitCommitSummary> {
|
||||
await this.run(projectPath, ['commit', '-m', message]);
|
||||
const head = await this.getHeadCommit(projectPath);
|
||||
if (!head) {
|
||||
throw new Error('Git commit completed, but the new HEAD commit could not be resolved.');
|
||||
}
|
||||
|
||||
return head;
|
||||
}
|
||||
|
||||
async push(projectPath: string): Promise<void> {
|
||||
await this.run(projectPath, ['push']);
|
||||
}
|
||||
|
||||
async fetch(projectPath: string): Promise<void> {
|
||||
await this.run(projectPath, ['fetch', '--all', '--prune']);
|
||||
}
|
||||
|
||||
async pull(projectPath: string, rebase = false): Promise<void> {
|
||||
await this.run(projectPath, rebase ? ['pull', '--rebase'] : ['pull']);
|
||||
}
|
||||
|
||||
async createBranch(
|
||||
projectPath: string,
|
||||
name: string,
|
||||
startPoint?: string,
|
||||
checkout = true,
|
||||
): Promise<void> {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
throw new Error('A branch name is required.');
|
||||
}
|
||||
|
||||
if (checkout) {
|
||||
await this.run(
|
||||
projectPath,
|
||||
['switch', '-c', trimmedName, ...(startPoint?.trim() ? [startPoint.trim()] : [])],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.run(
|
||||
projectPath,
|
||||
['branch', trimmedName, ...(startPoint?.trim() ? [startPoint.trim()] : [])],
|
||||
);
|
||||
}
|
||||
|
||||
async switchBranch(projectPath: string, name: string): Promise<void> {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
throw new Error('A branch name is required.');
|
||||
}
|
||||
|
||||
await this.run(projectPath, ['switch', trimmedName]);
|
||||
}
|
||||
|
||||
async deleteBranch(projectPath: string, name: string, force = false): Promise<void> {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
throw new Error('A branch name is required.');
|
||||
}
|
||||
|
||||
await this.run(projectPath, ['branch', force ? '-D' : '-d', trimmedName]);
|
||||
}
|
||||
|
||||
async listBranches(projectPath: string): Promise<ProjectGitBranchSummary[]> {
|
||||
const result = await this.tryRun(projectPath, [
|
||||
'for-each-ref',
|
||||
'--format=%(refname:short)%00%(HEAD)%00%(upstream:short)',
|
||||
'refs/heads',
|
||||
]);
|
||||
|
||||
return result.ok ? parseBranchList(result.stdout) : [];
|
||||
}
|
||||
|
||||
async listRecentCommits(projectPath: string, limit = 20): Promise<ProjectGitCommitLogEntry[]> {
|
||||
const result = await this.tryRun(projectPath, [
|
||||
'log',
|
||||
`-n${Math.max(1, Math.round(limit))}`,
|
||||
'--format=%H%x00%h%x00%an%x00%s%x00%cI%x00%D%x1e',
|
||||
]);
|
||||
|
||||
return result.ok ? parseCommitLog(result.stdout) : [];
|
||||
}
|
||||
|
||||
async discardRunChanges(
|
||||
projectPath: string,
|
||||
options: {
|
||||
summary: ProjectGitRunChangeSummary;
|
||||
preRunBaselineFiles?: readonly ProjectGitBaselineFile[];
|
||||
files?: readonly ProjectGitFileReference[];
|
||||
},
|
||||
): Promise<void> {
|
||||
const selectedFiles = options.files && options.files.length > 0
|
||||
? options.summary.files.filter((candidate) =>
|
||||
options.files?.some((selected) =>
|
||||
selected.path === candidate.path
|
||||
|| selected.path === candidate.previousPath
|
||||
|| (selected.previousPath !== undefined && selected.previousPath === candidate.previousPath)))
|
||||
: options.summary.files;
|
||||
if (selectedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baselinesByPath = new Map(
|
||||
(options.preRunBaselineFiles ?? []).map((file) => [file.path, file] satisfies [string, ProjectGitBaselineFile]),
|
||||
);
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
if (file.origin === 'pre-existing') {
|
||||
if (!file.canRevert) {
|
||||
throw new Error(`Cannot restore "${file.path}" to its pre-run state because no restorable baseline was captured.`);
|
||||
}
|
||||
|
||||
const baseline = baselinesByPath.get(file.previousPath ?? file.path) ?? baselinesByPath.get(file.path);
|
||||
if (!canRestoreBaseline(baseline)) {
|
||||
throw new Error(`Cannot restore "${file.path}" to its pre-run state because no restorable baseline was captured.`);
|
||||
}
|
||||
|
||||
await this.restorePreExistingChange(projectPath, file, baseline);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.restoreRunCreatedChange(projectPath, file);
|
||||
}
|
||||
}
|
||||
|
||||
private async captureBaselineFile(
|
||||
projectPath: string,
|
||||
file: ProjectGitWorkingTreeFile,
|
||||
): Promise<ProjectGitBaselineFile | undefined> {
|
||||
if (!file.path.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isPureUntrackedFile(file)) {
|
||||
try {
|
||||
const contents = await readFile(join(projectPath, file.path));
|
||||
return {
|
||||
path: file.path,
|
||||
previousPath: file.previousPath,
|
||||
untrackedContentBase64: contents.toString('base64'),
|
||||
...(isUtf8(contents) ? {} : { isBinary: true }),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
path: file.path,
|
||||
previousPath: file.previousPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const diffPaths = buildGitPaths(file);
|
||||
const diffResult = await this.tryRun(projectPath, [
|
||||
'diff',
|
||||
'--binary',
|
||||
'--no-ext-diff',
|
||||
'--no-renames',
|
||||
'HEAD',
|
||||
'--',
|
||||
...diffPaths,
|
||||
]);
|
||||
if (!diffResult.ok) {
|
||||
return {
|
||||
path: file.path,
|
||||
previousPath: file.previousPath,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path: file.path,
|
||||
previousPath: file.previousPath,
|
||||
combinedDiff: diffResult.stdout.trim() ? diffResult.stdout : undefined,
|
||||
...(isBinaryDiff(diffResult.stdout) ? { isBinary: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async getHeadCommit(projectPath: string): Promise<ProjectGitCommitSummary | undefined> {
|
||||
const result = await this.tryRun(projectPath, ['log', '-1', '--format=%H%n%h%n%s%n%cI']);
|
||||
return result.ok ? parseHead(result.stdout) : undefined;
|
||||
}
|
||||
|
||||
private async restoreRunCreatedChange(
|
||||
projectPath: string,
|
||||
file: ProjectGitRunChangedFile,
|
||||
): Promise<void> {
|
||||
if (file.kind === 'renamed' && file.previousPath) {
|
||||
await this.restorePathFromHead(projectPath, file.previousPath);
|
||||
await this.removePath(projectPath, file.path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.kind === 'added' || file.kind === 'untracked' || file.kind === 'copied') {
|
||||
await this.removePath(projectPath, file.path);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.restorePathFromHead(projectPath, file.path);
|
||||
}
|
||||
|
||||
private async restorePreExistingChange(
|
||||
projectPath: string,
|
||||
file: ProjectGitRunChangedFile,
|
||||
baseline: ProjectGitBaselineFile,
|
||||
): Promise<void> {
|
||||
await this.restorePathFromHead(projectPath, baseline.path);
|
||||
if (file.path !== baseline.path) {
|
||||
await this.removePath(projectPath, file.path);
|
||||
}
|
||||
|
||||
if (baseline.untrackedContentBase64 !== undefined) {
|
||||
const contents = Buffer.from(baseline.untrackedContentBase64, 'base64');
|
||||
await mkdir(dirname(join(projectPath, baseline.path)), { recursive: true });
|
||||
await writeFile(join(projectPath, baseline.path), contents);
|
||||
return;
|
||||
}
|
||||
|
||||
if (baseline.combinedDiff) {
|
||||
await this.applyPatch(projectPath, baseline.combinedDiff);
|
||||
}
|
||||
}
|
||||
|
||||
private async restorePathFromHead(projectPath: string, path: string): Promise<void> {
|
||||
const result = await this.tryRun(projectPath, ['restore', '--source=HEAD', '--staged', '--worktree', '--', path]);
|
||||
if (!result.ok && !isUnknownPathspec(result.error)) {
|
||||
throw result.error;
|
||||
}
|
||||
}
|
||||
|
||||
private async removePath(projectPath: string, path: string): Promise<void> {
|
||||
const unstageResult = await this.tryRun(projectPath, ['rm', '--cached', '--force', '--ignore-unmatch', '--', path]);
|
||||
if (!unstageResult.ok && !isUnknownPathspec(unstageResult.error)) {
|
||||
throw unstageResult.error;
|
||||
}
|
||||
|
||||
await rm(join(projectPath, path), { force: true });
|
||||
}
|
||||
|
||||
private async applyPatch(projectPath: string, diff: string): Promise<void> {
|
||||
const tempDirectory = await mkdtemp(join(tmpdir(), 'aryx-git-patch-'));
|
||||
const patchPath = join(tempDirectory, 'restore.diff');
|
||||
try {
|
||||
await writeFile(patchPath, diff, 'utf8');
|
||||
await this.run(projectPath, ['apply', '--whitespace=nowarn', '--recount', patchPath]);
|
||||
} finally {
|
||||
await rm(tempDirectory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async run(projectPath: string, args: string[]): Promise<string> {
|
||||
try {
|
||||
return await this.runGitCommand(projectPath, args);
|
||||
} catch (error) {
|
||||
throw createGitCommandFailure(projectPath, args, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async tryRun(projectPath: string, args: string[]): Promise<GitCommandResult> {
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
stdout: await this.runGitCommand(projectPath, args),
|
||||
stdout: await this.run(projectPath, args),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -6,12 +6,23 @@ import type {
|
||||
BranchSessionInput,
|
||||
CancelSessionTurnInput,
|
||||
CreateSessionInput,
|
||||
CreateProjectGitBranchInput,
|
||||
DismissSessionMcpAuthInput,
|
||||
DismissSessionPlanReviewInput,
|
||||
DeleteProjectGitBranchInput,
|
||||
DeleteSessionInput,
|
||||
DiscardSessionRunGitChangesInput,
|
||||
EditAndResendSessionMessageInput,
|
||||
CommitProjectGitChangesInput,
|
||||
ProjectGitDetailsInput,
|
||||
ProjectGitFilePreviewInput,
|
||||
ProjectGitFileSelectionInput,
|
||||
ProjectGitInput,
|
||||
PullProjectGitInput,
|
||||
RegenerateSessionMessageInput,
|
||||
StartSessionMcpAuthInput,
|
||||
SuggestProjectGitCommitMessageInput,
|
||||
SwitchProjectGitBranchInput,
|
||||
DuplicateSessionInput,
|
||||
RenameSessionInput,
|
||||
RescanProjectConfigsInput,
|
||||
@@ -52,6 +63,12 @@ export function registerIpcHandlers(
|
||||
service: AryxAppService,
|
||||
autoUpdateService: AutoUpdateService,
|
||||
): void {
|
||||
window.on('focus', () => {
|
||||
if (service.isGitAutoRefreshEnabled()) {
|
||||
service.scheduleProjectGitRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(ipcChannels.describeSidecarCapabilities, () => service.describeSidecarCapabilities());
|
||||
ipcMain.handle(ipcChannels.refreshSidecarCapabilities, () => service.refreshSidecarCapabilities());
|
||||
ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace());
|
||||
@@ -65,6 +82,12 @@ export function registerIpcHandlers(
|
||||
ipcMain.handle(ipcChannels.refreshProjectGitContext, (_event, projectId?: string) =>
|
||||
service.refreshProjectGitContext(projectId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.getProjectGitDetails, (_event, input: ProjectGitDetailsInput) =>
|
||||
service.getProjectGitDetails(input.projectId, input.commitLimit),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.getProjectGitFilePreview, (_event, input: ProjectGitFilePreviewInput) =>
|
||||
service.getProjectGitFilePreview(input.projectId, input.file),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.rescanProjectConfigs, (_event, input: RescanProjectConfigsInput) =>
|
||||
service.rescanProjectConfigs(input.projectId),
|
||||
);
|
||||
@@ -105,6 +128,10 @@ export function registerIpcHandlers(
|
||||
ipcChannels.setMinimizeToTray,
|
||||
(_event, enabled: boolean) => service.setMinimizeToTray(enabled),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.setGitAutoRefreshEnabled,
|
||||
(_event, enabled: boolean) => service.setGitAutoRefreshEnabled(enabled),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.checkForUpdates, () => autoUpdateService.checkForUpdates());
|
||||
ipcMain.handle(ipcChannels.installUpdate, () => {
|
||||
autoUpdateService.installUpdate();
|
||||
@@ -197,11 +224,58 @@ export function registerIpcHandlers(
|
||||
ipcMain.handle(ipcChannels.startSessionMcpAuth, (_event, input: StartSessionMcpAuthInput) =>
|
||||
service.startSessionMcpAuth(input.sessionId),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.discardSessionRunGitChanges,
|
||||
(_event, input: DiscardSessionRunGitChangesInput) =>
|
||||
service.discardSessionRunGitChanges(input.sessionId, input.runId, input.files),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.suggestProjectGitCommitMessage,
|
||||
(_event, input: SuggestProjectGitCommitMessageInput) =>
|
||||
service.suggestProjectGitCommitMessage(input.sessionId, input.runId, input.conventionalType),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.updateSessionModelConfig,
|
||||
(_event, input: UpdateSessionModelConfigInput) =>
|
||||
service.updateSessionModelConfig(input.sessionId, input.model, input.reasoningEffort),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.stageProjectGitFiles,
|
||||
(_event, input: ProjectGitFileSelectionInput) => service.stageProjectGitFiles(input.projectId, input.files),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.unstageProjectGitFiles,
|
||||
(_event, input: ProjectGitFileSelectionInput) => service.unstageProjectGitFiles(input.projectId, input.files),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.commitProjectGitChanges,
|
||||
(_event, input: CommitProjectGitChangesInput) =>
|
||||
service.commitProjectGitChanges(input.projectId, input.message, input.files, input.push),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.pushProjectGit, (_event, input: ProjectGitInput) =>
|
||||
service.pushProjectGit(input.projectId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.fetchProjectGit, (_event, input: ProjectGitInput) =>
|
||||
service.fetchProjectGit(input.projectId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.pullProjectGit, (_event, input: PullProjectGitInput) =>
|
||||
service.pullProjectGit(input.projectId, input.rebase),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.createProjectGitBranch,
|
||||
(_event, input: CreateProjectGitBranchInput) =>
|
||||
service.createProjectGitBranch(input.projectId, input.name, input.startPoint, input.checkout),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.switchProjectGitBranch,
|
||||
(_event, input: SwitchProjectGitBranchInput) =>
|
||||
service.switchProjectGitBranch(input.projectId, input.name),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.deleteProjectGitBranch,
|
||||
(_event, input: DeleteProjectGitBranchInput) =>
|
||||
service.deleteProjectGitBranch(input.projectId, input.name, input.force),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.querySessions, (_event, input: QuerySessionsInput) => service.querySessions(input));
|
||||
ipcMain.handle(ipcChannels.selectProject, (_event, projectId?: string) => service.selectProject(projectId));
|
||||
ipcMain.handle(ipcChannels.selectPattern, (_event, patternId?: string) => service.selectPattern(patternId));
|
||||
|
||||
@@ -192,7 +192,7 @@ export class AutoUpdateService {
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.started || !this.options.isPackaged) {
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,11 @@ import type {
|
||||
SessionUsageEvent,
|
||||
SessionCompactionEvent,
|
||||
PendingMessagesModifiedEvent,
|
||||
WorkflowCheckpointSavedEvent,
|
||||
AssistantUsageEvent,
|
||||
AssistantIntentEvent,
|
||||
ReasoningDeltaEvent,
|
||||
WorkflowDiagnosticEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
|
||||
@@ -25,9 +27,11 @@ export type TurnScopedEvent =
|
||||
| SessionUsageEvent
|
||||
| SessionCompactionEvent
|
||||
| PendingMessagesModifiedEvent
|
||||
| WorkflowCheckpointSavedEvent
|
||||
| AssistantUsageEvent
|
||||
| AssistantIntentEvent
|
||||
| ReasoningDeltaEvent;
|
||||
| ReasoningDeltaEvent
|
||||
| WorkflowDiagnosticEvent;
|
||||
|
||||
export interface RunTurnPendingCommand {
|
||||
kind: 'run-turn';
|
||||
|
||||
@@ -452,11 +452,13 @@ export class SidecarClient {
|
||||
case 'skill-invoked':
|
||||
case 'hook-lifecycle':
|
||||
case 'session-usage':
|
||||
case 'session-compaction':
|
||||
case 'pending-messages-modified':
|
||||
case 'assistant-usage':
|
||||
case 'assistant-intent':
|
||||
case 'reasoning-delta':
|
||||
case 'session-compaction':
|
||||
case 'pending-messages-modified':
|
||||
case 'workflow-checkpoint-saved':
|
||||
case 'workflow-diagnostic':
|
||||
case 'assistant-usage':
|
||||
case 'assistant-intent':
|
||||
case 'reasoning-delta':
|
||||
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onTurnScopedEvent(event));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ const api: ElectronApi = {
|
||||
resolveWorkspaceDiscoveredTooling: (input) =>
|
||||
ipcRenderer.invoke(ipcChannels.resolveWorkspaceDiscoveredTooling, input),
|
||||
refreshProjectGitContext: (projectId) => ipcRenderer.invoke(ipcChannels.refreshProjectGitContext, projectId),
|
||||
getProjectGitDetails: (input) => ipcRenderer.invoke(ipcChannels.getProjectGitDetails, input),
|
||||
getProjectGitFilePreview: (input) => ipcRenderer.invoke(ipcChannels.getProjectGitFilePreview, input),
|
||||
rescanProjectConfigs: (input) => ipcRenderer.invoke(ipcChannels.rescanProjectConfigs, input),
|
||||
rescanProjectCustomization: (input) =>
|
||||
ipcRenderer.invoke(ipcChannels.rescanProjectCustomization, input),
|
||||
@@ -28,6 +30,7 @@ const api: ElectronApi = {
|
||||
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
|
||||
setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled),
|
||||
setMinimizeToTray: (enabled) => ipcRenderer.invoke(ipcChannels.setMinimizeToTray, enabled),
|
||||
setGitAutoRefreshEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setGitAutoRefreshEnabled, enabled),
|
||||
checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates),
|
||||
installUpdate: () => ipcRenderer.invoke(ipcChannels.installUpdate),
|
||||
saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, input),
|
||||
@@ -65,9 +68,20 @@ const api: ElectronApi = {
|
||||
dismissSessionPlanReview: (input) => ipcRenderer.invoke(ipcChannels.dismissSessionPlanReview, input),
|
||||
dismissSessionMcpAuth: (input) => ipcRenderer.invoke(ipcChannels.dismissSessionMcpAuth, input),
|
||||
startSessionMcpAuth: (input) => ipcRenderer.invoke(ipcChannels.startSessionMcpAuth, input),
|
||||
discardSessionRunGitChanges: (input) => ipcRenderer.invoke(ipcChannels.discardSessionRunGitChanges, input),
|
||||
suggestProjectGitCommitMessage: (input) => ipcRenderer.invoke(ipcChannels.suggestProjectGitCommitMessage, input),
|
||||
updateSessionModelConfig: (input) =>
|
||||
ipcRenderer.invoke(ipcChannels.updateSessionModelConfig, input),
|
||||
querySessions: (input) => ipcRenderer.invoke(ipcChannels.querySessions, input),
|
||||
stageProjectGitFiles: (input) => ipcRenderer.invoke(ipcChannels.stageProjectGitFiles, input),
|
||||
unstageProjectGitFiles: (input) => ipcRenderer.invoke(ipcChannels.unstageProjectGitFiles, input),
|
||||
commitProjectGitChanges: (input) => ipcRenderer.invoke(ipcChannels.commitProjectGitChanges, input),
|
||||
pushProjectGit: (input) => ipcRenderer.invoke(ipcChannels.pushProjectGit, input),
|
||||
fetchProjectGit: (input) => ipcRenderer.invoke(ipcChannels.fetchProjectGit, input),
|
||||
pullProjectGit: (input) => ipcRenderer.invoke(ipcChannels.pullProjectGit, input),
|
||||
createProjectGitBranch: (input) => ipcRenderer.invoke(ipcChannels.createProjectGitBranch, input),
|
||||
switchProjectGitBranch: (input) => ipcRenderer.invoke(ipcChannels.switchProjectGitBranch, input),
|
||||
deleteProjectGitBranch: (input) => ipcRenderer.invoke(ipcChannels.deleteProjectGitBranch, input),
|
||||
selectProject: (projectId) => ipcRenderer.invoke(ipcChannels.selectProject, projectId),
|
||||
selectPattern: (patternId) => ipcRenderer.invoke(ipcChannels.selectPattern, patternId),
|
||||
selectSession: (sessionId) => ipcRenderer.invoke(ipcChannels.selectSession, sessionId),
|
||||
|
||||
+115
-25
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { CommitComposer } from '@renderer/components/chat/CommitComposer';
|
||||
import { AppShell } from '@renderer/components/AppShell';
|
||||
import { ActivityPanel } from '@renderer/components/ActivityPanel';
|
||||
import { ChatPane } from '@renderer/components/ChatPane';
|
||||
@@ -10,9 +11,11 @@ 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 { SettingsPanel, type SettingsSection } from '@renderer/components/SettingsPanel';
|
||||
import { Sidebar } from '@renderer/components/Sidebar';
|
||||
import { TerminalPanel, DEFAULT_HEIGHT as DEFAULT_TERMINAL_HEIGHT, MIN_HEIGHT as MIN_TERMINAL_HEIGHT } from '@renderer/components/TerminalPanel';
|
||||
import { BottomPanel, DEFAULT_HEIGHT as DEFAULT_BOTTOM_HEIGHT, MIN_HEIGHT as MIN_BOTTOM_HEIGHT, type BottomPanelTab } from '@renderer/components/BottomPanel';
|
||||
import { GitPanel } from '@renderer/components/GitPanel';
|
||||
import { TerminalPanel } from '@renderer/components/TerminalPanel';
|
||||
import { resolveChatToolingSettings } from '@renderer/lib/chatTooling';
|
||||
import {
|
||||
applySessionEventActivity,
|
||||
@@ -43,9 +46,11 @@ import { createDefaultToolApprovalPolicy } from '@shared/domain/approval';
|
||||
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||
import { syncPatternGraph, type PatternDefinition } from '@shared/domain/pattern';
|
||||
import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
|
||||
import type { ProjectGitFileReference } from '@shared/domain/project';
|
||||
import { applySessionModelConfig } from '@shared/domain/session';
|
||||
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
import type { UpdateStatus } from '@shared/contracts/ipc';
|
||||
import { createId, nowIso } from '@shared/utils/ids';
|
||||
|
||||
function createDraftPattern(defaultModelId: string, defaultReasoningEffort: PatternDefinition['agents'][0]['reasoningEffort']): PatternDefinition {
|
||||
@@ -113,6 +118,8 @@ export default function App() {
|
||||
const [activeSubagents, setActiveSubagents] = useState<ActiveSubagentMap>({});
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [settingsSection, setSettingsSection] = useState<SettingsSection>();
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({ state: 'idle' });
|
||||
const [projectSettingsId, setProjectSettingsId] = useState<string>();
|
||||
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
|
||||
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
|
||||
@@ -121,12 +128,17 @@ export default function App() {
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [showBookmarks, setShowBookmarks] = useState(false);
|
||||
|
||||
// Terminal state
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [terminalHeight, setTerminalHeight] = useState(
|
||||
() => workspace?.settings.terminalHeight ?? DEFAULT_TERMINAL_HEIGHT,
|
||||
// Commit composer state
|
||||
const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>();
|
||||
|
||||
// Bottom panel state (terminal + git)
|
||||
const [bottomPanelOpen, setBottomPanelOpen] = useState(false);
|
||||
const [bottomPanelTab, setBottomPanelTab] = useState<BottomPanelTab>('terminal');
|
||||
const [bottomPanelHeight, setBottomPanelHeight] = useState(
|
||||
() => workspace?.settings.terminalHeight ?? DEFAULT_BOTTOM_HEIGHT,
|
||||
);
|
||||
const [terminalRunning, setTerminalRunning] = useState(false);
|
||||
const [gitDirty, setGitDirty] = useState(false);
|
||||
|
||||
// Load workspace on mount
|
||||
useEffect(() => {
|
||||
@@ -188,6 +200,12 @@ export default function App() {
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
// Subscribe to auto-update status pushes from the main process
|
||||
useEffect(() => {
|
||||
const off = api.onUpdateStatus((status) => setUpdateStatus(status));
|
||||
return off;
|
||||
}, [api]);
|
||||
|
||||
// Apply theme to the document root
|
||||
const themeSetting: AppearanceTheme = workspace?.settings.theme ?? 'dark';
|
||||
useTheme(themeSetting);
|
||||
@@ -301,7 +319,7 @@ export default function App() {
|
||||
// ── Ctrl+` — Toggle terminal ──
|
||||
if (e.ctrlKey && e.key === '`') {
|
||||
e.preventDefault();
|
||||
setTerminalOpen((prev) => !prev);
|
||||
handleTerminalToggle();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -448,26 +466,38 @@ export default function App() {
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
// Sync terminalHeight from workspace settings when workspace loads
|
||||
// Sync bottom panel height from workspace settings when workspace loads
|
||||
useEffect(() => {
|
||||
if (workspace?.settings.terminalHeight) {
|
||||
setTerminalHeight(workspace.settings.terminalHeight);
|
||||
setBottomPanelHeight(workspace.settings.terminalHeight);
|
||||
}
|
||||
}, [workspace?.settings.terminalHeight]);
|
||||
|
||||
const handleTerminalHeightChange = useCallback((newHeight: number) => {
|
||||
const clamped = Math.max(MIN_TERMINAL_HEIGHT, Math.round(newHeight));
|
||||
setTerminalHeight(clamped);
|
||||
const handleBottomPanelHeightChange = useCallback((newHeight: number) => {
|
||||
const clamped = Math.max(MIN_BOTTOM_HEIGHT, Math.round(newHeight));
|
||||
setBottomPanelHeight(clamped);
|
||||
void api.setTerminalHeight({ height: clamped });
|
||||
}, [api]);
|
||||
|
||||
const handleTerminalClose = useCallback(() => {
|
||||
setTerminalOpen(false);
|
||||
const handleBottomPanelClose = useCallback(() => {
|
||||
setBottomPanelOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleTerminalToggle = useCallback(() => {
|
||||
setTerminalOpen((prev) => !prev);
|
||||
}, []);
|
||||
setBottomPanelOpen((prev) => {
|
||||
if (prev && bottomPanelTab === 'terminal') return false;
|
||||
return true;
|
||||
});
|
||||
setBottomPanelTab('terminal');
|
||||
}, [bottomPanelTab]);
|
||||
|
||||
const handleGitToggle = useCallback(() => {
|
||||
setBottomPanelOpen((prev) => {
|
||||
if (prev && bottomPanelTab === 'git') return false;
|
||||
return true;
|
||||
});
|
||||
setBottomPanelTab('git');
|
||||
}, [bottomPanelTab]);
|
||||
|
||||
const jumpToMessage = useCallback((messageId: string) => {
|
||||
const element = document.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`);
|
||||
@@ -478,6 +508,20 @@ export default function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDiscardRunChanges = useCallback(
|
||||
(sessionId: string, runId: string, files?: ProjectGitFileReference[]) =>
|
||||
api.discardSessionRunGitChanges({ sessionId, runId, files }),
|
||||
[api],
|
||||
);
|
||||
|
||||
const handleOpenCommitComposer = useCallback(() => {
|
||||
if (!selectedSession) return;
|
||||
setCommitComposerCtx({
|
||||
projectId: selectedSession.projectId,
|
||||
sessionId: selectedSession.id,
|
||||
});
|
||||
}, [selectedSession]);
|
||||
|
||||
const handleCreateScratchpad = useCallback(() => {
|
||||
if (!workspace) return;
|
||||
const singlePatterns = workspace.patterns
|
||||
@@ -494,6 +538,15 @@ export default function App() {
|
||||
}
|
||||
}, [api, workspace]);
|
||||
|
||||
const handleOpenSettingsAt = useCallback((section?: SettingsSection) => {
|
||||
setSettingsSection(section);
|
||||
setShowSettings(true);
|
||||
}, []);
|
||||
|
||||
const handleInstallUpdate = useCallback(() => {
|
||||
void api.installUpdate();
|
||||
}, [api]);
|
||||
|
||||
// Listen for tray "Quick Scratchpad" action
|
||||
const scratchpadRef = useRef(handleCreateScratchpad);
|
||||
scratchpadRef.current = handleCreateScratchpad;
|
||||
@@ -600,21 +653,26 @@ export default function App() {
|
||||
availableModels={availableModels}
|
||||
mcpProbingServerIds={workspace.mcpProbingServerIds}
|
||||
onTerminalToggle={handleTerminalToggle}
|
||||
onGitToggle={!isScratchpadProject(selectedSession.projectId) ? handleGitToggle : undefined}
|
||||
pattern={patternForSession}
|
||||
project={projectForSession}
|
||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||
session={selectedSession}
|
||||
sessionUsage={usageForSession}
|
||||
activeSubagents={subagentsForSession}
|
||||
terminalOpen={terminalOpen}
|
||||
terminalOpen={bottomPanelOpen && bottomPanelTab === 'terminal'}
|
||||
terminalRunning={terminalRunning}
|
||||
gitPanelOpen={bottomPanelOpen && bottomPanelTab === 'git'}
|
||||
gitDirty={gitDirty}
|
||||
toolingSettings={chatToolingSettings ?? workspace.settings.tooling}
|
||||
/>
|
||||
);
|
||||
detailPanel = (
|
||||
<ActivityPanel
|
||||
activity={activityForSession}
|
||||
onDiscard={handleDiscardRunChanges}
|
||||
onJumpToMessage={jumpToMessage}
|
||||
onOpenCommitComposer={handleOpenCommitComposer}
|
||||
pattern={patternForSession}
|
||||
session={selectedSession}
|
||||
sessionRequestUsage={requestUsageForSession}
|
||||
@@ -637,8 +695,9 @@ export default function App() {
|
||||
const overlay = showSettings ? (
|
||||
<SettingsPanel
|
||||
availableModels={availableModels}
|
||||
initialSection={settingsSection}
|
||||
isRefreshingCapabilities={isRefreshingCapabilities}
|
||||
onClose={() => setShowSettings(false)}
|
||||
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
|
||||
onDeleteLspProfile={async (id) => {
|
||||
await api.deleteLspProfile(id);
|
||||
}}
|
||||
@@ -673,6 +732,8 @@ export default function App() {
|
||||
onSetNotificationsEnabled={(enabled) => void api.setNotificationsEnabled(enabled)}
|
||||
minimizeToTray={workspace.settings.minimizeToTray === true}
|
||||
onSetMinimizeToTray={(enabled) => void api.setMinimizeToTray(enabled)}
|
||||
gitAutoRefreshEnabled={workspace.settings.gitAutoRefreshEnabled !== false}
|
||||
onSetGitAutoRefreshEnabled={(enabled) => void api.setGitAutoRefreshEnabled(enabled)}
|
||||
onOpenAppDataFolder={() => void api.openAppDataFolder()}
|
||||
onResetLocalWorkspace={async () => {
|
||||
const fresh = await api.resetLocalWorkspace();
|
||||
@@ -698,13 +759,30 @@ export default function App() {
|
||||
content={content}
|
||||
detailPanel={detailPanel}
|
||||
overlay={overlay}
|
||||
terminalPanel={
|
||||
terminalOpen ? (
|
||||
<TerminalPanel
|
||||
height={terminalHeight}
|
||||
onHeightChange={handleTerminalHeightChange}
|
||||
onClose={handleTerminalClose}
|
||||
onMinimize={handleTerminalClose}
|
||||
bottomPanel={
|
||||
bottomPanelOpen ? (
|
||||
<BottomPanel
|
||||
activeTab={bottomPanelTab}
|
||||
gitContent={
|
||||
selectedSession && !isScratchpadProject(selectedSession.projectId) ? (
|
||||
<GitPanel
|
||||
onDirtyChange={setGitDirty}
|
||||
projectId={selectedSession.projectId}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8 text-[11px] text-[var(--color-text-muted)]">
|
||||
Git is not available for scratchpad sessions
|
||||
</div>
|
||||
)
|
||||
}
|
||||
gitDirty={gitDirty}
|
||||
height={bottomPanelHeight}
|
||||
onClose={handleBottomPanelClose}
|
||||
onHeightChange={handleBottomPanelHeightChange}
|
||||
onTabChange={setBottomPanelTab}
|
||||
showGitTab={!!selectedSession && !isScratchpadProject(selectedSession.projectId)}
|
||||
terminalContent={<TerminalPanel onRunningChange={setTerminalRunning} />}
|
||||
terminalRunning={terminalRunning}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
@@ -741,6 +819,9 @@ export default function App() {
|
||||
onRefreshGitContext={(projectId) => {
|
||||
void api.refreshProjectGitContext(projectId);
|
||||
}}
|
||||
updateStatus={updateStatus}
|
||||
onViewUpdateDetails={() => handleOpenSettingsAt('troubleshooting')}
|
||||
onInstallUpdate={handleInstallUpdate}
|
||||
workspace={workspace}
|
||||
/>
|
||||
}
|
||||
@@ -863,6 +944,15 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{commitComposerCtx && (
|
||||
<CommitComposer
|
||||
onClose={() => setCommitComposerCtx(undefined)}
|
||||
projectId={commitComposerCtx.projectId}
|
||||
runId={commitComposerCtx.runId}
|
||||
sessionId={commitComposerCtx.sessionId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { Activity, ArrowRight, BarChart3, CheckCircle2, Clock, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
|
||||
import { Activity, AlertTriangle, ArrowRight, BarChart3, CheckCircle2, Clock, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
|
||||
|
||||
import {
|
||||
buildAgentActivityRows,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { RunTimeline } from '@renderer/components/RunTimeline';
|
||||
import { inferProvider } from '@shared/domain/models';
|
||||
import type { OrchestrationMode, PatternAgentDefinition, PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { ProjectGitFileReference } from '@shared/domain/project';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import { ProviderIcon } from './ProviderIcons';
|
||||
|
||||
@@ -189,6 +190,8 @@ function TurnEventIcon({ kind, phase, success }: { kind: SessionEventKind; phase
|
||||
return <Sparkles className={`${base} text-[var(--color-accent-purple)]`} />;
|
||||
case 'session-compaction':
|
||||
return <CheckCircle2 className={`${base} ${phase === 'start' ? 'animate-pulse text-[var(--color-status-warning)]' : 'text-[var(--color-status-success)]'}`} />;
|
||||
case 'workflow-diagnostic':
|
||||
return <AlertTriangle className={`${base} ${success === false ? 'text-[var(--color-status-error)]' : 'text-[var(--color-status-warning)]'}`} />;
|
||||
default:
|
||||
return <Zap className={`${base} text-[var(--color-text-muted)]`} />;
|
||||
}
|
||||
@@ -208,6 +211,8 @@ function formatTurnEventTimestamp(iso: string): string {
|
||||
interface ActivityPanelProps {
|
||||
activity?: SessionActivityState;
|
||||
onJumpToMessage?: (messageId: string) => void;
|
||||
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
||||
onOpenCommitComposer?: () => void;
|
||||
pattern: PatternDefinition;
|
||||
session: SessionRecord;
|
||||
sessionRequestUsage?: SessionRequestUsageState;
|
||||
@@ -217,6 +222,8 @@ interface ActivityPanelProps {
|
||||
export function ActivityPanel({
|
||||
activity,
|
||||
onJumpToMessage,
|
||||
onDiscard,
|
||||
onOpenCommitComposer,
|
||||
pattern,
|
||||
session,
|
||||
sessionRequestUsage,
|
||||
@@ -345,7 +352,13 @@ export function ActivityPanel({
|
||||
)}
|
||||
</SectionHeader>
|
||||
|
||||
<RunTimeline onJumpToMessage={onJumpToMessage} runs={session.runs} />
|
||||
<RunTimeline
|
||||
onDiscard={onDiscard}
|
||||
onJumpToMessage={onJumpToMessage}
|
||||
onOpenCommitComposer={onOpenCommitComposer}
|
||||
runs={session.runs}
|
||||
sessionId={session.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Turn events section ─────────────────────────── */}
|
||||
|
||||
@@ -4,11 +4,11 @@ interface AppShellProps {
|
||||
sidebar: ReactNode;
|
||||
content: ReactNode;
|
||||
detailPanel?: ReactNode;
|
||||
terminalPanel?: ReactNode;
|
||||
bottomPanel?: ReactNode;
|
||||
overlay?: ReactNode;
|
||||
}
|
||||
|
||||
export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay }: AppShellProps) {
|
||||
export function AppShell({ sidebar, content, detailPanel, bottomPanel, overlay }: AppShellProps) {
|
||||
return (
|
||||
<div className="relative flex h-screen bg-[var(--color-surface-0)] text-[var(--color-text-primary)]">
|
||||
{/* Full-width drag region matching the title bar overlay height */}
|
||||
@@ -19,7 +19,7 @@ export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay
|
||||
{sidebar}
|
||||
</aside>
|
||||
|
||||
{/* Main content + terminal */}
|
||||
{/* Main content + bottom panel */}
|
||||
<main className="relative flex min-w-0 flex-1 flex-col">
|
||||
{/* Ambient glow behind active content area */}
|
||||
<div
|
||||
@@ -27,7 +27,7 @@ export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay
|
||||
style={{ background: 'var(--gradient-glow)' }}
|
||||
/>
|
||||
<div className="relative min-h-0 flex-1">{content}</div>
|
||||
{terminalPanel}
|
||||
{bottomPanel}
|
||||
</main>
|
||||
|
||||
{/* Detail panel */}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useRef, useState, type ReactNode } from 'react';
|
||||
import { GitBranch, Minus, TerminalSquare, X } from 'lucide-react';
|
||||
|
||||
/* ── Constants ────────────────────────────────────────────── */
|
||||
|
||||
const MIN_HEIGHT = 120;
|
||||
const MAX_HEIGHT_FRACTION = 0.7;
|
||||
const DEFAULT_HEIGHT = 280;
|
||||
|
||||
/* ── Types ────────────────────────────────────────────────── */
|
||||
|
||||
export type BottomPanelTab = 'terminal' | 'git';
|
||||
|
||||
/* ── BottomPanel ──────────────────────────────────────────── */
|
||||
|
||||
interface BottomPanelProps {
|
||||
activeTab: BottomPanelTab;
|
||||
onTabChange: (tab: BottomPanelTab) => void;
|
||||
onClose: () => void;
|
||||
height: number;
|
||||
onHeightChange: (height: number) => void;
|
||||
terminalContent: ReactNode;
|
||||
gitContent: ReactNode;
|
||||
showGitTab: boolean;
|
||||
terminalRunning?: boolean;
|
||||
gitDirty?: boolean;
|
||||
}
|
||||
|
||||
export function BottomPanel({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
onClose,
|
||||
height,
|
||||
onHeightChange,
|
||||
terminalContent,
|
||||
gitContent,
|
||||
showGitTab,
|
||||
terminalRunning,
|
||||
gitDirty,
|
||||
}: BottomPanelProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef<{ y: number; height: number } | null>(null);
|
||||
|
||||
const handleDragStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragStartRef.current = { y: e.clientY, height };
|
||||
setIsDragging(true);
|
||||
|
||||
const handleDragMove = (moveEvent: MouseEvent) => {
|
||||
if (!dragStartRef.current) return;
|
||||
const maxHeight = window.innerHeight * MAX_HEIGHT_FRACTION;
|
||||
const delta = dragStartRef.current.y - moveEvent.clientY;
|
||||
const nextHeight = Math.max(MIN_HEIGHT, Math.min(maxHeight, dragStartRef.current.height + delta));
|
||||
onHeightChange(nextHeight);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
dragStartRef.current = null;
|
||||
document.removeEventListener('mousemove', handleDragMove);
|
||||
document.removeEventListener('mouseup', handleDragEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleDragMove);
|
||||
document.addEventListener('mouseup', handleDragEnd);
|
||||
}, [height, onHeightChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col border-t border-[var(--color-border)] bg-[var(--color-surface-0)]"
|
||||
style={{ height, minHeight: MIN_HEIGHT }}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className={`h-1 shrink-0 cursor-row-resize transition-colors ${isDragging ? 'bg-[var(--color-accent)]/40' : 'hover:bg-[var(--color-surface-3)]/60'}`}
|
||||
onMouseDown={handleDragStart}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize panel"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
onHeightChange(Math.min(window.innerHeight * MAX_HEIGHT_FRACTION, height + 20));
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
onHeightChange(Math.max(MIN_HEIGHT, height - 20));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex h-8 shrink-0 items-center border-b border-[var(--color-border)] px-1">
|
||||
{/* Terminal tab */}
|
||||
<button
|
||||
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors duration-100 ${
|
||||
activeTab === 'terminal'
|
||||
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-2)]/50 hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
onClick={() => onTabChange('terminal')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'terminal'}
|
||||
>
|
||||
{terminalRunning && <span className="size-1.5 shrink-0 rounded-full bg-emerald-400" />}
|
||||
<TerminalSquare className="size-3" />
|
||||
Terminal
|
||||
</button>
|
||||
|
||||
{/* Git tab */}
|
||||
{showGitTab && (
|
||||
<button
|
||||
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors duration-100 ${
|
||||
activeTab === 'git'
|
||||
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-2)]/50 hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
onClick={() => onTabChange('git')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'git'}
|
||||
>
|
||||
{gitDirty && <span className="size-1.5 shrink-0 rounded-full bg-[var(--color-status-warning)]" />}
|
||||
<GitBranch className="size-3" />
|
||||
Git
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Minimize */}
|
||||
<button
|
||||
aria-label="Minimize panel"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<Minus className="size-3" />
|
||||
</button>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
aria-label="Close panel"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-red-400"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content — both always mounted, only active one visible */}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div className={`absolute inset-0 flex flex-col ${activeTab === 'terminal' ? '' : 'invisible'}`}>
|
||||
{terminalContent}
|
||||
</div>
|
||||
{showGitTab && (
|
||||
<div className={`absolute inset-0 flex flex-col overflow-y-auto ${activeTab === 'git' ? '' : 'hidden'}`}>
|
||||
{gitContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { DEFAULT_HEIGHT, MIN_HEIGHT };
|
||||
@@ -9,7 +9,7 @@ import { MessageEditComposer } from '@renderer/components/chat/MessageEditCompos
|
||||
import { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner';
|
||||
import { McpAuthBanner } from '@renderer/components/chat/McpAuthBanner';
|
||||
import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
|
||||
import { InlineApprovalPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
|
||||
import { InlineApprovalPill, InlineGitPill, 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';
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from '@shared/domain/models';
|
||||
import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern';
|
||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||
import { resolveSessionToolingSelection, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
|
||||
import { resolveSessionToolingSelection, type ChatMessageRecord, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
|
||||
import {
|
||||
countApprovedToolsInGroups,
|
||||
groupApprovalToolsByProvider,
|
||||
@@ -41,6 +41,10 @@ import {
|
||||
|
||||
/* ── ChatPane ──────────────────────────────────────────────── */
|
||||
|
||||
type DisplayItem =
|
||||
| { type: 'message'; message: ChatMessageRecord }
|
||||
| { type: 'thinking-group'; messages: ChatMessageRecord[]; turnStartedAt?: string };
|
||||
|
||||
interface ChatPaneProps {
|
||||
project: ProjectRecord;
|
||||
pattern: PatternDefinition;
|
||||
@@ -53,6 +57,8 @@ interface ChatPaneProps {
|
||||
activeSubagents?: ReadonlyArray<ActiveSubagent>;
|
||||
terminalOpen?: boolean;
|
||||
terminalRunning?: boolean;
|
||||
gitPanelOpen?: boolean;
|
||||
gitDirty?: boolean;
|
||||
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>;
|
||||
onCancelTurn?: () => void;
|
||||
onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>;
|
||||
@@ -62,6 +68,7 @@ interface ChatPaneProps {
|
||||
onDismissMcpAuth?: () => void;
|
||||
onAuthenticateMcp?: () => void;
|
||||
onTerminalToggle?: () => void;
|
||||
onGitToggle?: () => void;
|
||||
onUpdateSessionModelConfig?: (config: {
|
||||
model: string;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
@@ -87,6 +94,8 @@ export function ChatPane({
|
||||
activeSubagents,
|
||||
terminalOpen,
|
||||
terminalRunning,
|
||||
gitPanelOpen,
|
||||
gitDirty,
|
||||
onSend,
|
||||
onCancelTurn,
|
||||
onResolveApproval,
|
||||
@@ -96,6 +105,7 @@ export function ChatPane({
|
||||
onDismissMcpAuth,
|
||||
onAuthenticateMcp,
|
||||
onTerminalToggle,
|
||||
onGitToggle,
|
||||
onUpdateSessionModelConfig,
|
||||
onUpdateSessionTooling,
|
||||
onUpdateSessionApprovalSettings,
|
||||
@@ -116,28 +126,51 @@ 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 = [];
|
||||
|
||||
const displayItems = useMemo(() => {
|
||||
const runsByTrigger = new Map(session.runs.map((r) => [r.triggerMessageId, r]));
|
||||
const items: DisplayItem[] = [];
|
||||
let pendingThinking: ChatMessageRecord[] = [];
|
||||
let lastUserMessageId: string | undefined;
|
||||
|
||||
for (const message of session.messages) {
|
||||
if (message.messageKind === 'thinking') {
|
||||
thinking.push(message);
|
||||
pendingThinking.push(message);
|
||||
} else {
|
||||
visible.push(message);
|
||||
if (pendingThinking.length > 0) {
|
||||
const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined;
|
||||
items.push({ type: 'thinking-group', messages: pendingThinking, turnStartedAt: run?.startedAt });
|
||||
pendingThinking = [];
|
||||
}
|
||||
items.push({ type: 'message', message });
|
||||
if (message.role === 'user') {
|
||||
lastUserMessageId = message.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { visibleMessages: visible, thinkingMessages: thinking };
|
||||
}, [session.messages]);
|
||||
const lastAssistantIndex = useMemo(() => {
|
||||
for (let i = visibleMessages.length - 1; i >= 0; i--) {
|
||||
if (visibleMessages[i].role === 'assistant') return i;
|
||||
|
||||
if (pendingThinking.length > 0) {
|
||||
const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined;
|
||||
items.push({ type: 'thinking-group', messages: pendingThinking, turnStartedAt: run?.startedAt });
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [session.messages, session.runs]);
|
||||
|
||||
const lastThinkingGroupIndex = useMemo(() => {
|
||||
for (let i = displayItems.length - 1; i >= 0; i--) {
|
||||
if (displayItems[i].type === 'thinking-group') return i;
|
||||
}
|
||||
return -1;
|
||||
}, [visibleMessages]);
|
||||
const turnStartedAt = useMemo(() => {
|
||||
if (session.runs.length === 0) return undefined;
|
||||
return session.runs[0].startedAt;
|
||||
}, [session.runs]);
|
||||
}, [displayItems]);
|
||||
|
||||
const lastAssistantId = useMemo(() => {
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
const m = session.messages[i];
|
||||
if (m.role === 'assistant' && m.messageKind !== 'thinking') return m.id;
|
||||
}
|
||||
return undefined;
|
||||
}, [session.messages]);
|
||||
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;
|
||||
@@ -298,17 +331,34 @@ export function ChatPane({
|
||||
{isScratchpad
|
||||
? `Scratchpad · ${pattern.name}`
|
||||
: `${project.name} · ${pattern.name} · ${pattern.mode}`}
|
||||
{!isScratchpad && project.git?.status === 'ready' && (
|
||||
<span className="ml-2 inline-flex items-center gap-1 text-[var(--color-text-muted)]">
|
||||
<GitBranch className="inline size-2.5" />
|
||||
{project.git.branch ?? project.git.head?.shortHash ?? 'HEAD'}
|
||||
{project.git.isDirty && (
|
||||
<Circle className="inline size-1.5 fill-amber-500 text-amber-500" />
|
||||
)}
|
||||
{(project.git.ahead ?? 0) > 0 && <span>↑{project.git.ahead}</span>}
|
||||
{(project.git.behind ?? 0) > 0 && <span>↓{project.git.behind}</span>}
|
||||
</span>
|
||||
)}
|
||||
{!isScratchpad && project.git?.status === 'ready' && (() => {
|
||||
const git = project.git;
|
||||
const tipLines: string[] = [git.branch ?? git.head?.shortHash ?? 'HEAD'];
|
||||
if (git.changes) {
|
||||
const bd: string[] = [];
|
||||
if (git.changes.staged > 0) bd.push(`${git.changes.staged} staged`);
|
||||
if (git.changes.unstaged > 0) bd.push(`${git.changes.unstaged} modified`);
|
||||
if (git.changes.untracked > 0) bd.push(`${git.changes.untracked} untracked`);
|
||||
if (bd.length > 0) tipLines.push(bd.join(', '));
|
||||
}
|
||||
if (git.ahead || git.behind) {
|
||||
const sync: string[] = [];
|
||||
if (git.ahead) sync.push(`${git.ahead} ahead`);
|
||||
if (git.behind) sync.push(`${git.behind} behind`);
|
||||
tipLines.push(sync.join(', '));
|
||||
}
|
||||
return (
|
||||
<span className="ml-2 inline-flex items-center gap-1 text-[var(--color-text-muted)]" title={tipLines.join('\n')}>
|
||||
<GitBranch className="inline size-2.5" />
|
||||
{git.branch ?? git.head?.shortHash ?? 'HEAD'}
|
||||
{git.isDirty && (
|
||||
<Circle className="inline size-1.5 fill-amber-500 text-amber-500" />
|
||||
)}
|
||||
{(git.ahead ?? 0) > 0 && <span>↑{git.ahead}</span>}
|
||||
{(git.behind ?? 0) > 0 && <span>↓{git.behind}</span>}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -375,11 +425,25 @@ export function ChatPane({
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{visibleMessages.map((message, index) => {
|
||||
{displayItems.map((item, itemIndex) => {
|
||||
if (item.type === 'thinking-group') {
|
||||
const isLastThinkingGroup = itemIndex === lastThinkingGroupIndex;
|
||||
return (
|
||||
<div key={`thinking-${item.messages[0].id}`} className="py-2">
|
||||
<ThinkingProcess
|
||||
messages={item.messages}
|
||||
isActive={isSessionBusy && isLastThinkingGroup}
|
||||
turnStartedAt={item.turnStartedAt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const message = item.message;
|
||||
const isUser = message.role === 'user';
|
||||
const isEditing = editingMessageId === message.id;
|
||||
const isLastAssistant = index === lastAssistantIndex;
|
||||
const phase = getAssistantMessagePhase(session, message, index);
|
||||
const isLastAssistant = message.id === lastAssistantId;
|
||||
const phase = getAssistantMessagePhase(session, message);
|
||||
const assistantContainerClass =
|
||||
phase === 'thinking'
|
||||
? 'border-[var(--color-accent-sky)]/20 bg-[var(--color-accent-sky)]/5'
|
||||
@@ -393,19 +457,9 @@ 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 key={message.id}>
|
||||
{showThinkingBefore && (
|
||||
<div className="py-2">
|
||||
<ThinkingProcess
|
||||
messages={thinkingMessages}
|
||||
isActive={isSessionBusy}
|
||||
turnStartedAt={turnStartedAt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="message-enter group py-3" data-message-id={message.id}>
|
||||
<div className="flex gap-3">
|
||||
<div
|
||||
@@ -481,13 +535,7 @@ export function ChatPane({
|
||||
)}
|
||||
</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} />
|
||||
)}
|
||||
<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)]" />
|
||||
)}
|
||||
@@ -500,15 +548,6 @@ export function ChatPane({
|
||||
</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">
|
||||
@@ -734,6 +773,13 @@ export function ChatPane({
|
||||
onToggle={onTerminalToggle}
|
||||
/>
|
||||
)}
|
||||
{onGitToggle && !isScratchpad && (
|
||||
<InlineGitPill
|
||||
isDirty={!!gitDirty}
|
||||
isOpen={!!gitPanelOpen}
|
||||
onToggle={onGitToggle}
|
||||
/>
|
||||
)}
|
||||
{!isScratchpad && promptFiles.length > 0 && (
|
||||
<InlinePromptPill
|
||||
disabled={isComposerDisabled}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { CliInstallGuide } from '@renderer/components/settings/CliInstallGuide';
|
||||
import type {
|
||||
SidecarConnectionDiagnostics,
|
||||
SidecarConnectionStatus,
|
||||
@@ -391,8 +392,15 @@ export function CopilotStatusCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action hint for non-ready states */}
|
||||
{!isHealthy && config.actionLabel && (
|
||||
{/* Installation guide for missing CLI */}
|
||||
{connection.status === 'copilot-cli-missing' && (
|
||||
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-glass)] p-4">
|
||||
<CliInstallGuide isRefreshing={isRefreshing} onRefresh={onRefresh} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action hint for other non-ready states */}
|
||||
{!isHealthy && connection.status !== 'copilot-cli-missing' && config.actionLabel && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-glass)] px-3 py-2.5">
|
||||
<div className="mt-0.5 shrink-0">{config.actionIcon}</div>
|
||||
<p className="text-[12px] leading-relaxed text-[var(--color-text-secondary)]">{config.actionLabel}</p>
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cloud,
|
||||
FileCode2,
|
||||
FileMinus2,
|
||||
FilePlus2,
|
||||
GitBranch,
|
||||
GitCommitHorizontal,
|
||||
History,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { getElectronApi } from '@renderer/lib/electronApi';
|
||||
import type {
|
||||
ProjectGitBranchSummary,
|
||||
ProjectGitCommitLogEntry,
|
||||
ProjectGitDetails,
|
||||
ProjectGitDiffPreview,
|
||||
ProjectGitWorkingTreeFile,
|
||||
} from '@shared/domain/project';
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────── */
|
||||
|
||||
function fileBaseName(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
const lastSlash = normalized.lastIndexOf('/');
|
||||
return lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
|
||||
}
|
||||
|
||||
function fileDir(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
const lastSlash = normalized.lastIndexOf('/');
|
||||
return lastSlash > 0 ? normalized.slice(0, lastSlash + 1) : '';
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const secs = Math.floor(diff / 1000);
|
||||
if (secs < 60) return 'just now';
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days === 1) return 'yesterday';
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/* ── Section header ────────────────────────────────────────── */
|
||||
|
||||
function SectionHeader({
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
count?: number;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left transition-colors duration-100 hover:bg-[var(--color-surface-2)]/30"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown className="size-2.5 text-[var(--color-text-muted)]" />
|
||||
: <ChevronRight className="size-2.5 text-[var(--color-text-muted)]" />}
|
||||
{icon}
|
||||
<span className="font-display text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
{label}
|
||||
</span>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className="rounded-full bg-[var(--color-surface-3)] px-1.5 py-0.5 text-[8px] font-semibold tabular-nums text-[var(--color-text-muted)]">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── File status icon ──────────────────────────────────────── */
|
||||
|
||||
function fileIcon(file: ProjectGitWorkingTreeFile) {
|
||||
if (file.stagedStatus === 'added' || file.unstagedStatus === 'untracked') {
|
||||
return <FilePlus2 className="size-3 shrink-0 text-[var(--color-status-success)]" />;
|
||||
}
|
||||
if (file.stagedStatus === 'deleted' || file.unstagedStatus === 'deleted') {
|
||||
return <FileMinus2 className="size-3 shrink-0 text-[var(--color-status-error)]" />;
|
||||
}
|
||||
return <FileCode2 className="size-3 shrink-0 text-[var(--color-accent-sky)]" />;
|
||||
}
|
||||
|
||||
/* ── Changed file row ──────────────────────────────────────── */
|
||||
|
||||
function ChangedFileEntry({
|
||||
file,
|
||||
projectId,
|
||||
}: {
|
||||
file: ProjectGitWorkingTreeFile;
|
||||
projectId: string;
|
||||
}) {
|
||||
const api = getElectronApi();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [preview, setPreview] = useState<ProjectGitDiffPreview>();
|
||||
|
||||
const dir = fileDir(file.path);
|
||||
const base = fileBaseName(file.path);
|
||||
const status = file.stagedStatus ?? file.unstagedStatus ?? 'modified';
|
||||
|
||||
const handleToggle = useCallback(async () => {
|
||||
if (expanded) {
|
||||
setExpanded(false);
|
||||
return;
|
||||
}
|
||||
setExpanded(true);
|
||||
if (!preview) {
|
||||
try {
|
||||
const result = await api.getProjectGitFilePreview({
|
||||
projectId,
|
||||
file: { path: file.path, previousPath: file.previousPath },
|
||||
});
|
||||
if (result) setPreview(result);
|
||||
} catch {
|
||||
// Preview is best-effort
|
||||
}
|
||||
}
|
||||
}, [api, expanded, file.path, file.previousPath, preview, projectId]);
|
||||
|
||||
return (
|
||||
<div className="border-b border-[var(--color-border-subtle)] last:border-b-0">
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 px-3 py-[5px] text-left text-[10px] transition-colors duration-150 hover:bg-[var(--color-surface-2)]/40"
|
||||
onClick={() => void handleToggle()}
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`size-2 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${expanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
{fileIcon(file)}
|
||||
<span className="min-w-0 flex-1 truncate font-mono">
|
||||
{dir && <span className="text-[var(--color-text-muted)]">{dir}</span>}
|
||||
<span className="text-[var(--color-text-primary)]">{base}</span>
|
||||
</span>
|
||||
<span className="shrink-0 rounded px-1 py-px text-[7px] font-semibold uppercase tracking-wider bg-[var(--color-surface-3)] text-[var(--color-text-muted)]">
|
||||
{status}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && preview && (
|
||||
<div className="border-t border-[var(--color-border-subtle)]">
|
||||
<pre className="max-h-40 overflow-auto bg-[var(--color-surface-0)] px-3 py-1 font-mono text-[9px] leading-relaxed">
|
||||
{preview.diff
|
||||
? preview.diff.split('\n').map((line, i) => {
|
||||
let cls = 'text-[var(--color-text-secondary)]';
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) cls = 'text-[var(--color-status-success)]';
|
||||
else if (line.startsWith('-') && !line.startsWith('---')) cls = 'text-[var(--color-status-error)]';
|
||||
else if (line.startsWith('@@')) cls = 'text-[var(--color-accent-sky)]';
|
||||
return <div key={i} className={cls}>{line || '\u00A0'}</div>;
|
||||
})
|
||||
: preview.newFileContents
|
||||
? preview.newFileContents.split('\n').map((line, i) => (
|
||||
<div key={i} className="text-[var(--color-text-secondary)]">{line || '\u00A0'}</div>
|
||||
))
|
||||
: <div className="text-[var(--color-text-muted)] italic">Binary file</div>}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Branch row ────────────────────────────────────────────── */
|
||||
|
||||
function BranchRow({
|
||||
branch,
|
||||
onSwitch,
|
||||
onDelete,
|
||||
isSwitching,
|
||||
}: {
|
||||
branch: ProjectGitBranchSummary;
|
||||
onSwitch: () => void;
|
||||
onDelete: () => void;
|
||||
isSwitching: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-3 py-[5px] text-[10px]">
|
||||
<GitBranch className={`size-3 shrink-0 ${branch.isCurrent ? 'text-[var(--color-status-success)]' : 'text-[var(--color-text-muted)]'}`} />
|
||||
<span className={`min-w-0 flex-1 truncate font-mono ${branch.isCurrent ? 'font-medium text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'}`}>
|
||||
{branch.name}
|
||||
</span>
|
||||
{branch.isCurrent && (
|
||||
<span className="rounded px-1 py-px text-[7px] font-semibold uppercase tracking-wider bg-[var(--color-status-success)]/10 text-[var(--color-status-success)]">
|
||||
current
|
||||
</span>
|
||||
)}
|
||||
{!branch.isCurrent && (
|
||||
<>
|
||||
<button
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={onSwitch}
|
||||
type="button"
|
||||
title={`Switch to ${branch.name}`}
|
||||
disabled={isSwitching}
|
||||
>
|
||||
{isSwitching ? <Loader2 className="size-2.5 animate-spin" /> : <Check className="size-2.5" />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
title={`Delete ${branch.name}`}
|
||||
>
|
||||
<Trash2 className="size-2.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Commit log row ────────────────────────────────────────── */
|
||||
|
||||
function CommitRow({ commit }: { commit: ProjectGitCommitLogEntry }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 px-3 py-[5px] text-[10px]">
|
||||
<GitCommitHorizontal className="mt-0.5 size-3 shrink-0 text-[var(--color-text-muted)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[var(--color-text-secondary)]">
|
||||
{commit.subject}
|
||||
</p>
|
||||
<p className="flex items-center gap-1.5 text-[9px] text-[var(--color-text-muted)]">
|
||||
<span className="font-mono">{commit.shortHash}</span>
|
||||
<span>·</span>
|
||||
<span>{commit.authorName}</span>
|
||||
<span>·</span>
|
||||
<span>{relativeTime(commit.committedAt)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Create branch dialog ──────────────────────────────────── */
|
||||
|
||||
function CreateBranchForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
onSubmit: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 border-b border-[var(--color-border-subtle)] px-3 py-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
className="min-w-0 flex-1 rounded border border-[var(--color-border)] bg-[var(--color-surface-0)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && name.trim()) {
|
||||
e.preventDefault();
|
||||
onSubmit(name.trim());
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
placeholder="Branch name…"
|
||||
value={name}
|
||||
/>
|
||||
<button
|
||||
className="rounded p-1 text-[var(--color-status-success)] transition-colors duration-100 hover:bg-[var(--color-status-success)]/10 disabled:opacity-40"
|
||||
disabled={!name.trim()}
|
||||
onClick={() => onSubmit(name.trim())}
|
||||
type="button"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)]"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main export ───────────────────────────────────────────── */
|
||||
|
||||
interface GitPanelProps {
|
||||
projectId: string;
|
||||
onDirtyChange?: (isDirty: boolean) => void;
|
||||
}
|
||||
|
||||
export function GitPanel({ projectId, onDirtyChange }: GitPanelProps) {
|
||||
const api = getElectronApi();
|
||||
|
||||
const [details, setDetails] = useState<ProjectGitDetails>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [showChanges, setShowChanges] = useState(true);
|
||||
const [showBranches, setShowBranches] = useState(false);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [showCreateBranch, setShowCreateBranch] = useState(false);
|
||||
const [switchingBranch, setSwitchingBranch] = useState<string>();
|
||||
const [operationError, setOperationError] = useState<string>();
|
||||
const [networkBusy, setNetworkBusy] = useState<'push' | 'pull' | 'fetch'>();
|
||||
|
||||
const loadDetails = useCallback(async (quiet = false) => {
|
||||
if (!quiet) setLoading(true);
|
||||
else setRefreshing(true);
|
||||
|
||||
try {
|
||||
const result = await api.getProjectGitDetails({ projectId });
|
||||
setDetails(result);
|
||||
setOperationError(undefined);
|
||||
onDirtyChange?.(result.context.isDirty ?? false);
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [api, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDetails();
|
||||
}, [loadDetails]);
|
||||
|
||||
const ctx = details?.context;
|
||||
const workingTree = details?.workingTree;
|
||||
const branches = details?.branches ?? [];
|
||||
const commits = details?.recentCommits ?? [];
|
||||
const changedFiles = workingTree?.files ?? [];
|
||||
|
||||
const handlePush = useCallback(async () => {
|
||||
setNetworkBusy('push');
|
||||
setOperationError(undefined);
|
||||
try {
|
||||
await api.pushProjectGit({ projectId });
|
||||
await loadDetails(true);
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setNetworkBusy(undefined);
|
||||
}
|
||||
}, [api, loadDetails, projectId]);
|
||||
|
||||
const handlePull = useCallback(async () => {
|
||||
setNetworkBusy('pull');
|
||||
setOperationError(undefined);
|
||||
try {
|
||||
await api.pullProjectGit({ projectId });
|
||||
await loadDetails(true);
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setNetworkBusy(undefined);
|
||||
}
|
||||
}, [api, loadDetails, projectId]);
|
||||
|
||||
const handleFetch = useCallback(async () => {
|
||||
setNetworkBusy('fetch');
|
||||
setOperationError(undefined);
|
||||
try {
|
||||
await api.fetchProjectGit({ projectId });
|
||||
await loadDetails(true);
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setNetworkBusy(undefined);
|
||||
}
|
||||
}, [api, loadDetails, projectId]);
|
||||
|
||||
const handleSwitchBranch = useCallback(async (name: string) => {
|
||||
setSwitchingBranch(name);
|
||||
setOperationError(undefined);
|
||||
try {
|
||||
await api.switchProjectGitBranch({ projectId, name });
|
||||
await loadDetails(true);
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setSwitchingBranch(undefined);
|
||||
}
|
||||
}, [api, loadDetails, projectId]);
|
||||
|
||||
const handleDeleteBranch = useCallback(async (name: string) => {
|
||||
setOperationError(undefined);
|
||||
try {
|
||||
await api.deleteProjectGitBranch({ projectId, name });
|
||||
await loadDetails(true);
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [api, loadDetails, projectId]);
|
||||
|
||||
const handleCreateBranch = useCallback(async (name: string) => {
|
||||
setShowCreateBranch(false);
|
||||
setOperationError(undefined);
|
||||
try {
|
||||
await api.createProjectGitBranch({ projectId, name, checkout: true });
|
||||
await loadDetails(true);
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [api, loadDetails, projectId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-4 animate-spin text-[var(--color-text-muted)]" aria-label="Loading git status" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!ctx || ctx.status !== 'ready') {
|
||||
return (
|
||||
<div className="px-3 py-4 text-center text-[11px] text-[var(--color-text-muted)]">
|
||||
{ctx?.status === 'not-repository'
|
||||
? 'Not a git repository'
|
||||
: ctx?.status === 'git-missing'
|
||||
? 'Git is not installed'
|
||||
: ctx?.errorMessage ?? 'Unable to read git status'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Branch header + network actions */}
|
||||
<div className="border-b border-[var(--color-border-subtle)] px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<GitBranch className="size-3 text-[var(--color-accent-sky)]" />
|
||||
<span className="font-mono text-[11px] font-medium text-[var(--color-text-primary)]">
|
||||
{ctx.branch ?? 'detached HEAD'}
|
||||
</span>
|
||||
|
||||
{ctx.upstream && (
|
||||
<span className="text-[9px] text-[var(--color-text-muted)]">
|
||||
{ctx.ahead !== undefined && ctx.ahead > 0 && <span className="text-[var(--color-status-success)]">↑{ctx.ahead}</span>}
|
||||
{ctx.behind !== undefined && ctx.behind > 0 && <span className="ml-0.5 text-[var(--color-status-warning)]">↓{ctx.behind}</span>}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Network buttons */}
|
||||
<button
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-40"
|
||||
disabled={!!networkBusy}
|
||||
onClick={() => void handleFetch()}
|
||||
title="Fetch"
|
||||
type="button"
|
||||
>
|
||||
{networkBusy === 'fetch' ? <Loader2 className="size-3 animate-spin" /> : <Cloud className="size-3" />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-40"
|
||||
disabled={!!networkBusy}
|
||||
onClick={() => void handlePull()}
|
||||
title="Pull"
|
||||
type="button"
|
||||
>
|
||||
{networkBusy === 'pull' ? <Loader2 className="size-3 animate-spin" /> : <ArrowDownToLine className="size-3" />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-40"
|
||||
disabled={!!networkBusy}
|
||||
onClick={() => void handlePush()}
|
||||
title="Push"
|
||||
type="button"
|
||||
>
|
||||
{networkBusy === 'push' ? <Loader2 className="size-3 animate-spin" /> : <ArrowUpFromLine className="size-3" />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => void loadDetails(true)}
|
||||
title="Refresh"
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw className={`size-3 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ctx.isDirty && (
|
||||
<div className="mt-1 text-[9px] text-[var(--color-status-warning)]">
|
||||
{ctx.changedFileCount} uncommitted {ctx.changedFileCount === 1 ? 'change' : 'changes'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{operationError && (
|
||||
<div className="flex items-start gap-1.5 border-b border-[var(--color-status-error)]/20 bg-[var(--color-status-error)]/5 px-3 py-1.5 text-[9px] text-[var(--color-status-error)]" role="alert">
|
||||
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
|
||||
<span className="min-w-0 flex-1">{operationError}</span>
|
||||
<button
|
||||
className="shrink-0 rounded p-0.5 transition-colors duration-100 hover:bg-[var(--color-status-error)]/10"
|
||||
onClick={() => setOperationError(undefined)}
|
||||
type="button"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changed files */}
|
||||
<SectionHeader
|
||||
count={changedFiles.length}
|
||||
expanded={showChanges}
|
||||
icon={<FileCode2 className="size-3 text-[var(--color-accent-sky)]" />}
|
||||
label="Changes"
|
||||
onToggle={() => setShowChanges(!showChanges)}
|
||||
/>
|
||||
{showChanges && (
|
||||
<div>
|
||||
{changedFiles.length === 0 ? (
|
||||
<p className="px-3 py-2 text-[10px] text-[var(--color-text-muted)]">Working tree clean</p>
|
||||
) : (
|
||||
changedFiles.map((file) => (
|
||||
<ChangedFileEntry
|
||||
file={file}
|
||||
key={file.path}
|
||||
projectId={projectId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branches */}
|
||||
<SectionHeader
|
||||
count={branches.length}
|
||||
expanded={showBranches}
|
||||
icon={<GitBranch className="size-3 text-[var(--color-status-success)]" />}
|
||||
label="Branches"
|
||||
onToggle={() => setShowBranches(!showBranches)}
|
||||
/>
|
||||
{showBranches && (
|
||||
<div>
|
||||
{/* New branch button */}
|
||||
{!showCreateBranch && (
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 px-3 py-[5px] text-[10px] text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-2)]/40 hover:text-[var(--color-text-accent)]"
|
||||
onClick={() => setShowCreateBranch(true)}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
New branch
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showCreateBranch && (
|
||||
<CreateBranchForm
|
||||
onCancel={() => setShowCreateBranch(false)}
|
||||
onSubmit={(name) => void handleCreateBranch(name)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{branches.map((branch) => (
|
||||
<BranchRow
|
||||
branch={branch}
|
||||
isSwitching={switchingBranch === branch.name}
|
||||
key={branch.name}
|
||||
onDelete={() => void handleDeleteBranch(branch.name)}
|
||||
onSwitch={() => void handleSwitchBranch(branch.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commit history */}
|
||||
<SectionHeader
|
||||
count={commits.length}
|
||||
expanded={showHistory}
|
||||
icon={<History className="size-3 text-[var(--color-text-muted)]" />}
|
||||
label="Recent Commits"
|
||||
onToggle={() => setShowHistory(!showHistory)}
|
||||
/>
|
||||
{showHistory && (
|
||||
<div>
|
||||
{commits.length === 0 ? (
|
||||
<p className="px-3 py-2 text-[10px] text-[var(--color-text-muted)]">No commit history</p>
|
||||
) : (
|
||||
commits.map((commit) => (
|
||||
<CommitRow commit={commit} key={commit.hash} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleDot,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
Play,
|
||||
Wrench,
|
||||
@@ -25,8 +26,10 @@ import {
|
||||
type CollapsedTimelineEvent,
|
||||
} from '@renderer/lib/runTimelineFormatting';
|
||||
import type { OrchestrationMode } from '@shared/domain/pattern';
|
||||
import type { ProjectGitFileReference, ProjectGitWorkingTreeSnapshot } from '@shared/domain/project';
|
||||
import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline';
|
||||
import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
|
||||
import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard';
|
||||
|
||||
/* ── Mode accent colours (shared with ActivityPanel) ───────── */
|
||||
|
||||
@@ -229,6 +232,39 @@ function CollapsedEventRow({
|
||||
return <TimelineEventRow event={item.event} isLast={isLast} onJumpToMessage={onJumpToMessage} />;
|
||||
}
|
||||
|
||||
/* ── Git baseline snapshot ──────────────────────────────────── */
|
||||
|
||||
function RunGitBaseline({ snapshot }: { snapshot: ProjectGitWorkingTreeSnapshot }) {
|
||||
const { branch, changes, changedFileCount } = snapshot;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (changes.staged > 0) parts.push(`${changes.staged} staged`);
|
||||
if (changes.unstaged > 0) parts.push(`${changes.unstaged} modified`);
|
||||
if (changes.untracked > 0) parts.push(`${changes.untracked} untracked`);
|
||||
if (changes.conflicted > 0) parts.push(`${changes.conflicted} conflicted`);
|
||||
|
||||
return (
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[9px] text-[var(--color-text-muted)]">
|
||||
<GitBranch className="size-2.5 shrink-0" />
|
||||
{branch && <span className="font-mono text-[var(--color-text-secondary)]">{branch}</span>}
|
||||
{changedFileCount > 0 ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="text-[var(--color-status-warning)]">{changedFileCount} changed</span>
|
||||
{parts.length > 0 && (
|
||||
<span className="text-[var(--color-text-muted)]">({parts.join(', ')})</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="text-[var(--color-status-success)]">clean</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Run card ──────────────────────────────────────────────── */
|
||||
|
||||
function RunCard({
|
||||
@@ -236,11 +272,17 @@ function RunCard({
|
||||
expanded,
|
||||
onToggle,
|
||||
onJumpToMessage,
|
||||
sessionId,
|
||||
onDiscard,
|
||||
onOpenCommitComposer,
|
||||
}: {
|
||||
run: SessionRunRecord;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onJumpToMessage?: (messageId: string) => void;
|
||||
sessionId: string;
|
||||
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
||||
onOpenCommitComposer?: () => void;
|
||||
}) {
|
||||
const accent = modeAccent[run.patternMode] ?? modeAccent.single;
|
||||
const statusStyle = runStatusStyles[run.status];
|
||||
@@ -294,6 +336,11 @@ function RunCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Git baseline */}
|
||||
{run.preRunGitSnapshot && (
|
||||
<RunGitBaseline snapshot={run.preRunGitSnapshot} />
|
||||
)}
|
||||
|
||||
{/* Timeline events */}
|
||||
<div>
|
||||
{collapsedEvents.map((item, index) => (
|
||||
@@ -312,6 +359,19 @@ function RunCard({
|
||||
Duration: {duration}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Post-run git changes */}
|
||||
{run.postRunGitSummary && run.status !== 'running' && onDiscard && (
|
||||
<div className="mt-2">
|
||||
<RunChangeSummaryCard
|
||||
onDiscard={onDiscard}
|
||||
onOpenCommitComposer={onOpenCommitComposer}
|
||||
runId={run.requestId}
|
||||
sessionId={sessionId}
|
||||
summary={run.postRunGitSummary}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -332,10 +392,13 @@ function EmptyTimeline() {
|
||||
|
||||
interface RunTimelineProps {
|
||||
runs: readonly SessionRunRecord[];
|
||||
sessionId: string;
|
||||
onJumpToMessage?: (messageId: string) => void;
|
||||
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
||||
onOpenCommitComposer?: () => void;
|
||||
}
|
||||
|
||||
export function RunTimeline({ runs, onJumpToMessage }: RunTimelineProps) {
|
||||
export function RunTimeline({ runs, sessionId, onJumpToMessage, onDiscard, onOpenCommitComposer }: RunTimelineProps) {
|
||||
const latestRunId = runs.length > 0 ? runs[0].id : undefined;
|
||||
const [expandedRunId, setExpandedRunId] = useState<string | undefined>(latestRunId);
|
||||
|
||||
@@ -354,9 +417,12 @@ export function RunTimeline({ runs, onJumpToMessage }: RunTimelineProps) {
|
||||
<RunCard
|
||||
expanded={expandedRunId === run.id}
|
||||
key={run.id}
|
||||
onDiscard={onDiscard}
|
||||
onJumpToMessage={onJumpToMessage}
|
||||
onOpenCommitComposer={onOpenCommitComposer}
|
||||
onToggle={() => setExpandedRunId(expandedRunId === run.id ? undefined : run.id)}
|
||||
run={run}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ interface SettingsPanelProps {
|
||||
toolingSettings: WorkspaceToolingSettings;
|
||||
discoveredUserTooling: DiscoveredToolingState;
|
||||
isRefreshingCapabilities: boolean;
|
||||
initialSection?: SettingsSection;
|
||||
onRefreshCapabilities: () => void;
|
||||
onClose: () => void;
|
||||
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
|
||||
@@ -45,13 +46,15 @@ interface SettingsPanelProps {
|
||||
onSetNotificationsEnabled: (enabled: boolean) => void;
|
||||
minimizeToTray: boolean;
|
||||
onSetMinimizeToTray: (enabled: boolean) => void;
|
||||
gitAutoRefreshEnabled: boolean;
|
||||
onSetGitAutoRefreshEnabled: (enabled: boolean) => void;
|
||||
onOpenAppDataFolder: () => void;
|
||||
onResetLocalWorkspace: () => Promise<void>;
|
||||
onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
|
||||
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
||||
}
|
||||
|
||||
type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
|
||||
export type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
|
||||
|
||||
interface NavItem {
|
||||
id: SettingsSection;
|
||||
@@ -111,6 +114,7 @@ export function SettingsPanel({
|
||||
toolingSettings,
|
||||
discoveredUserTooling,
|
||||
isRefreshingCapabilities,
|
||||
initialSection,
|
||||
onRefreshCapabilities,
|
||||
onClose,
|
||||
onSavePattern,
|
||||
@@ -127,12 +131,14 @@ export function SettingsPanel({
|
||||
onSetNotificationsEnabled,
|
||||
minimizeToTray,
|
||||
onSetMinimizeToTray,
|
||||
gitAutoRefreshEnabled,
|
||||
onSetGitAutoRefreshEnabled,
|
||||
onOpenAppDataFolder,
|
||||
onResetLocalWorkspace,
|
||||
onResolveUserDiscoveredTooling,
|
||||
onGetQuota,
|
||||
}: SettingsPanelProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('appearance');
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
|
||||
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
||||
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
|
||||
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
|
||||
@@ -272,6 +278,8 @@ export function SettingsPanel({
|
||||
onSetNotificationsEnabled={onSetNotificationsEnabled}
|
||||
minimizeToTray={minimizeToTray}
|
||||
onSetMinimizeToTray={onSetMinimizeToTray}
|
||||
gitAutoRefreshEnabled={gitAutoRefreshEnabled}
|
||||
onSetGitAutoRefreshEnabled={onSetGitAutoRefreshEnabled}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'connection' && (
|
||||
@@ -336,6 +344,8 @@ function AppearanceSection({
|
||||
onSetNotificationsEnabled,
|
||||
minimizeToTray,
|
||||
onSetMinimizeToTray,
|
||||
gitAutoRefreshEnabled,
|
||||
onSetGitAutoRefreshEnabled,
|
||||
}: {
|
||||
theme: AppearanceTheme;
|
||||
onSetTheme: (theme: AppearanceTheme) => void;
|
||||
@@ -343,6 +353,8 @@ function AppearanceSection({
|
||||
onSetNotificationsEnabled: (enabled: boolean) => void;
|
||||
minimizeToTray: boolean;
|
||||
onSetMinimizeToTray: (enabled: boolean) => void;
|
||||
gitAutoRefreshEnabled: boolean;
|
||||
onSetGitAutoRefreshEnabled: (enabled: boolean) => void;
|
||||
}){
|
||||
return (
|
||||
<div>
|
||||
@@ -432,6 +444,30 @@ function AppearanceSection({
|
||||
</div>
|
||||
<ToggleSwitch enabled={minimizeToTray} />
|
||||
</button>
|
||||
|
||||
{/* Git */}
|
||||
<div className="mt-8 mb-1">
|
||||
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Git</h3>
|
||||
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
||||
Control how Aryx monitors your repositories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="mt-4 flex w-full items-center justify-between rounded-lg border border-[var(--color-border)] px-4 py-3 text-left transition hover:bg-[var(--color-surface-3)]/40"
|
||||
onClick={() => onSetGitAutoRefreshEnabled(!gitAutoRefreshEnabled)}
|
||||
type="button"
|
||||
>
|
||||
<div>
|
||||
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
|
||||
Auto-refresh git status
|
||||
</span>
|
||||
<p className="text-[12px] text-[var(--color-text-muted)]">
|
||||
Periodically poll repository status in the background and refresh on window focus
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch enabled={gitAutoRefreshEnabled} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ import { isScratchpadProject, type ProjectRecord, type ProjectGitContext } from
|
||||
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import { querySessions } from '@shared/domain/sessionLibrary';
|
||||
import type { UpdateStatus } from '@shared/contracts/ipc';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
import { UpdateBanner } from '@renderer/components/ui';
|
||||
|
||||
interface SidebarProps {
|
||||
workspace: WorkspaceState;
|
||||
@@ -50,6 +52,9 @@ interface SidebarProps {
|
||||
onSetSessionArchived: (sessionId: string, isArchived: boolean) => void;
|
||||
onDeleteSession: (sessionId: string) => void;
|
||||
onRefreshGitContext: (projectId: string) => void;
|
||||
updateStatus?: UpdateStatus;
|
||||
onViewUpdateDetails?: () => void;
|
||||
onInstallUpdate?: () => void;
|
||||
}
|
||||
|
||||
/* ── Mode icon + accent colour mapping ─────────────────────── */
|
||||
@@ -117,10 +122,27 @@ function GitContextBadge({ git }: { git: ProjectGitContext }) {
|
||||
if (git.ahead) parts.push(`↑${git.ahead}`);
|
||||
if (git.behind) parts.push(`↓${git.behind}`);
|
||||
|
||||
const tooltipLines: string[] = [branchLabel];
|
||||
if (git.changes) {
|
||||
const breakdown: string[] = [];
|
||||
if (git.changes.staged > 0) breakdown.push(`${git.changes.staged} staged`);
|
||||
if (git.changes.unstaged > 0) breakdown.push(`${git.changes.unstaged} modified`);
|
||||
if (git.changes.untracked > 0) breakdown.push(`${git.changes.untracked} untracked`);
|
||||
if (git.changes.conflicted > 0) breakdown.push(`${git.changes.conflicted} conflicted`);
|
||||
if (breakdown.length > 0) tooltipLines.push(breakdown.join(', '));
|
||||
}
|
||||
if (git.ahead || git.behind) {
|
||||
const sync: string[] = [];
|
||||
if (git.ahead) sync.push(`${git.ahead} ahead`);
|
||||
if (git.behind) sync.push(`${git.behind} behind`);
|
||||
tooltipLines.push(sync.join(', '));
|
||||
}
|
||||
if (git.upstream) tooltipLines.push(`→ ${git.upstream}`);
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]" title={parts.join(' · ') || branchLabel}>
|
||||
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]" title={tooltipLines.join('\n')}>
|
||||
<GitBranch className="size-2.5 shrink-0" />
|
||||
<span className="max-w-[80px] truncate">{branchLabel}</span>
|
||||
<span className="max-w-[140px] truncate font-mono">{branchLabel}</span>
|
||||
{git.isDirty && <Circle className="size-1.5 shrink-0 fill-amber-500 text-amber-500" />}
|
||||
</span>
|
||||
);
|
||||
@@ -396,76 +418,93 @@ function ProjectGroup({
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className="group flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-[13px] font-semibold text-[var(--color-text-secondary)] transition-all duration-150 hover:bg-[var(--color-surface-2)]/40 hover:text-[var(--color-text-primary)]"
|
||||
className="group flex w-full flex-col gap-0.5 rounded-lg px-2 py-2 text-left transition-all duration-150 hover:bg-[var(--color-surface-2)]/40"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
type="button"
|
||||
title={`${project.name}\n${project.path}`}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-3 shrink-0 text-[var(--color-text-muted)]" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 shrink-0 text-[var(--color-text-muted)]" />
|
||||
)}
|
||||
{isScratchpad ? (
|
||||
<MessageSquare className="size-3.5 shrink-0 text-[var(--color-text-muted)] transition group-hover:text-[var(--color-accent)]" />
|
||||
) : (
|
||||
<FolderOpen className="size-3.5 shrink-0 text-[var(--color-text-muted)] transition group-hover:text-[var(--color-accent)]" />
|
||||
)}
|
||||
<span className="truncate">{project.name}</span>
|
||||
{/* Row 1 — project identity + hover actions */}
|
||||
<div className="flex w-full items-center gap-2 text-[13px] font-semibold text-[var(--color-text-secondary)] group-hover:text-[var(--color-text-primary)]">
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-3 shrink-0 text-[var(--color-text-muted)]" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 shrink-0 text-[var(--color-text-muted)]" />
|
||||
)}
|
||||
{isScratchpad ? (
|
||||
<MessageSquare className="size-3.5 shrink-0 text-[var(--color-text-muted)] transition group-hover:text-[var(--color-accent)]" />
|
||||
) : (
|
||||
<FolderOpen className="size-3.5 shrink-0 text-[var(--color-text-muted)] transition group-hover:text-[var(--color-accent)]" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{project.name}</span>
|
||||
|
||||
{!isScratchpad && project.git && (
|
||||
<GitContextBadge git={project.git} />
|
||||
)}
|
||||
{isScratchpad && (
|
||||
<span className="shrink-0 rounded-full bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
{visibleSessions.length}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{!isScratchpad && onOpenProjectSettings && (
|
||||
<span
|
||||
className="flex size-5 items-center justify-center rounded text-[var(--color-text-muted)] opacity-0 transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)] group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenProjectSettings(project.id);
|
||||
}}
|
||||
role="button"
|
||||
title="Project settings"
|
||||
>
|
||||
<Settings className="size-3" />
|
||||
</span>
|
||||
{!isScratchpad && (onOpenProjectSettings || onRefreshGitContext) && (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{onOpenProjectSettings && (
|
||||
<span
|
||||
className="flex size-5 items-center justify-center rounded text-[var(--color-text-muted)] opacity-0 transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)] group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenProjectSettings(project.id);
|
||||
}}
|
||||
role="button"
|
||||
title="Project settings"
|
||||
>
|
||||
<Settings className="size-3" />
|
||||
</span>
|
||||
)}
|
||||
{onRefreshGitContext && (
|
||||
<span
|
||||
className="flex size-5 items-center justify-center rounded text-[var(--color-text-muted)] opacity-0 transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)] group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRefreshGitContext(project.id);
|
||||
}}
|
||||
role="button"
|
||||
title="Refresh git status"
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isScratchpad && onRefreshGitContext && (
|
||||
<span
|
||||
className="flex size-5 items-center justify-center rounded text-[var(--color-text-muted)] opacity-0 transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)] group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRefreshGitContext(project.id);
|
||||
}}
|
||||
role="button"
|
||||
title="Refresh git status"
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
</span>
|
||||
)}
|
||||
{runningCount > 0 && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-[var(--color-accent-sky)]/10 px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent-sky)]">
|
||||
<span className="size-1.5 rounded-full bg-[var(--color-accent-sky)] sidebar-pulse" />
|
||||
{runningCount}
|
||||
</span>
|
||||
)}
|
||||
{pendingDiscoveryCount > 0 && (
|
||||
<span
|
||||
className="flex cursor-pointer items-center gap-1 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 transition hover:bg-amber-500/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenProjectSettings?.(project.id);
|
||||
}}
|
||||
role="button"
|
||||
title={`${pendingDiscoveryCount} MCP server${pendingDiscoveryCount === 1 ? '' : 's'} discovered — click to review`}
|
||||
>
|
||||
{pendingDiscoveryCount} new
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-full bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
{visibleSessions.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2 — metadata strip: branch, status badges, session count */}
|
||||
{!isScratchpad && (project.git || runningCount > 0 || pendingDiscoveryCount > 0 || visibleSessions.length > 0) && (
|
||||
<div className="ml-[26px] flex items-center gap-2">
|
||||
{project.git && <GitContextBadge git={project.git} />}
|
||||
|
||||
{runningCount > 0 && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-[var(--color-accent-sky)]/10 px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent-sky)]">
|
||||
<span className="size-1.5 rounded-full bg-[var(--color-accent-sky)] sidebar-pulse" />
|
||||
{runningCount}
|
||||
</span>
|
||||
)}
|
||||
{pendingDiscoveryCount > 0 && (
|
||||
<span
|
||||
className="flex cursor-pointer items-center gap-1 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 transition hover:bg-amber-500/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenProjectSettings?.(project.id);
|
||||
}}
|
||||
role="button"
|
||||
title={`${pendingDiscoveryCount} MCP server${pendingDiscoveryCount === 1 ? '' : 's'} discovered — click to review`}
|
||||
>
|
||||
{pendingDiscoveryCount} new
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto rounded-full bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
{visibleSessions.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
@@ -523,6 +562,9 @@ export function Sidebar({
|
||||
onSetSessionArchived,
|
||||
onDeleteSession,
|
||||
onRefreshGitContext,
|
||||
updateStatus,
|
||||
onViewUpdateDetails,
|
||||
onInstallUpdate,
|
||||
}: SidebarProps) {
|
||||
const scratchpadProject = workspace.projects.find((project) => isScratchpadProject(project));
|
||||
const userProjects = workspace.projects.filter((project) => !isScratchpadProject(project));
|
||||
@@ -735,6 +777,15 @@ export function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Update notification banner */}
|
||||
{updateStatus && onViewUpdateDetails && onInstallUpdate && (
|
||||
<UpdateBanner
|
||||
status={updateStatus}
|
||||
onViewDetails={onViewUpdateDetails}
|
||||
onInstallUpdate={onInstallUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
{userProjects.length > 0 && (
|
||||
<div className="border-t border-[var(--color-border-subtle)] px-3 py-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { RotateCcw, Minus, X } from 'lucide-react';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
@@ -44,17 +44,11 @@ const DEFAULT_HEIGHT = 280;
|
||||
/* ── TerminalPanel ────────────────────────────────────────── */
|
||||
|
||||
interface TerminalPanelProps {
|
||||
height: number;
|
||||
onHeightChange: (height: number) => void;
|
||||
onClose: () => void;
|
||||
onMinimize: () => void;
|
||||
onRunningChange?: (running: boolean) => void;
|
||||
}
|
||||
|
||||
export function TerminalPanel({
|
||||
height,
|
||||
onHeightChange,
|
||||
onClose,
|
||||
onMinimize,
|
||||
onRunningChange,
|
||||
}: TerminalPanelProps) {
|
||||
const api = getElectronApi();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -62,8 +56,6 @@ export function TerminalPanel({
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<TerminalSnapshot>();
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef<{ y: number; height: number } | null>(null);
|
||||
|
||||
// Create or recover terminal on mount
|
||||
useEffect(() => {
|
||||
@@ -74,11 +66,13 @@ export function TerminalPanel({
|
||||
if (existing) {
|
||||
setSnapshot(existing);
|
||||
setIsRunning(true);
|
||||
onRunningChange?.(true);
|
||||
} else {
|
||||
void api.createTerminal().then((created) => {
|
||||
if (disposed) return;
|
||||
setSnapshot(created);
|
||||
setIsRunning(true);
|
||||
onRunningChange?.(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -135,6 +129,7 @@ export function TerminalPanel({
|
||||
});
|
||||
const offExit = api.onTerminalExit((_info: TerminalExitInfo) => {
|
||||
setIsRunning(false);
|
||||
onRunningChange?.(false);
|
||||
terminalRef.current?.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
|
||||
});
|
||||
|
||||
@@ -144,19 +139,7 @@ export function TerminalPanel({
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
// Refit on height changes
|
||||
useEffect(() => {
|
||||
if (!fitAddonRef.current || !terminalRef.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
const terminal = terminalRef.current;
|
||||
if (terminal) {
|
||||
api.resizeTerminal({ cols: terminal.cols, rows: terminal.rows });
|
||||
}
|
||||
});
|
||||
}, [height, api]);
|
||||
|
||||
// ResizeObserver for container width changes
|
||||
// ResizeObserver for container size changes (width or height from parent)
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
@@ -174,100 +157,31 @@ export function TerminalPanel({
|
||||
return () => observer.disconnect();
|
||||
}, [api]);
|
||||
|
||||
// Drag-to-resize
|
||||
const handleDragStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragStartRef.current = { y: e.clientY, height };
|
||||
setIsDragging(true);
|
||||
|
||||
const handleDragMove = (moveEvent: MouseEvent) => {
|
||||
if (!dragStartRef.current) return;
|
||||
const maxHeight = window.innerHeight * MAX_HEIGHT_FRACTION;
|
||||
const delta = dragStartRef.current.y - moveEvent.clientY;
|
||||
const nextHeight = Math.max(MIN_HEIGHT, Math.min(maxHeight, dragStartRef.current.height + delta));
|
||||
onHeightChange(nextHeight);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
dragStartRef.current = null;
|
||||
document.removeEventListener('mousemove', handleDragMove);
|
||||
document.removeEventListener('mouseup', handleDragEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleDragMove);
|
||||
document.addEventListener('mouseup', handleDragEnd);
|
||||
}, [height, onHeightChange]);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
void api.restartTerminal().then((restarted) => {
|
||||
setSnapshot(restarted);
|
||||
setIsRunning(true);
|
||||
onRunningChange?.(true);
|
||||
terminalRef.current?.clear();
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
void api.killTerminal();
|
||||
onClose();
|
||||
}, [api, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col border-t border-[var(--color-border)] bg-[var(--color-surface-0)]"
|
||||
style={{ height, minHeight: MIN_HEIGHT }}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className={`h-1 shrink-0 cursor-row-resize transition-colors ${isDragging ? 'bg-[var(--color-accent)]/40' : 'hover:bg-[var(--color-surface-3)]/60'}`}
|
||||
onMouseDown={handleDragStart}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize terminal"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
onHeightChange(Math.min(window.innerHeight * MAX_HEIGHT_FRACTION, height + 20));
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
onHeightChange(Math.max(MIN_HEIGHT, height - 20));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* Header bar */}
|
||||
<div className="flex h-7 shrink-0 items-center gap-2 border-b border-[var(--color-border)] px-3">
|
||||
<span className={`size-1.5 shrink-0 rounded-full ${isRunning ? 'bg-emerald-400' : 'bg-[var(--color-text-muted)]'}`} />
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-[var(--color-text-muted)]">
|
||||
{snapshot ? `${snapshot.shell} — ${snapshot.cwd}` : 'Terminal'}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
aria-label="Restart terminal"
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={handleRestart}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Minimize terminal"
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={onMinimize}
|
||||
type="button"
|
||||
>
|
||||
<Minus className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Close terminal"
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-red-400"
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Restart terminal"
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={handleRestart}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Terminal body */}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { CheckCircle2, Circle, FolderPlus, MessageSquarePlus, Settings, Zap } from 'lucide-react';
|
||||
import { CheckCircle2, Circle, Download, FolderPlus, MessageSquarePlus, Settings, Zap } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import type { SidecarConnectionStatus } from '@shared/contracts/sidecar';
|
||||
import { detectedPlatform } from '@renderer/lib/platform';
|
||||
import { getInstallInfoForPlatform } from '@renderer/lib/cliInstallInstructions';
|
||||
import appIconUrl from '../../../assets/icons/icon.png';
|
||||
|
||||
interface WelcomePaneProps {
|
||||
@@ -71,6 +73,34 @@ function SetupStep({ label, done, active }: SetupStepProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function CliMissingCard({ onOpenSettings }: { onOpenSettings: () => void }) {
|
||||
const info = getInstallInfoForPlatform(detectedPlatform);
|
||||
const recommended = info.methods.find((m) => m.recommended) ?? info.methods[0];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="group flex w-full cursor-pointer items-start gap-4 rounded-xl border border-[var(--color-border-glow)] bg-[var(--color-accent-muted)] px-5 py-4 text-left shadow-[0_0_24px_rgba(36,92,249,0.1)] backdrop-blur-sm transition-all duration-200 hover:shadow-[0_0_32px_rgba(36,92,249,0.15)]"
|
||||
>
|
||||
<div className="brand-gradient-bg flex size-9 shrink-0 items-center justify-center rounded-full">
|
||||
<Download className="size-4 text-white" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<span className="block text-[13px] font-medium text-[var(--color-text-primary)]">
|
||||
Install the Copilot CLI
|
||||
</span>
|
||||
<code className="block truncate rounded-md bg-[var(--color-surface-1)] px-2 py-1 font-mono text-[11px] text-[var(--color-text-secondary)]">
|
||||
{recommended.command}
|
||||
</code>
|
||||
<span className="block text-[11px] text-[var(--color-text-muted)]">
|
||||
View full instructions for {info.displayName} →
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function WelcomePane({
|
||||
hasProjects,
|
||||
connectionStatus,
|
||||
@@ -165,7 +195,10 @@ export function WelcomePane({
|
||||
{/* Action cards */}
|
||||
<motion.div {...fadeUp(isFirstRun && !allDone ? 0.2 : 0.16)} className="flex w-full flex-col gap-2.5">
|
||||
{/* Primary CTA adapts to state */}
|
||||
{!isConnected && (
|
||||
{connectionStatus === 'copilot-cli-missing' && (
|
||||
<CliMissingCard onOpenSettings={onOpenSettings} />
|
||||
)}
|
||||
{!isConnected && connectionStatus !== 'copilot-cli-missing' && (
|
||||
<ActionCard
|
||||
icon={<Zap className="size-4 text-white" />}
|
||||
title="Connect GitHub Copilot"
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowUpFromLine,
|
||||
Check,
|
||||
ChevronRight,
|
||||
FileCode2,
|
||||
FileMinus2,
|
||||
FilePlus2,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { getElectronApi } from '@renderer/lib/electronApi';
|
||||
import type {
|
||||
ProjectGitCommitMessageSuggestion,
|
||||
ProjectGitConventionalCommitType,
|
||||
ProjectGitDetails,
|
||||
ProjectGitDiffPreview,
|
||||
ProjectGitFileReference,
|
||||
ProjectGitWorkingTreeFile,
|
||||
} from '@shared/domain/project';
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────── */
|
||||
|
||||
function fileBaseName(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
const lastSlash = normalized.lastIndexOf('/');
|
||||
return lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
|
||||
}
|
||||
|
||||
function fileDir(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
const lastSlash = normalized.lastIndexOf('/');
|
||||
return lastSlash > 0 ? normalized.slice(0, lastSlash + 1) : '';
|
||||
}
|
||||
|
||||
function fileIcon(file: ProjectGitWorkingTreeFile) {
|
||||
if (file.stagedStatus === 'added' || file.unstagedStatus === 'untracked') {
|
||||
return <FilePlus2 className="size-3 shrink-0 text-[var(--color-status-success)]" />;
|
||||
}
|
||||
if (file.stagedStatus === 'deleted' || file.unstagedStatus === 'deleted') {
|
||||
return <FileMinus2 className="size-3 shrink-0 text-[var(--color-status-error)]" />;
|
||||
}
|
||||
return <FileCode2 className="size-3 shrink-0 text-[var(--color-accent-sky)]" />;
|
||||
}
|
||||
|
||||
function statusLabel(file: ProjectGitWorkingTreeFile): string {
|
||||
return file.stagedStatus ?? file.unstagedStatus ?? 'modified';
|
||||
}
|
||||
|
||||
const CONVENTIONAL_TYPES: { value: ProjectGitConventionalCommitType; label: string }[] = [
|
||||
{ value: 'feat', label: 'feat' },
|
||||
{ value: 'fix', label: 'fix' },
|
||||
{ value: 'refactor', label: 'refactor' },
|
||||
{ value: 'docs', label: 'docs' },
|
||||
{ value: 'test', label: 'test' },
|
||||
{ value: 'chore', label: 'chore' },
|
||||
];
|
||||
|
||||
/* ── Diff line renderer ────────────────────────────────────── */
|
||||
|
||||
function DiffLine({ line }: { line: string }) {
|
||||
let textClass = 'text-[var(--color-text-secondary)]';
|
||||
let bgClass = '';
|
||||
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
textClass = 'text-[var(--color-status-success)]';
|
||||
bgClass = 'bg-[var(--color-status-success)]/[0.06]';
|
||||
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
textClass = 'text-[var(--color-status-error)]';
|
||||
bgClass = 'bg-[var(--color-status-error)]/[0.06]';
|
||||
} else if (line.startsWith('@@')) {
|
||||
textClass = 'text-[var(--color-accent-sky)]';
|
||||
} else if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('---') || line.startsWith('+++')) {
|
||||
textClass = 'text-[var(--color-text-muted)]';
|
||||
}
|
||||
|
||||
return <div className={`${textClass} ${bgClass} -mx-3 px-3`}>{line || '\u00A0'}</div>;
|
||||
}
|
||||
|
||||
/* ── File row with staging checkbox ────────────────────────── */
|
||||
|
||||
function CommitFileRow({
|
||||
file,
|
||||
isStaged,
|
||||
onToggle,
|
||||
onPreview,
|
||||
preview,
|
||||
previewExpanded,
|
||||
}: {
|
||||
file: ProjectGitWorkingTreeFile;
|
||||
isStaged: boolean;
|
||||
onToggle: () => void;
|
||||
onPreview: () => void;
|
||||
preview?: ProjectGitDiffPreview;
|
||||
previewExpanded: boolean;
|
||||
}) {
|
||||
const dir = fileDir(file.path);
|
||||
const base = fileBaseName(file.path);
|
||||
const hasPreview = !!preview?.diff || !!preview?.newFileContents;
|
||||
|
||||
return (
|
||||
<div className="border-b border-[var(--color-border-subtle)] last:border-b-0">
|
||||
<div className="flex items-center gap-1 px-2.5 py-[5px] text-[10px]">
|
||||
{/* Staging checkbox */}
|
||||
<button
|
||||
className={`flex size-3.5 shrink-0 items-center justify-center rounded border transition-colors duration-100 ${
|
||||
isStaged
|
||||
? 'border-[var(--color-status-success)] bg-[var(--color-status-success)] text-white'
|
||||
: 'border-[var(--color-border)] bg-transparent hover:border-[var(--color-text-muted)]'
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
aria-label={`${isStaged ? 'Unstage' : 'Stage'} ${file.path}`}
|
||||
aria-pressed={isStaged}
|
||||
>
|
||||
{isStaged && <Check className="size-2" />}
|
||||
</button>
|
||||
|
||||
{/* File path + expand */}
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 text-left transition-colors duration-150 hover:text-[var(--color-text-primary)]"
|
||||
onClick={onPreview}
|
||||
type="button"
|
||||
aria-expanded={previewExpanded}
|
||||
>
|
||||
{hasPreview || previewExpanded ? (
|
||||
<ChevronRight
|
||||
className={`size-2.5 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${previewExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
) : (
|
||||
<span className="w-2.5 shrink-0" />
|
||||
)}
|
||||
|
||||
{fileIcon(file)}
|
||||
|
||||
<span className="min-w-0 flex-1 truncate font-mono">
|
||||
{dir && <span className="text-[var(--color-text-muted)]">{dir}</span>}
|
||||
<span className="text-[var(--color-text-primary)]">{base}</span>
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 rounded px-1 py-px text-[8px] font-semibold uppercase tracking-wider bg-[var(--color-surface-3)] text-[var(--color-text-muted)]">
|
||||
{statusLabel(file)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Diff preview */}
|
||||
{previewExpanded && preview && (
|
||||
<div className="border-t border-[var(--color-border-subtle)]">
|
||||
<pre className="max-h-52 overflow-auto bg-[var(--color-surface-0)] px-3 py-1.5 font-mono text-[10px] leading-relaxed">
|
||||
{preview.diff
|
||||
? preview.diff.split('\n').map((line, i) => <DiffLine key={i} line={line} />)
|
||||
: preview.newFileContents
|
||||
? preview.newFileContents.split('\n').map((line, i) => (
|
||||
<div key={i} className="text-[var(--color-text-secondary)]">{line || '\u00A0'}</div>
|
||||
))
|
||||
: preview.isBinary
|
||||
? <div className="text-[var(--color-text-muted)] italic">Binary file</div>
|
||||
: <div className="text-[var(--color-text-muted)] italic">Loading…</div>}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main export ───────────────────────────────────────────── */
|
||||
|
||||
interface CommitComposerProps {
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
runId?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CommitComposer({
|
||||
projectId,
|
||||
sessionId,
|
||||
runId,
|
||||
onClose,
|
||||
}: CommitComposerProps) {
|
||||
const api = getElectronApi();
|
||||
|
||||
const [details, setDetails] = useState<ProjectGitDetails>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [commitMessage, setCommitMessage] = useState('');
|
||||
const [commitType, setCommitType] = useState<ProjectGitConventionalCommitType>('feat');
|
||||
const [stagedPaths, setStagedPaths] = useState<Set<string>>(new Set());
|
||||
const [previews, setPreviews] = useState<Record<string, ProjectGitDiffPreview>>({});
|
||||
const [expandedPath, setExpandedPath] = useState<string>();
|
||||
const [committing, setCommitting] = useState(false);
|
||||
const [pushAfterCommit, setPushAfterCommit] = useState(false);
|
||||
const [commitError, setCommitError] = useState<string>();
|
||||
const [commitSuccess, setCommitSuccess] = useState(false);
|
||||
|
||||
// Load git details on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await api.getProjectGitDetails({ projectId });
|
||||
if (!cancelled) {
|
||||
setDetails(result);
|
||||
|
||||
// Pre-stage files that are already in the index
|
||||
if (result.workingTree) {
|
||||
const preStaged = new Set<string>();
|
||||
for (const file of result.workingTree.files) {
|
||||
if (file.stagedStatus && file.stagedStatus !== 'unmerged') {
|
||||
preStaged.add(file.path);
|
||||
}
|
||||
}
|
||||
setStagedPaths(preStaged);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => { cancelled = true; };
|
||||
}, [api, projectId]);
|
||||
|
||||
// Load commit message suggestion
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function suggest() {
|
||||
try {
|
||||
const suggestion = await api.suggestProjectGitCommitMessage({
|
||||
sessionId,
|
||||
runId,
|
||||
conventionalType: commitType,
|
||||
});
|
||||
if (!cancelled && suggestion) {
|
||||
setCommitMessage(suggestion.message);
|
||||
setCommitType(suggestion.type);
|
||||
}
|
||||
} catch {
|
||||
// Suggestion is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
if (!commitMessage) {
|
||||
void suggest();
|
||||
}
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [api, sessionId, runId]); // Deliberately omitting commitType/commitMessage to avoid re-triggering
|
||||
|
||||
const files = useMemo(
|
||||
() => details?.workingTree?.files ?? [],
|
||||
[details?.workingTree?.files],
|
||||
);
|
||||
|
||||
const stagedFiles = useMemo(
|
||||
() => files.filter((f) => stagedPaths.has(f.path)),
|
||||
[files, stagedPaths],
|
||||
);
|
||||
|
||||
const toggleStaged = useCallback((path: string) => {
|
||||
setStagedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
if (stagedPaths.size === files.length) {
|
||||
setStagedPaths(new Set());
|
||||
} else {
|
||||
setStagedPaths(new Set(files.map((f) => f.path)));
|
||||
}
|
||||
}, [files, stagedPaths.size]);
|
||||
|
||||
const handlePreview = useCallback(async (file: ProjectGitWorkingTreeFile) => {
|
||||
if (expandedPath === file.path) {
|
||||
setExpandedPath(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedPath(file.path);
|
||||
|
||||
if (!previews[file.path]) {
|
||||
try {
|
||||
const preview = await api.getProjectGitFilePreview({
|
||||
projectId,
|
||||
file: { path: file.path, previousPath: file.previousPath },
|
||||
});
|
||||
if (preview) {
|
||||
setPreviews((prev) => ({ ...prev, [file.path]: preview }));
|
||||
}
|
||||
} catch {
|
||||
// Preview is best-effort
|
||||
}
|
||||
}
|
||||
}, [api, expandedPath, previews, projectId]);
|
||||
|
||||
const handleSuggestMessage = useCallback(async () => {
|
||||
try {
|
||||
const suggestion = await api.suggestProjectGitCommitMessage({
|
||||
sessionId,
|
||||
runId,
|
||||
conventionalType: commitType,
|
||||
});
|
||||
if (suggestion) {
|
||||
setCommitMessage(suggestion.message);
|
||||
setCommitType(suggestion.type);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}, [api, sessionId, runId, commitType]);
|
||||
|
||||
const handleCommit = useCallback(async () => {
|
||||
if (!commitMessage.trim() || stagedFiles.length === 0) return;
|
||||
|
||||
setCommitting(true);
|
||||
setCommitError(undefined);
|
||||
try {
|
||||
const filesToCommit: ProjectGitFileReference[] = stagedFiles.map((f) => ({
|
||||
path: f.path,
|
||||
previousPath: f.previousPath,
|
||||
}));
|
||||
await api.commitProjectGitChanges({
|
||||
projectId,
|
||||
message: commitMessage.trim(),
|
||||
files: filesToCommit,
|
||||
push: pushAfterCommit,
|
||||
});
|
||||
setCommitSuccess(true);
|
||||
setTimeout(() => onClose(), 1200);
|
||||
} catch (error) {
|
||||
setCommitError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setCommitting(false);
|
||||
}
|
||||
}, [api, commitMessage, onClose, projectId, pushAfterCommit, stagedFiles]);
|
||||
|
||||
// Keyboard: Escape to close
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKey, true);
|
||||
return () => window.removeEventListener('keydown', handleKey, true);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-stretch justify-end"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="commit-composer-title"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="relative z-10 flex w-[420px] max-w-full flex-col border-l border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 border-b border-[var(--color-border-subtle)] px-4 py-3">
|
||||
<GitBranch className="size-4 text-[var(--color-accent-sky)]" />
|
||||
<h2 id="commit-composer-title" className="font-display text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
Commit Changes
|
||||
</h2>
|
||||
{details?.context.branch && (
|
||||
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">
|
||||
on {details.context.branch}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
className="rounded-md p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
aria-label="Close commit composer"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-[var(--color-text-muted)]" aria-label="Loading git details" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success state */}
|
||||
{commitSuccess && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-[var(--color-status-success)]/10">
|
||||
<Check className="size-6 text-[var(--color-status-success)]" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-[var(--color-status-success)]">
|
||||
Changes committed{pushAfterCommit ? ' and pushed' : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{!loading && !commitSuccess && (
|
||||
<>
|
||||
{/* Commit message */}
|
||||
<div className="border-b border-[var(--color-border-subtle)] px-4 py-3">
|
||||
{/* Type selector */}
|
||||
<div className="mb-2 flex items-center gap-1">
|
||||
{CONVENTIONAL_TYPES.map((ct) => (
|
||||
<button
|
||||
key={ct.value}
|
||||
className={`rounded-md px-2 py-0.5 text-[10px] font-medium transition-colors duration-100 ${
|
||||
commitType === ct.value
|
||||
? 'bg-[var(--color-accent)]/15 text-[var(--color-text-accent)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
onClick={() => setCommitType(ct.value)}
|
||||
type="button"
|
||||
>
|
||||
{ct.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-accent)]"
|
||||
onClick={() => void handleSuggestMessage()}
|
||||
type="button"
|
||||
title="Suggest commit message"
|
||||
>
|
||||
<Sparkles className="size-3" />
|
||||
Suggest
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="w-full resize-none rounded-md border border-[var(--color-border)] bg-[var(--color-surface-0)] px-3 py-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||
placeholder="Commit message…"
|
||||
rows={3}
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{/* Select all header */}
|
||||
<div className="flex items-center gap-2 border-b border-[var(--color-border-subtle)] px-2.5 py-1.5">
|
||||
<button
|
||||
className={`flex size-3.5 shrink-0 items-center justify-center rounded border transition-colors duration-100 ${
|
||||
stagedPaths.size === files.length && files.length > 0
|
||||
? 'border-[var(--color-status-success)] bg-[var(--color-status-success)] text-white'
|
||||
: 'border-[var(--color-border)] bg-transparent hover:border-[var(--color-text-muted)]'
|
||||
}`}
|
||||
onClick={toggleAll}
|
||||
type="button"
|
||||
aria-label={stagedPaths.size === files.length ? 'Unstage all' : 'Stage all'}
|
||||
>
|
||||
{stagedPaths.size === files.length && files.length > 0 && <Check className="size-2" />}
|
||||
</button>
|
||||
<span className="text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
{stagedPaths.size} of {files.length} staged
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{files.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-[11px] text-[var(--color-text-muted)]">
|
||||
Working tree is clean — nothing to commit.
|
||||
</p>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<CommitFileRow
|
||||
file={file}
|
||||
isStaged={stagedPaths.has(file.path)}
|
||||
key={file.path}
|
||||
onPreview={() => void handlePreview(file)}
|
||||
onToggle={() => toggleStaged(file.path)}
|
||||
preview={previews[file.path]}
|
||||
previewExpanded={expandedPath === file.path}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{commitError && (
|
||||
<div className="border-t border-[var(--color-status-error)]/20 bg-[var(--color-status-error)]/5 px-4 py-2 text-[10px] text-[var(--color-status-error)]" role="alert">
|
||||
{commitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="flex items-center gap-2 border-t border-[var(--color-border-subtle)] px-4 py-3">
|
||||
{/* Push toggle */}
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pushAfterCommit}
|
||||
onChange={(e) => setPushAfterCommit(e.target.checked)}
|
||||
className="accent-[var(--color-accent)]"
|
||||
/>
|
||||
Push after commit
|
||||
</label>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
className="rounded-md px-3 py-1.5 text-[11px] font-medium text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)]"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-[11px] font-medium text-white transition-colors duration-150 hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={committing || !commitMessage.trim() || stagedFiles.length === 0}
|
||||
onClick={() => void handleCommit()}
|
||||
type="button"
|
||||
>
|
||||
{committing ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<ArrowUpFromLine className="size-3" />
|
||||
)}
|
||||
{committing ? 'Committing…' : pushAfterCommit ? 'Commit & Push' : 'Commit'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Loader2, Minus, Search, Sparkles, TerminalSquare } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, GitBranch, Loader2, Minus, Search, Sparkles, TerminalSquare } from 'lucide-react';
|
||||
|
||||
import { ProviderIcon } from '@renderer/components/ProviderIcons';
|
||||
import { PopoverToggleRow } from '@renderer/components/ui';
|
||||
@@ -757,3 +757,32 @@ export function InlineTerminalPill({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── InlineGitPill ─────────────────────────────────────────── */
|
||||
|
||||
export function InlineGitPill({
|
||||
isDirty,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
isDirty: boolean;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-pressed={isOpen}
|
||||
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium transition-all duration-200 ${
|
||||
isOpen
|
||||
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)] hover:bg-[var(--color-accent)]/20'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{isDirty && <span className="size-1.5 shrink-0 rounded-full bg-[var(--color-status-warning)]" />}
|
||||
<GitBranch className="size-3" />
|
||||
<span>Git</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
FileText,
|
||||
Globe,
|
||||
Server,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { PermissionDetail } from '@shared/contracts/sidecar';
|
||||
@@ -61,6 +60,37 @@ export function permissionDetailSummary(detail: PermissionDetail): string | unde
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Display helpers ─────────────────────────────────────────── */
|
||||
|
||||
/** Recursively parse string values that contain JSON objects or arrays (display-time only). */
|
||||
function deepParseJsonStrings(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (
|
||||
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
||||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|
||||
) {
|
||||
try {
|
||||
return deepParseJsonStrings(JSON.parse(trimmed));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(deepParseJsonStrings);
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
result[k] = deepParseJsonStrings(v);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/* ── Kind-specific renderers ────────────────────────────────── */
|
||||
|
||||
function ShellDetail({ detail }: { detail: PermissionDetail }) {
|
||||
@@ -134,7 +164,7 @@ function McpDetail({ detail }: { detail: PermissionDetail }) {
|
||||
)}
|
||||
</div>
|
||||
{detail.args && Object.keys(detail.args).length > 0 && (
|
||||
<CollapsibleCode label="Arguments" text={JSON.stringify(detail.args, null, 2)} />
|
||||
<CollapsibleCode label="Arguments" text={JSON.stringify(deepParseJsonStrings(detail.args), null, 2)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -185,7 +215,7 @@ function CustomToolDetail({ detail }: { detail: PermissionDetail }) {
|
||||
<p className="text-[11px] text-[var(--color-text-secondary)]">{detail.toolDescription}</p>
|
||||
)}
|
||||
{detail.args && Object.keys(detail.args).length > 0 && (
|
||||
<CollapsibleCode label="Arguments" text={JSON.stringify(detail.args, null, 2)} />
|
||||
<CollapsibleCode label="Arguments" text={JSON.stringify(deepParseJsonStrings(detail.args), null, 2)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -201,13 +231,13 @@ function HookDetail({ detail }: { detail: PermissionDetail }) {
|
||||
</div>
|
||||
)}
|
||||
{detail.args && Object.keys(detail.args).length > 0 && (
|
||||
<CollapsibleCode label="Arguments" text={JSON.stringify(detail.args, null, 2)} />
|
||||
<CollapsibleCode label="Arguments" text={JSON.stringify(deepParseJsonStrings(detail.args), null, 2)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Shared primitives ──────────────────────────────────────── */
|
||||
/* ── Shared primitives──────────────────────────────────────── */
|
||||
|
||||
function IntentionLine({ text }: { text: string }) {
|
||||
return <p className="text-[11px] italic text-[var(--color-text-secondary)]">{text}</p>;
|
||||
@@ -242,6 +272,47 @@ function DiffBlock({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── JSON syntax highlighting ───────────────────────────────── */
|
||||
|
||||
const jsonTokenPattern =
|
||||
/("(?:[^"\\]|\\.)*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|([{}\[\],])/g;
|
||||
|
||||
function JsonHighlighted({ json }: { json: string }) {
|
||||
const elements: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let key = 0;
|
||||
|
||||
for (const match of json.matchAll(jsonTokenPattern)) {
|
||||
const idx = match.index ?? 0;
|
||||
if (idx > lastIndex) elements.push(json.slice(lastIndex, idx));
|
||||
|
||||
if (match[1] && match[2]) {
|
||||
// Object key + colon
|
||||
elements.push(
|
||||
<span key={key++} className="text-[var(--color-text-accent)]">{match[1]}</span>,
|
||||
<span key={key++} className="text-[var(--color-text-muted)]">{match[2]}</span>,
|
||||
);
|
||||
} else if (match[1]) {
|
||||
// String value
|
||||
elements.push(<span key={key++} className="text-[var(--color-status-success)]">{match[1]}</span>);
|
||||
} else if (match[3]) {
|
||||
// true / false / null
|
||||
elements.push(<span key={key++} className="text-[var(--color-accent-sky)]">{match[3]}</span>);
|
||||
} else if (match[4]) {
|
||||
// Number
|
||||
elements.push(<span key={key++} className="text-[var(--color-accent-sky)]">{match[4]}</span>);
|
||||
} else if (match[5]) {
|
||||
// Structural punctuation
|
||||
elements.push(<span key={key++} className="text-[var(--color-text-muted)]">{match[5]}</span>);
|
||||
}
|
||||
|
||||
lastIndex = idx + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < json.length) elements.push(json.slice(lastIndex));
|
||||
return <>{elements}</>;
|
||||
}
|
||||
|
||||
function CollapsibleCode({
|
||||
label,
|
||||
text,
|
||||
@@ -254,6 +325,7 @@ function CollapsibleCode({
|
||||
defaultExpanded?: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const isJson = text.trimStart().startsWith('{') || text.trimStart().startsWith('[');
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]">
|
||||
@@ -272,7 +344,7 @@ function CollapsibleCode({
|
||||
<div className="border-t border-[var(--color-border-subtle)] px-2.5 py-1.5">
|
||||
{children ?? (
|
||||
<pre className="max-h-48 overflow-auto font-mono text-[10px] leading-relaxed text-[var(--color-text-primary)]">
|
||||
{text}
|
||||
{isJson ? <JsonHighlighted json={text} /> : text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDownToLine,
|
||||
Check,
|
||||
ChevronRight,
|
||||
FileCode2,
|
||||
FileMinus2,
|
||||
FilePlus2,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type {
|
||||
ProjectGitFileReference,
|
||||
ProjectGitRunChangedFile,
|
||||
ProjectGitRunChangeSummary,
|
||||
} from '@shared/domain/project';
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────── */
|
||||
|
||||
function fileBaseName(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
const lastSlash = normalized.lastIndexOf('/');
|
||||
return lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
|
||||
}
|
||||
|
||||
function fileDir(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
const lastSlash = normalized.lastIndexOf('/');
|
||||
return lastSlash > 0 ? normalized.slice(0, lastSlash + 1) : '';
|
||||
}
|
||||
|
||||
function kindLabel(kind: ProjectGitRunChangedFile['kind']): string {
|
||||
switch (kind) {
|
||||
case 'added': return 'added';
|
||||
case 'modified': return 'modified';
|
||||
case 'deleted': return 'deleted';
|
||||
case 'renamed': return 'renamed';
|
||||
case 'copied': return 'copied';
|
||||
case 'type-changed': return 'type changed';
|
||||
case 'unmerged': return 'conflict';
|
||||
case 'untracked': return 'new';
|
||||
case 'cleaned': return 'cleaned';
|
||||
}
|
||||
}
|
||||
|
||||
function kindIcon(kind: ProjectGitRunChangedFile['kind']) {
|
||||
switch (kind) {
|
||||
case 'added':
|
||||
case 'untracked':
|
||||
case 'copied':
|
||||
return <FilePlus2 className="size-3 shrink-0 text-[var(--color-status-success)]" />;
|
||||
case 'deleted':
|
||||
case 'cleaned':
|
||||
return <FileMinus2 className="size-3 shrink-0 text-[var(--color-status-error)]" />;
|
||||
default:
|
||||
return <FileCode2 className="size-3 shrink-0 text-[var(--color-accent-sky)]" />;
|
||||
}
|
||||
}
|
||||
|
||||
function originBadge(origin: ProjectGitRunChangedFile['origin']) {
|
||||
if (origin === 'pre-existing') {
|
||||
return (
|
||||
<span className="rounded px-1 py-px text-[7px] font-semibold uppercase tracking-wider bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]">
|
||||
pre-existing
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── Mini diff-stats bar ───────────────────────────────────── */
|
||||
|
||||
function DiffStatsBar({ additions, deletions }: { additions: number; deletions: number }) {
|
||||
const total = additions + deletions;
|
||||
if (total === 0) return null;
|
||||
const blocks = 5;
|
||||
const addBlocks = Math.max(additions > 0 ? 1 : 0, Math.round((additions / total) * blocks));
|
||||
const delBlocks = blocks - addBlocks;
|
||||
|
||||
return (
|
||||
<span className="inline-flex gap-px" aria-label={`${additions} additions, ${deletions} deletions`}>
|
||||
{Array.from({ length: addBlocks }, (_, i) => (
|
||||
<span key={`a${i}`} className="size-1.5 rounded-[1px] bg-[var(--color-status-success)]" />
|
||||
))}
|
||||
{Array.from({ length: delBlocks }, (_, i) => (
|
||||
<span key={`d${i}`} className="size-1.5 rounded-[1px] bg-[var(--color-status-error)]" />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Diff line renderer ────────────────────────────────────── */
|
||||
|
||||
function DiffLine({ line }: { line: string }) {
|
||||
let textClass = 'text-[var(--color-text-secondary)]';
|
||||
let bgClass = '';
|
||||
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
textClass = 'text-[var(--color-status-success)]';
|
||||
bgClass = 'bg-[var(--color-status-success)]/[0.06]';
|
||||
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
textClass = 'text-[var(--color-status-error)]';
|
||||
bgClass = 'bg-[var(--color-status-error)]/[0.06]';
|
||||
} else if (line.startsWith('@@')) {
|
||||
textClass = 'text-[var(--color-accent-sky)]';
|
||||
} else if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('---') || line.startsWith('+++')) {
|
||||
textClass = 'text-[var(--color-text-muted)]';
|
||||
}
|
||||
|
||||
return <div className={`${textClass} ${bgClass} -mx-3 px-3`}>{line || '\u00A0'}</div>;
|
||||
}
|
||||
|
||||
/* ── Single file row ───────────────────────────────────────── */
|
||||
|
||||
function ChangedFileRow({
|
||||
file,
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
canSelect,
|
||||
}: {
|
||||
file: ProjectGitRunChangedFile;
|
||||
isSelected: boolean;
|
||||
onToggleSelect: () => void;
|
||||
canSelect: boolean;
|
||||
}) {
|
||||
const [diffExpanded, setDiffExpanded] = useState(false);
|
||||
const hasPreview = !!file.preview?.diff || !!file.preview?.newFileContents;
|
||||
const dir = fileDir(file.path);
|
||||
const base = fileBaseName(file.path);
|
||||
|
||||
return (
|
||||
<div className="border-b border-[var(--color-border-subtle)] last:border-b-0">
|
||||
<div className="flex items-center gap-1 px-2 py-[5px] text-[10px]">
|
||||
{/* Select checkbox */}
|
||||
{canSelect && (
|
||||
<button
|
||||
className={`flex size-3.5 shrink-0 items-center justify-center rounded border transition-colors duration-100 ${
|
||||
isSelected
|
||||
? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white'
|
||||
: 'border-[var(--color-border)] bg-transparent hover:border-[var(--color-text-muted)]'
|
||||
}`}
|
||||
onClick={onToggleSelect}
|
||||
type="button"
|
||||
aria-label={`${isSelected ? 'Deselect' : 'Select'} ${file.path}`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{isSelected && <Check className="size-2" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Expand diff button */}
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 text-left transition-colors duration-150 hover:bg-[var(--color-surface-3)]/40 disabled:cursor-default"
|
||||
disabled={!hasPreview}
|
||||
onClick={hasPreview ? () => setDiffExpanded(!diffExpanded) : undefined}
|
||||
type="button"
|
||||
aria-expanded={hasPreview ? diffExpanded : undefined}
|
||||
>
|
||||
{hasPreview ? (
|
||||
<ChevronRight
|
||||
className={`size-2.5 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${diffExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
) : (
|
||||
<span className="w-2.5 shrink-0" />
|
||||
)}
|
||||
|
||||
{kindIcon(file.kind)}
|
||||
|
||||
<span className="min-w-0 flex-1 truncate font-mono">
|
||||
{dir && <span className="text-[var(--color-text-muted)]">{dir}</span>}
|
||||
<span className="text-[var(--color-text-primary)]">{base}</span>
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 rounded px-1 py-px text-[8px] font-semibold uppercase tracking-wider bg-[var(--color-surface-3)] text-[var(--color-text-muted)]">
|
||||
{kindLabel(file.kind)}
|
||||
</span>
|
||||
|
||||
{originBadge(file.origin)}
|
||||
|
||||
{(file.additions > 0 || file.deletions > 0) && (
|
||||
<span className="flex items-center gap-1.5 shrink-0 font-mono">
|
||||
{file.additions > 0 && <span className="text-[var(--color-status-success)]">+{file.additions}</span>}
|
||||
{file.deletions > 0 && <span className="text-[var(--color-status-error)]">−{file.deletions}</span>}
|
||||
<DiffStatsBar additions={file.additions} deletions={file.deletions} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Diff preview */}
|
||||
{diffExpanded && file.preview && (
|
||||
<div className="border-t border-[var(--color-border-subtle)]">
|
||||
<pre className="max-h-64 overflow-auto bg-[var(--color-surface-0)] px-3 py-1.5 font-mono text-[10px] leading-relaxed">
|
||||
{file.preview.diff
|
||||
? file.preview.diff.split('\n').map((line, i) => <DiffLine key={i} line={line} />)
|
||||
: file.preview.newFileContents
|
||||
? file.preview.newFileContents.split('\n').map((line, i) => (
|
||||
<div key={i} className="text-[var(--color-text-secondary)]">{line || '\u00A0'}</div>
|
||||
))
|
||||
: file.preview.isBinary
|
||||
? <div className="text-[var(--color-text-muted)] italic">Binary file</div>
|
||||
: null}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main export ───────────────────────────────────────────── */
|
||||
|
||||
interface RunChangeSummaryCardProps {
|
||||
summary: ProjectGitRunChangeSummary;
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
onDiscard: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
|
||||
onOpenCommitComposer?: () => void;
|
||||
}
|
||||
|
||||
export function RunChangeSummaryCard({
|
||||
summary,
|
||||
sessionId,
|
||||
runId,
|
||||
onDiscard,
|
||||
onOpenCommitComposer,
|
||||
}: RunChangeSummaryCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||
const [discarding, setDiscarding] = useState(false);
|
||||
const [confirmDiscard, setConfirmDiscard] = useState<'bulk' | 'selected' | undefined>();
|
||||
|
||||
const revertableFiles = useMemo(
|
||||
() => summary.files.filter((file) => file.canRevert),
|
||||
[summary.files],
|
||||
);
|
||||
const hasRevertable = revertableFiles.length > 0;
|
||||
|
||||
const toggleSelect = useCallback((path: string) => {
|
||||
setSelectedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDiscard = useCallback(async (mode: 'bulk' | 'selected') => {
|
||||
setDiscarding(true);
|
||||
try {
|
||||
if (mode === 'selected') {
|
||||
const files: ProjectGitFileReference[] = summary.files
|
||||
.filter((f) => selectedPaths.has(f.path))
|
||||
.map((f) => ({ path: f.path, previousPath: f.previousPath }));
|
||||
await onDiscard(sessionId, runId, files);
|
||||
setSelectedPaths(new Set());
|
||||
} else {
|
||||
await onDiscard(sessionId, runId);
|
||||
}
|
||||
} finally {
|
||||
setDiscarding(false);
|
||||
setConfirmDiscard(undefined);
|
||||
}
|
||||
}, [onDiscard, sessionId, runId, selectedPaths, summary.files]);
|
||||
|
||||
const selectedRevertableCount = useMemo(
|
||||
() => revertableFiles.filter((f) => selectedPaths.has(f.path)).length,
|
||||
[revertableFiles, selectedPaths],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)]/80">
|
||||
{/* Header */}
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors duration-150 hover:bg-[var(--color-surface-2)]/40"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${summary.fileCount} files changed by this run`}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`size-3 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${expanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
|
||||
<GitBranch className="size-3 shrink-0 text-[var(--color-accent-sky)]" />
|
||||
|
||||
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">
|
||||
{summary.fileCount} {summary.fileCount === 1 ? 'file' : 'files'} changed
|
||||
</span>
|
||||
|
||||
{(summary.additions > 0 || summary.deletions > 0) && (
|
||||
<span className="flex items-center gap-1.5 font-mono text-[10px]">
|
||||
{summary.additions > 0 && (
|
||||
<span className="text-[var(--color-status-success)]">+{summary.additions}</span>
|
||||
)}
|
||||
{summary.deletions > 0 && (
|
||||
<span className="text-[var(--color-status-error)]">−{summary.deletions}</span>
|
||||
)}
|
||||
<DiffStatsBar additions={summary.additions} deletions={summary.deletions} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{summary.branchChanged && (
|
||||
<span className="ml-auto flex items-center gap-1 text-[9px] text-[var(--color-status-warning)]">
|
||||
<AlertTriangle className="size-2.5" />
|
||||
branch changed
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="border-t border-[var(--color-border-subtle)]">
|
||||
{/* Branch info */}
|
||||
{summary.branchChanged && summary.branchAtStart && summary.branchAtEnd && (
|
||||
<div className="flex items-center gap-1.5 border-b border-[var(--color-border-subtle)] px-3 py-1.5 text-[9px] text-[var(--color-text-muted)]">
|
||||
<GitBranch className="size-2.5" />
|
||||
<span className="font-mono text-[var(--color-text-secondary)]">{summary.branchAtStart}</span>
|
||||
<span>→</span>
|
||||
<span className="font-mono text-[var(--color-text-secondary)]">{summary.branchAtEnd}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File list */}
|
||||
<div>
|
||||
{summary.files.map((file) => (
|
||||
<ChangedFileRow
|
||||
canSelect={hasRevertable && file.canRevert}
|
||||
file={file}
|
||||
isSelected={selectedPaths.has(file.path)}
|
||||
key={file.path}
|
||||
onToggleSelect={() => toggleSelect(file.path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{(hasRevertable || onOpenCommitComposer) && (
|
||||
<div className="flex items-center gap-2 border-t border-[var(--color-border-subtle)] px-3 py-2">
|
||||
{/* Commit button */}
|
||||
{onOpenCommitComposer && (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1 text-[10px] font-medium text-white transition-colors duration-150 hover:bg-[var(--color-accent-hover)]"
|
||||
onClick={onOpenCommitComposer}
|
||||
type="button"
|
||||
>
|
||||
<ArrowDownToLine className="size-3" />
|
||||
Commit changes
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Discard actions */}
|
||||
{hasRevertable && !confirmDiscard && (
|
||||
<>
|
||||
{selectedRevertableCount > 0 && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-[var(--color-status-error)] transition-colors duration-150 hover:bg-[var(--color-status-error)]/10"
|
||||
disabled={discarding}
|
||||
onClick={() => setConfirmDiscard('selected')}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Discard {selectedRevertableCount} selected
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-[var(--color-text-muted)] transition-colors duration-150 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-status-error)]"
|
||||
disabled={discarding}
|
||||
onClick={() => setConfirmDiscard('bulk')}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
Discard all
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Confirmation */}
|
||||
{confirmDiscard && (
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-[var(--color-status-error)]/30 bg-[var(--color-status-error)]/5 px-2.5 py-1" role="alert">
|
||||
<AlertTriangle className="size-3 text-[var(--color-status-error)]" />
|
||||
<span className="text-[10px] text-[var(--color-status-error)]">
|
||||
{confirmDiscard === 'bulk'
|
||||
? `Revert all ${revertableFiles.length} files to pre-run state?`
|
||||
: `Revert ${selectedRevertableCount} selected files?`}
|
||||
</span>
|
||||
<button
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-semibold text-[var(--color-status-error)] transition-colors duration-100 hover:bg-[var(--color-status-error)]/15"
|
||||
disabled={discarding}
|
||||
onClick={() => void handleDiscard(confirmDiscard)}
|
||||
type="button"
|
||||
>
|
||||
{discarding ? <Loader2 className="size-2.5 animate-spin" /> : <Check className="size-2.5" />}
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
className="rounded px-1.5 py-0.5 text-[9px] font-semibold text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)]"
|
||||
onClick={() => setConfirmDiscard(undefined)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Check, Copy, ChevronRight, KeyRound, Sparkles } from 'lucide-react';
|
||||
|
||||
import { detectedPlatform, type DetectedPlatform } from '@renderer/lib/platform';
|
||||
import {
|
||||
installInstructions,
|
||||
authCommand,
|
||||
type PlatformInstallInfo,
|
||||
type InstallMethod,
|
||||
} from '@renderer/lib/cliInstallInstructions';
|
||||
|
||||
interface CliInstallGuideProps {
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
}
|
||||
|
||||
function PlatformTab({
|
||||
info,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
info: PlatformInstallInfo;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`relative rounded-md px-3 py-1.5 text-[11px] font-semibold tracking-wide transition-all duration-200 ${
|
||||
active
|
||||
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{info.displayName}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandBlock({
|
||||
method,
|
||||
index,
|
||||
}: {
|
||||
method: InstallMethod;
|
||||
index: number;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
void navigator.clipboard.writeText(method.command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
}, [method.command]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/cmd space-y-1.5"
|
||||
style={{ animationDelay: `${index * 60}ms` }}
|
||||
>
|
||||
{/* Method label row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">
|
||||
{method.label}
|
||||
</span>
|
||||
{method.recommended && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--color-accent)]/10 px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest text-[var(--color-accent)]">
|
||||
<Sparkles className="size-2.5" />
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command block */}
|
||||
<div className="relative flex items-center overflow-hidden rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2">
|
||||
<ChevronRight className="size-3 shrink-0 text-[var(--color-accent)]/60" />
|
||||
<code className="min-w-0 select-all truncate font-mono text-[12px] leading-relaxed text-[var(--color-text-primary)]">
|
||||
{method.command}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border-subtle)] px-2.5 py-2 text-[var(--color-text-muted)] transition-colors duration-150 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
title="Copy command"
|
||||
aria-label={`Copy command: ${method.command}`}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3 text-[var(--color-status-success)]" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CliInstallGuide({ onRefresh, isRefreshing }: CliInstallGuideProps) {
|
||||
const [activePlatform, setActivePlatform] = useState<DetectedPlatform>(detectedPlatform);
|
||||
|
||||
const activeInfo = installInstructions.find((i) => i.platform === activePlatform)!;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Step 1: Install */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex size-5 items-center justify-center rounded-full bg-[var(--color-accent)]/15 text-[10px] font-bold text-[var(--color-accent)]">
|
||||
1
|
||||
</span>
|
||||
<span className="text-[12px] font-semibold text-[var(--color-text-primary)]">
|
||||
Install the Copilot CLI
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Platform tabs */}
|
||||
<div className="flex items-center gap-1 rounded-lg bg-[var(--color-surface-2)] p-1">
|
||||
{installInstructions.map((info) => (
|
||||
<PlatformTab
|
||||
key={info.platform}
|
||||
active={activePlatform === info.platform}
|
||||
info={info}
|
||||
onClick={() => setActivePlatform(info.platform)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Commands for active platform */}
|
||||
<div className="space-y-3">
|
||||
{activeInfo.methods.map((method, i) => (
|
||||
<CommandBlock key={method.label} index={i} method={method} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Authenticate */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex size-5 items-center justify-center rounded-full bg-[var(--color-accent)]/15 text-[10px] font-bold text-[var(--color-accent)]">
|
||||
2
|
||||
</span>
|
||||
<span className="text-[12px] font-semibold text-[var(--color-text-primary)]">
|
||||
Sign in to GitHub
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AuthCommandBlock />
|
||||
</div>
|
||||
|
||||
{/* Step 3: Refresh */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex size-5 items-center justify-center rounded-full bg-[var(--color-accent)]/15 text-[10px] font-bold text-[var(--color-accent)]">
|
||||
3
|
||||
</span>
|
||||
<span className="text-[12px] font-semibold text-[var(--color-text-primary)]">
|
||||
Refresh connection
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-[var(--color-accent)]/30 bg-[var(--color-accent)]/8 px-3 py-2 text-[12px] font-medium text-[var(--color-accent)] transition-all duration-200 hover:bg-[var(--color-accent)]/15 disabled:opacity-50"
|
||||
>
|
||||
{isRefreshing ? 'Checking…' : 'Check connection'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthCommandBlock() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
void navigator.clipboard.writeText(authCommand);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center overflow-hidden rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2">
|
||||
<KeyRound className="size-3 shrink-0 text-[var(--color-accent)]/60" />
|
||||
<code className="min-w-0 select-all truncate font-mono text-[12px] leading-relaxed text-[var(--color-text-primary)]">
|
||||
{authCommand}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border-subtle)] px-2.5 py-2 text-[var(--color-text-muted)] transition-colors duration-150 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
title="Copy command"
|
||||
aria-label="Copy authentication command"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3 text-[var(--color-status-success)]" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { ArrowDownToLine, Download, RefreshCw, Sparkles, X } from 'lucide-react';
|
||||
|
||||
import type { UpdateStatus } from '@shared/contracts/ipc';
|
||||
|
||||
export interface UpdateBannerProps {
|
||||
status: UpdateStatus;
|
||||
onViewDetails: () => void;
|
||||
onInstallUpdate: () => void;
|
||||
}
|
||||
|
||||
export function UpdateBanner({ status, onViewDetails, onInstallUpdate }: UpdateBannerProps) {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
const isActionable =
|
||||
status.state === 'available' ||
|
||||
status.state === 'downloading' ||
|
||||
status.state === 'downloaded';
|
||||
|
||||
// Nothing to show
|
||||
if (!isActionable) return null;
|
||||
|
||||
// Allow dismissal for transient states, never for downloaded
|
||||
if (dismissed && status.state !== 'downloaded') return null;
|
||||
|
||||
const version = status.version ? `v${status.version}` : '';
|
||||
|
||||
if (status.state === 'downloaded') {
|
||||
return (
|
||||
<div className="update-banner-enter px-3 pb-2" role="alert">
|
||||
<button
|
||||
className="group relative flex w-full items-center gap-2.5 overflow-hidden rounded-xl border border-[var(--color-status-success)]/25 bg-[var(--color-status-success)]/[0.07] px-3 py-2.5 text-left transition-all duration-200 hover:border-[var(--color-status-success)]/40 hover:bg-[var(--color-status-success)]/[0.12]"
|
||||
onClick={onInstallUpdate}
|
||||
type="button"
|
||||
>
|
||||
{/* Subtle glow effect */}
|
||||
<div className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100" style={{ background: 'radial-gradient(ellipse at center, rgba(52, 211, 153, 0.08), transparent 70%)' }} />
|
||||
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-[var(--color-status-success)]/15">
|
||||
<Sparkles className="size-3.5 text-[var(--color-status-success)]" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-[12px] font-semibold text-[var(--color-status-success)]">
|
||||
Update ready {version}
|
||||
</span>
|
||||
<span className="block text-[10px] text-[var(--color-text-muted)]">
|
||||
Restart to apply
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-lg bg-[var(--color-status-success)]/15 px-2 py-1 text-[10px] font-semibold text-[var(--color-status-success)] transition-all duration-200 group-hover:bg-[var(--color-status-success)]/25">
|
||||
<RefreshCw className="inline-block size-3 mr-1 align-[-2px]" />
|
||||
Restart
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// available / downloading
|
||||
const isDownloading = status.state === 'downloading';
|
||||
const percent = status.downloadProgress ? Math.round(status.downloadProgress.percent) : 0;
|
||||
|
||||
return (
|
||||
<div className="update-banner-enter px-3 pb-2" role="status">
|
||||
<div className="relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-2)]/60">
|
||||
<button
|
||||
className="group flex w-full items-center gap-2.5 px-3 py-2 text-left transition-all duration-200 hover:bg-[var(--color-surface-2)]"
|
||||
onClick={onViewDetails}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-[var(--color-accent)]/10">
|
||||
{isDownloading
|
||||
? <Download className="size-3 text-[var(--color-accent)] animate-pulse" />
|
||||
: <ArrowDownToLine className="size-3 text-[var(--color-accent)]" />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-[11px] font-medium text-[var(--color-text-primary)]">
|
||||
{isDownloading
|
||||
? `Downloading ${version}${percent > 0 ? ` · ${percent}%` : '…'}`
|
||||
: `Update available ${version}`}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="flex size-5 shrink-0 items-center justify-center rounded text-[var(--color-text-muted)] opacity-0 transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)] group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDismissed(true);
|
||||
}}
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</button>
|
||||
|
||||
{/* Download progress bar */}
|
||||
{isDownloading && percent > 0 && (
|
||||
<div className="h-[2px] w-full bg-[var(--color-surface-3)]">
|
||||
<div
|
||||
className="h-full bg-[var(--color-accent)] transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,3 +7,5 @@ export { TextInput } from './TextInput';
|
||||
export { TextareaInput } from './TextareaInput';
|
||||
export { SelectInput } from './SelectInput';
|
||||
export { InfoCallout } from './InfoCallout';
|
||||
export type { UpdateBannerProps } from './UpdateBanner';
|
||||
export { UpdateBanner } from './UpdateBanner';
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { DetectedPlatform } from './platform';
|
||||
|
||||
export interface InstallMethod {
|
||||
label: string;
|
||||
command: string;
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformInstallInfo {
|
||||
platform: DetectedPlatform;
|
||||
displayName: string;
|
||||
methods: InstallMethod[];
|
||||
}
|
||||
|
||||
export const installInstructions: PlatformInstallInfo[] = [
|
||||
{
|
||||
platform: 'macos',
|
||||
displayName: 'macOS',
|
||||
methods: [
|
||||
{ label: 'Homebrew', command: 'brew install copilot-cli', recommended: true },
|
||||
{ label: 'Install script', command: 'curl -fsSL https://gh.io/copilot-install | bash' },
|
||||
{ label: 'npm', command: 'npm install -g @github/copilot' },
|
||||
],
|
||||
},
|
||||
{
|
||||
platform: 'windows',
|
||||
displayName: 'Windows',
|
||||
methods: [
|
||||
{ label: 'WinGet', command: 'winget install GitHub.Copilot', recommended: true },
|
||||
{ label: 'npm', command: 'npm install -g @github/copilot' },
|
||||
],
|
||||
},
|
||||
{
|
||||
platform: 'linux',
|
||||
displayName: 'Linux',
|
||||
methods: [
|
||||
{ label: 'Install script', command: 'curl -fsSL https://gh.io/copilot-install | bash', recommended: true },
|
||||
{ label: 'Homebrew', command: 'brew install copilot-cli' },
|
||||
{ label: 'npm', command: 'npm install -g @github/copilot' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const authCommand = 'copilot auth login';
|
||||
|
||||
export function getInstallInfoForPlatform(platform: DetectedPlatform): PlatformInstallInfo {
|
||||
return installInstructions.find((i) => i.platform === platform)!;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ export type AssistantMessagePhase = 'default' | 'thinking' | 'final';
|
||||
export function getAssistantMessagePhase(
|
||||
session: SessionRecord,
|
||||
message: ChatMessageRecord,
|
||||
index: number,
|
||||
): AssistantMessagePhase {
|
||||
if (message.role !== 'assistant') {
|
||||
return 'default';
|
||||
@@ -23,17 +22,17 @@ export function getAssistantMessagePhase(
|
||||
return 'default';
|
||||
}
|
||||
|
||||
const lastCompletedAssistantIndex = findLastCompletedAssistantIndex(session.messages);
|
||||
return index === lastCompletedAssistantIndex ? 'final' : 'default';
|
||||
const lastId = findLastCompletedAssistantId(session.messages);
|
||||
return message.id === lastId ? 'final' : 'default';
|
||||
}
|
||||
|
||||
function findLastCompletedAssistantIndex(messages: ChatMessageRecord[]): number {
|
||||
function findLastCompletedAssistantId(messages: ChatMessageRecord[]): string | undefined {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message.role === 'assistant' && !message.pending && message.messageKind !== 'thinking') {
|
||||
return index;
|
||||
return message.id;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
export type DetectedPlatform = 'macos' | 'windows' | 'linux';
|
||||
|
||||
export const isMac = navigator.platform.startsWith('Mac');
|
||||
export const isWindows = navigator.platform.startsWith('Win');
|
||||
export const isLinux = !isMac && !isWindows;
|
||||
|
||||
export const detectedPlatform: DetectedPlatform =
|
||||
isMac ? 'macos' : isWindows ? 'windows' : 'linux';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { SessionEventRecord } from '@shared/domain/event';
|
||||
import type { QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||
import type { QuotaSnapshot, WorkflowDiagnosticKind, WorkflowDiagnosticSeverity } from '@shared/contracts/sidecar';
|
||||
|
||||
export interface AgentActivityState {
|
||||
agentId: string;
|
||||
@@ -260,6 +260,22 @@ function formatHookType(hookType: string | undefined): string {
|
||||
return hookTypeLabels[hookType] ?? hookType;
|
||||
}
|
||||
|
||||
const diagnosticLabels: Record<WorkflowDiagnosticKind, string> = {
|
||||
'workflow-warning': 'Workflow warning',
|
||||
'workflow-error': 'Workflow error',
|
||||
'executor-failed': 'Executor failed',
|
||||
'subworkflow-warning': 'Subworkflow warning',
|
||||
'subworkflow-error': 'Subworkflow error',
|
||||
};
|
||||
|
||||
function formatDiagnosticLabel(
|
||||
kind: WorkflowDiagnosticKind | undefined,
|
||||
severity: WorkflowDiagnosticSeverity | undefined,
|
||||
): string {
|
||||
if (kind) return diagnosticLabels[kind] ?? kind;
|
||||
return severity === 'error' ? 'Workflow error' : 'Workflow warning';
|
||||
}
|
||||
|
||||
function formatTurnEventEntry(event: SessionEventRecord): TurnEventEntry | undefined {
|
||||
switch (event.kind) {
|
||||
case 'subagent':
|
||||
@@ -300,6 +316,21 @@ function formatTurnEventEntry(event: SessionEventRecord): TurnEventEntry | undef
|
||||
phase: event.compactionPhase,
|
||||
success: event.compactionSuccess,
|
||||
};
|
||||
case 'workflow-diagnostic': {
|
||||
const label = formatDiagnosticLabel(event.diagnosticKind, event.diagnosticSeverity);
|
||||
const detailParts: string[] = [];
|
||||
if (event.executorId) detailParts.push(event.executorId);
|
||||
if (event.subworkflowId) detailParts.push(event.subworkflowId);
|
||||
if (event.exceptionType) detailParts.push(event.exceptionType);
|
||||
if (event.diagnosticMessage) detailParts.push(event.diagnosticMessage);
|
||||
return {
|
||||
kind: event.kind,
|
||||
occurredAt: event.occurredAt,
|
||||
label,
|
||||
detail: detailParts.length > 0 ? detailParts.join(' · ') : undefined,
|
||||
success: event.diagnosticSeverity === 'error' ? false : undefined,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -626,6 +626,19 @@ body {
|
||||
animation: banner-slide-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ── Update banner slide-up ──────────────────────────────────── */
|
||||
|
||||
@keyframes update-banner-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.update-banner-enter {
|
||||
animation: update-banner-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ── Thinking process section ────────────────────────────────── */
|
||||
|
||||
@keyframes thinking-process-in {
|
||||
@@ -651,6 +664,7 @@ body {
|
||||
.msg-actions-enter,
|
||||
.session-item-enter,
|
||||
.banner-slide-enter,
|
||||
.update-banner-enter,
|
||||
.thinking-process-enter {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ export const ipcChannels = {
|
||||
removeProject: 'workspace:remove-project',
|
||||
resolveWorkspaceDiscoveredTooling: 'workspace:resolve-discovered-tooling',
|
||||
refreshProjectGitContext: 'projects:refresh-git-context',
|
||||
getProjectGitDetails: 'projects:get-git-details',
|
||||
getProjectGitFilePreview: 'projects:get-git-file-preview',
|
||||
rescanProjectConfigs: 'project:rescan-configs',
|
||||
rescanProjectCustomization: 'project:rescan-customization',
|
||||
resolveProjectDiscoveredTooling: 'project:resolve-discovered-tooling',
|
||||
@@ -17,6 +19,7 @@ export const ipcChannels = {
|
||||
setTerminalHeight: 'settings:set-terminal-height',
|
||||
setNotificationsEnabled: 'settings:set-notifications-enabled',
|
||||
setMinimizeToTray: 'settings:set-minimize-to-tray',
|
||||
setGitAutoRefreshEnabled: 'settings:set-git-auto-refresh-enabled',
|
||||
checkForUpdates: 'app:check-for-updates',
|
||||
installUpdate: 'app:install-update',
|
||||
saveMcpServer: 'tooling:mcp:save',
|
||||
@@ -49,8 +52,19 @@ export const ipcChannels = {
|
||||
dismissSessionPlanReview: 'sessions:dismiss-plan-review',
|
||||
dismissSessionMcpAuth: 'sessions:dismiss-mcp-auth',
|
||||
startSessionMcpAuth: 'sessions:start-mcp-auth',
|
||||
discardSessionRunGitChanges: 'sessions:discard-run-git-changes',
|
||||
suggestProjectGitCommitMessage: 'sessions:suggest-git-commit-message',
|
||||
querySessions: 'sessions:query',
|
||||
updateSessionModelConfig: 'sessions:update-model-config',
|
||||
stageProjectGitFiles: 'git:stage-files',
|
||||
unstageProjectGitFiles: 'git:unstage-files',
|
||||
commitProjectGitChanges: 'git:commit',
|
||||
pushProjectGit: 'git:push',
|
||||
fetchProjectGit: 'git:fetch',
|
||||
pullProjectGit: 'git:pull',
|
||||
createProjectGitBranch: 'git:create-branch',
|
||||
switchProjectGitBranch: 'git:switch-branch',
|
||||
deleteProjectGitBranch: 'git:delete-branch',
|
||||
selectProject: 'selection:project',
|
||||
selectPattern: 'selection:pattern',
|
||||
selectSession: 'selection:session',
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
||||
import type { ProjectRecord } from '@shared/domain/project';
|
||||
import type {
|
||||
ProjectGitBranchSummary,
|
||||
ProjectGitCommitMessageSuggestion,
|
||||
ProjectGitDetails,
|
||||
ProjectGitDiffPreview,
|
||||
ProjectGitFileReference,
|
||||
ProjectRecord,
|
||||
} from '@shared/domain/project';
|
||||
import type { QuerySessionsInput, SessionQueryResult } from '@shared/domain/sessionLibrary';
|
||||
import type { SessionEventRecord } from '@shared/domain/event';
|
||||
import type { TerminalExitInfo, TerminalSnapshot } from '@shared/domain/terminal';
|
||||
@@ -175,6 +182,59 @@ export interface SetTerminalHeightInput {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface ProjectGitInput {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface ProjectGitDetailsInput extends ProjectGitInput {
|
||||
commitLimit?: number;
|
||||
}
|
||||
|
||||
export interface ProjectGitFilePreviewInput extends ProjectGitInput {
|
||||
file: ProjectGitFileReference;
|
||||
}
|
||||
|
||||
export interface ProjectGitFileSelectionInput extends ProjectGitInput {
|
||||
files: ProjectGitFileReference[];
|
||||
}
|
||||
|
||||
export interface DiscardSessionRunGitChangesInput {
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
files?: ProjectGitFileReference[];
|
||||
}
|
||||
|
||||
export interface SuggestProjectGitCommitMessageInput {
|
||||
sessionId: string;
|
||||
runId?: string;
|
||||
conventionalType?: ProjectGitCommitMessageSuggestion['type'];
|
||||
}
|
||||
|
||||
export interface CommitProjectGitChangesInput extends ProjectGitInput {
|
||||
message: string;
|
||||
files?: ProjectGitFileReference[];
|
||||
push?: boolean;
|
||||
}
|
||||
|
||||
export interface PullProjectGitInput extends ProjectGitInput {
|
||||
rebase?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateProjectGitBranchInput extends ProjectGitInput {
|
||||
name: string;
|
||||
startPoint?: string;
|
||||
checkout?: boolean;
|
||||
}
|
||||
|
||||
export interface SwitchProjectGitBranchInput extends ProjectGitInput {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DeleteProjectGitBranchInput extends ProjectGitInput {
|
||||
name: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateStatusState = 'idle' | 'checking' | 'up-to-date' | 'available' | 'downloading' | 'downloaded' | 'error';
|
||||
|
||||
export interface UpdateDownloadProgress {
|
||||
@@ -241,6 +301,7 @@ export interface ElectronApi {
|
||||
setTerminalHeight(input: SetTerminalHeightInput): Promise<WorkspaceState>;
|
||||
setNotificationsEnabled(enabled: boolean): Promise<WorkspaceState>;
|
||||
setMinimizeToTray(enabled: boolean): Promise<WorkspaceState>;
|
||||
setGitAutoRefreshEnabled(enabled: boolean): Promise<WorkspaceState>;
|
||||
checkForUpdates(): Promise<UpdateStatus>;
|
||||
installUpdate(): Promise<void>;
|
||||
describeTerminal(): Promise<TerminalSnapshot | undefined>;
|
||||
@@ -252,6 +313,19 @@ export interface ElectronApi {
|
||||
openAppDataFolder(): Promise<void>;
|
||||
resetLocalWorkspace(): Promise<WorkspaceState>;
|
||||
getQuota(): Promise<Record<string, QuotaSnapshot>>;
|
||||
getProjectGitDetails(input: ProjectGitDetailsInput): Promise<ProjectGitDetails>;
|
||||
getProjectGitFilePreview(input: ProjectGitFilePreviewInput): Promise<ProjectGitDiffPreview | undefined>;
|
||||
discardSessionRunGitChanges(input: DiscardSessionRunGitChangesInput): Promise<WorkspaceState>;
|
||||
stageProjectGitFiles(input: ProjectGitFileSelectionInput): Promise<WorkspaceState>;
|
||||
unstageProjectGitFiles(input: ProjectGitFileSelectionInput): Promise<WorkspaceState>;
|
||||
suggestProjectGitCommitMessage(input: SuggestProjectGitCommitMessageInput): Promise<ProjectGitCommitMessageSuggestion>;
|
||||
commitProjectGitChanges(input: CommitProjectGitChangesInput): Promise<WorkspaceState>;
|
||||
pushProjectGit(input: ProjectGitInput): Promise<WorkspaceState>;
|
||||
fetchProjectGit(input: ProjectGitInput): Promise<WorkspaceState>;
|
||||
pullProjectGit(input: PullProjectGitInput): Promise<WorkspaceState>;
|
||||
createProjectGitBranch(input: CreateProjectGitBranchInput): Promise<WorkspaceState>;
|
||||
switchProjectGitBranch(input: SwitchProjectGitBranchInput): Promise<WorkspaceState>;
|
||||
deleteProjectGitBranch(input: DeleteProjectGitBranchInput): Promise<WorkspaceState>;
|
||||
onTerminalData(listener: (data: string) => void): () => void;
|
||||
onTerminalExit(listener: (info: TerminalExitInfo) => void): () => void;
|
||||
onWorkspaceUpdated(listener: (workspace: WorkspaceState) => void): () => void;
|
||||
|
||||
@@ -72,6 +72,12 @@ export interface ValidatePatternCommand {
|
||||
export type InteractionMode = 'interactive' | 'plan';
|
||||
export type MessageMode = 'enqueue' | 'immediate';
|
||||
|
||||
export interface WorkflowCheckpointResume {
|
||||
workflowSessionId: string;
|
||||
checkpointId: string;
|
||||
storePath: string;
|
||||
}
|
||||
|
||||
export interface RunTurnCommand {
|
||||
type: 'run-turn';
|
||||
requestId: string;
|
||||
@@ -85,6 +91,7 @@ export interface RunTurnCommand {
|
||||
messages: ChatMessageRecord[];
|
||||
attachments?: ChatMessageAttachment[];
|
||||
tooling?: RunTurnToolingConfig;
|
||||
resumeFromCheckpoint?: WorkflowCheckpointResume;
|
||||
}
|
||||
|
||||
export interface CancelTurnCommand {
|
||||
@@ -384,6 +391,38 @@ export interface PendingMessagesModifiedEvent {
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowCheckpointSavedEvent {
|
||||
type: 'workflow-checkpoint-saved';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
workflowSessionId: string;
|
||||
checkpointId: string;
|
||||
storePath: string;
|
||||
stepNumber: number;
|
||||
}
|
||||
|
||||
export type WorkflowDiagnosticSeverity = 'warning' | 'error';
|
||||
export type WorkflowDiagnosticKind =
|
||||
| 'workflow-warning'
|
||||
| 'workflow-error'
|
||||
| 'executor-failed'
|
||||
| 'subworkflow-warning'
|
||||
| 'subworkflow-error';
|
||||
|
||||
export interface WorkflowDiagnosticEvent {
|
||||
type: 'workflow-diagnostic';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
severity: WorkflowDiagnosticSeverity;
|
||||
diagnosticKind: WorkflowDiagnosticKind;
|
||||
message: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
executorId?: string;
|
||||
subworkflowId?: string;
|
||||
exceptionType?: string;
|
||||
}
|
||||
|
||||
export interface CopilotSessionInfo {
|
||||
copilotSessionId: string;
|
||||
managedByAryx: boolean;
|
||||
@@ -563,6 +602,8 @@ export type SidecarEvent =
|
||||
| SessionUsageEvent
|
||||
| SessionCompactionEvent
|
||||
| PendingMessagesModifiedEvent
|
||||
| WorkflowCheckpointSavedEvent
|
||||
| WorkflowDiagnosticEvent
|
||||
| ApprovalRequestedEvent
|
||||
| UserInputRequestedEvent
|
||||
| McpOauthRequiredEvent
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
||||
import type { ChatMessageKind } from '@shared/domain/session';
|
||||
|
||||
import type { QuotaSnapshot, ToolCallFileChangePreview } from '@shared/contracts/sidecar';
|
||||
import type {
|
||||
QuotaSnapshot,
|
||||
ToolCallFileChangePreview,
|
||||
WorkflowDiagnosticKind,
|
||||
WorkflowDiagnosticSeverity,
|
||||
} from '@shared/contracts/sidecar';
|
||||
|
||||
export type SessionActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
|
||||
|
||||
@@ -19,7 +24,8 @@ export type SessionEventKind =
|
||||
| 'session-usage'
|
||||
| 'session-compaction'
|
||||
| 'pending-messages-modified'
|
||||
| 'assistant-usage';
|
||||
| 'assistant-usage'
|
||||
| 'workflow-diagnostic';
|
||||
|
||||
export type SubagentEventKind = 'started' | 'completed' | 'failed' | 'selected' | 'deselected';
|
||||
|
||||
@@ -86,4 +92,12 @@ export interface SessionEventRecord {
|
||||
usageDuration?: number;
|
||||
usageTotalNanoAiu?: number;
|
||||
usageQuotaSnapshots?: Record<string, QuotaSnapshot>;
|
||||
|
||||
// Workflow diagnostic fields
|
||||
diagnosticSeverity?: WorkflowDiagnosticSeverity;
|
||||
diagnosticKind?: WorkflowDiagnosticKind;
|
||||
diagnosticMessage?: string;
|
||||
executorId?: string;
|
||||
subworkflowId?: string;
|
||||
exceptionType?: string;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,122 @@ export interface ProjectGitCommitSummary {
|
||||
committedAt: string;
|
||||
}
|
||||
|
||||
export interface ProjectGitCommitLogEntry extends ProjectGitCommitSummary {
|
||||
authorName: string;
|
||||
refNames?: string;
|
||||
}
|
||||
|
||||
export type ProjectGitWorkingTreeFileStatus =
|
||||
| 'added'
|
||||
| 'modified'
|
||||
| 'deleted'
|
||||
| 'renamed'
|
||||
| 'copied'
|
||||
| 'type-changed'
|
||||
| 'unmerged'
|
||||
| 'untracked';
|
||||
|
||||
export interface ProjectGitWorkingTreeFile {
|
||||
path: string;
|
||||
previousPath?: string;
|
||||
stagedStatus?: ProjectGitWorkingTreeFileStatus;
|
||||
unstagedStatus?: ProjectGitWorkingTreeFileStatus;
|
||||
isConflicted?: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectGitFileReference {
|
||||
path: string;
|
||||
previousPath?: string;
|
||||
}
|
||||
|
||||
export interface ProjectGitDiffPreview extends ProjectGitFileReference {
|
||||
diff?: string;
|
||||
newFileContents?: string;
|
||||
isBinary?: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectGitBaselineFile extends ProjectGitFileReference {
|
||||
combinedDiff?: string;
|
||||
untrackedContentBase64?: string;
|
||||
isBinary?: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectGitWorkingTreeSnapshot {
|
||||
scannedAt: string;
|
||||
repoRoot: string;
|
||||
branch?: string;
|
||||
changedFileCount: number;
|
||||
changes: ProjectGitChangeSummary;
|
||||
files: ProjectGitWorkingTreeFile[];
|
||||
}
|
||||
|
||||
export type ProjectGitRunChangeOrigin = 'run-created' | 'pre-existing';
|
||||
export type ProjectGitRunChangeKind = ProjectGitWorkingTreeFileStatus | 'cleaned';
|
||||
|
||||
export interface ProjectGitRunChangeCounts {
|
||||
added: number;
|
||||
modified: number;
|
||||
deleted: number;
|
||||
renamed: number;
|
||||
copied: number;
|
||||
typeChanged: number;
|
||||
unmerged: number;
|
||||
untracked: number;
|
||||
cleaned: number;
|
||||
}
|
||||
|
||||
export interface ProjectGitRunChangedFile extends ProjectGitFileReference {
|
||||
kind: ProjectGitRunChangeKind;
|
||||
origin: ProjectGitRunChangeOrigin;
|
||||
stagedStatus?: ProjectGitWorkingTreeFileStatus;
|
||||
unstagedStatus?: ProjectGitWorkingTreeFileStatus;
|
||||
isConflicted?: boolean;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
canRevert: boolean;
|
||||
preview?: ProjectGitDiffPreview;
|
||||
}
|
||||
|
||||
export interface ProjectGitRunChangeSummary {
|
||||
generatedAt: string;
|
||||
branchAtStart?: string;
|
||||
branchAtEnd?: string;
|
||||
branchChanged?: boolean;
|
||||
fileCount: number;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
counts: ProjectGitRunChangeCounts;
|
||||
files: ProjectGitRunChangedFile[];
|
||||
}
|
||||
|
||||
export interface ProjectGitBranchSummary {
|
||||
name: string;
|
||||
isCurrent: boolean;
|
||||
upstream?: string;
|
||||
}
|
||||
|
||||
export interface ProjectGitDetails {
|
||||
scannedAt: string;
|
||||
context: ProjectGitContext;
|
||||
workingTree?: ProjectGitWorkingTreeSnapshot;
|
||||
branches: ProjectGitBranchSummary[];
|
||||
recentCommits: ProjectGitCommitLogEntry[];
|
||||
}
|
||||
|
||||
export type ProjectGitConventionalCommitType =
|
||||
| 'feat'
|
||||
| 'fix'
|
||||
| 'refactor'
|
||||
| 'docs'
|
||||
| 'test'
|
||||
| 'chore';
|
||||
|
||||
export interface ProjectGitCommitMessageSuggestion {
|
||||
type: ProjectGitConventionalCommitType;
|
||||
subject: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ProjectGitContext {
|
||||
status: ProjectGitContextStatus;
|
||||
scannedAt: string;
|
||||
|
||||
@@ -5,7 +5,18 @@ import type {
|
||||
} from '@shared/domain/approval';
|
||||
import type { ToolCallFileChangePreview } from '@shared/contracts/sidecar';
|
||||
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
||||
import type { ProjectRecord } from '@shared/domain/project';
|
||||
import type {
|
||||
ProjectGitBaselineFile,
|
||||
ProjectGitChangeSummary,
|
||||
ProjectGitDiffPreview,
|
||||
ProjectGitRunChangeCounts,
|
||||
ProjectGitRunChangedFile,
|
||||
ProjectGitRunChangeSummary,
|
||||
ProjectGitWorkingTreeFile,
|
||||
ProjectGitWorkingTreeFileStatus,
|
||||
ProjectGitWorkingTreeSnapshot,
|
||||
ProjectRecord,
|
||||
} from '@shared/domain/project';
|
||||
import { createId } from '@shared/utils/ids';
|
||||
|
||||
export type SessionRunStatus = 'running' | 'completed' | 'cancelled' | 'error';
|
||||
@@ -60,6 +71,7 @@ export interface SessionRunRecord {
|
||||
requestId: string;
|
||||
projectId: string;
|
||||
projectPath: string;
|
||||
workingDirectory?: string;
|
||||
workspaceKind: SessionRunWorkspaceKind;
|
||||
patternId: string;
|
||||
patternName: string;
|
||||
@@ -70,15 +82,21 @@ export interface SessionRunRecord {
|
||||
status: SessionRunStatus;
|
||||
agents: RunTimelineAgentRecord[];
|
||||
events: RunTimelineEventRecord[];
|
||||
preRunGitSnapshot?: ProjectGitWorkingTreeSnapshot;
|
||||
preRunGitBaselineFiles?: ProjectGitBaselineFile[];
|
||||
postRunGitSummary?: ProjectGitRunChangeSummary;
|
||||
}
|
||||
|
||||
export interface CreateSessionRunRecordInput {
|
||||
requestId: string;
|
||||
project: Pick<ProjectRecord, 'id' | 'path'>;
|
||||
workingDirectory?: string;
|
||||
workspaceKind: SessionRunWorkspaceKind;
|
||||
pattern: Pick<PatternDefinition, 'id' | 'name' | 'mode' | 'agents'>;
|
||||
triggerMessageId: string;
|
||||
startedAt: string;
|
||||
preRunGitSnapshot?: ProjectGitWorkingTreeSnapshot;
|
||||
preRunGitBaselineFiles?: ProjectGitBaselineFile[];
|
||||
}
|
||||
|
||||
export interface AppendRunActivityEventInput {
|
||||
@@ -122,6 +140,228 @@ function normalizeOptionalPreviewText(value: string | undefined): string | undef
|
||||
return value?.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInteger(value: number | undefined): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const normalized = Math.round(value);
|
||||
return normalized >= 0 ? normalized : 0;
|
||||
}
|
||||
|
||||
function normalizeWorkingTreeFileStatus(
|
||||
value: ProjectGitWorkingTreeFileStatus | undefined,
|
||||
): ProjectGitWorkingTreeFileStatus | undefined {
|
||||
switch (value) {
|
||||
case 'added':
|
||||
case 'modified':
|
||||
case 'deleted':
|
||||
case 'renamed':
|
||||
case 'copied':
|
||||
case 'type-changed':
|
||||
case 'unmerged':
|
||||
case 'untracked':
|
||||
return value;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWorkingTreeChangeSummary(
|
||||
summary: Partial<ProjectGitChangeSummary> | undefined,
|
||||
): ProjectGitChangeSummary {
|
||||
return {
|
||||
staged: normalizeNonNegativeInteger(summary?.staged),
|
||||
unstaged: normalizeNonNegativeInteger(summary?.unstaged),
|
||||
untracked: normalizeNonNegativeInteger(summary?.untracked),
|
||||
conflicted: normalizeNonNegativeInteger(summary?.conflicted),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWorkingTreeFile(
|
||||
file: ProjectGitWorkingTreeFile,
|
||||
): ProjectGitWorkingTreeFile | undefined {
|
||||
const path = normalizeOptionalString(file.path);
|
||||
if (!path) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
previousPath: normalizeOptionalString(file.previousPath),
|
||||
stagedStatus: normalizeWorkingTreeFileStatus(file.stagedStatus),
|
||||
unstagedStatus: normalizeWorkingTreeFileStatus(file.unstagedStatus),
|
||||
...(file.isConflicted ? { isConflicted: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWorkingTreeSnapshot(
|
||||
snapshot: ProjectGitWorkingTreeSnapshot | undefined,
|
||||
): ProjectGitWorkingTreeSnapshot | undefined {
|
||||
if (!snapshot) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scannedAt = normalizeOptionalString(snapshot.scannedAt);
|
||||
const repoRoot = normalizeOptionalString(snapshot.repoRoot);
|
||||
if (!scannedAt || !repoRoot) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const files = (snapshot.files ?? []).flatMap((file) => {
|
||||
const normalized = normalizeWorkingTreeFile(file);
|
||||
return normalized ? [normalized] : [];
|
||||
});
|
||||
|
||||
return {
|
||||
scannedAt,
|
||||
repoRoot,
|
||||
branch: normalizeOptionalString(snapshot.branch),
|
||||
changedFileCount: normalizeNonNegativeInteger(snapshot.changedFileCount),
|
||||
changes: normalizeWorkingTreeChangeSummary(snapshot.changes),
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGitDiffPreview(
|
||||
preview: ProjectGitDiffPreview,
|
||||
): ProjectGitDiffPreview | undefined {
|
||||
const path = normalizeOptionalString(preview.path);
|
||||
if (!path) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
previousPath: normalizeOptionalString(preview.previousPath),
|
||||
diff: normalizeOptionalPreviewText(preview.diff),
|
||||
newFileContents: normalizeOptionalPreviewText(preview.newFileContents),
|
||||
...(preview.isBinary ? { isBinary: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGitBaselineFile(
|
||||
file: ProjectGitBaselineFile,
|
||||
): ProjectGitBaselineFile | undefined {
|
||||
const path = normalizeOptionalString(file.path);
|
||||
if (!path) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
previousPath: normalizeOptionalString(file.previousPath),
|
||||
combinedDiff: normalizeOptionalPreviewText(file.combinedDiff),
|
||||
untrackedContentBase64: normalizeOptionalString(file.untrackedContentBase64),
|
||||
...(file.isBinary ? { isBinary: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGitBaselineFiles(
|
||||
files: readonly ProjectGitBaselineFile[] | undefined,
|
||||
): ProjectGitBaselineFile[] | undefined {
|
||||
if (!files || files.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = files.flatMap((file) => {
|
||||
const nextFile = normalizeGitBaselineFile(file);
|
||||
return nextFile ? [nextFile] : [];
|
||||
});
|
||||
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeGitRunChangeKind(
|
||||
value: ProjectGitRunChangedFile['kind'] | undefined,
|
||||
): ProjectGitRunChangedFile['kind'] | undefined {
|
||||
switch (value) {
|
||||
case 'cleaned':
|
||||
return value;
|
||||
case 'added':
|
||||
case 'modified':
|
||||
case 'deleted':
|
||||
case 'renamed':
|
||||
case 'copied':
|
||||
case 'type-changed':
|
||||
case 'unmerged':
|
||||
case 'untracked':
|
||||
return value;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGitRunChangeCounts(
|
||||
counts: Partial<ProjectGitRunChangeCounts> | undefined,
|
||||
): ProjectGitRunChangeCounts {
|
||||
return {
|
||||
added: normalizeNonNegativeInteger(counts?.added),
|
||||
modified: normalizeNonNegativeInteger(counts?.modified),
|
||||
deleted: normalizeNonNegativeInteger(counts?.deleted),
|
||||
renamed: normalizeNonNegativeInteger(counts?.renamed),
|
||||
copied: normalizeNonNegativeInteger(counts?.copied),
|
||||
typeChanged: normalizeNonNegativeInteger(counts?.typeChanged),
|
||||
unmerged: normalizeNonNegativeInteger(counts?.unmerged),
|
||||
untracked: normalizeNonNegativeInteger(counts?.untracked),
|
||||
cleaned: normalizeNonNegativeInteger(counts?.cleaned),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGitRunChangedFile(
|
||||
file: ProjectGitRunChangedFile,
|
||||
): ProjectGitRunChangedFile | undefined {
|
||||
const path = normalizeOptionalString(file.path);
|
||||
const kind = normalizeGitRunChangeKind(file.kind);
|
||||
if (!path || !kind) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
previousPath: normalizeOptionalString(file.previousPath),
|
||||
kind,
|
||||
origin: file.origin === 'pre-existing' ? 'pre-existing' : 'run-created',
|
||||
stagedStatus: normalizeWorkingTreeFileStatus(file.stagedStatus),
|
||||
unstagedStatus: normalizeWorkingTreeFileStatus(file.unstagedStatus),
|
||||
...(file.isConflicted ? { isConflicted: true } : {}),
|
||||
additions: normalizeNonNegativeInteger(file.additions),
|
||||
deletions: normalizeNonNegativeInteger(file.deletions),
|
||||
canRevert: file.canRevert === true,
|
||||
preview: file.preview ? normalizeGitDiffPreview(file.preview) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGitRunChangeSummary(
|
||||
summary: ProjectGitRunChangeSummary | undefined,
|
||||
): ProjectGitRunChangeSummary | undefined {
|
||||
if (!summary) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const generatedAt = normalizeOptionalString(summary.generatedAt);
|
||||
if (!generatedAt) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const files = (summary.files ?? []).flatMap((file) => {
|
||||
const normalized = normalizeGitRunChangedFile(file);
|
||||
return normalized ? [normalized] : [];
|
||||
});
|
||||
|
||||
return {
|
||||
generatedAt,
|
||||
branchAtStart: normalizeOptionalString(summary.branchAtStart),
|
||||
branchAtEnd: normalizeOptionalString(summary.branchAtEnd),
|
||||
...(summary.branchChanged ? { branchChanged: true } : {}),
|
||||
fileCount: normalizeNonNegativeInteger(summary.fileCount),
|
||||
additions: normalizeNonNegativeInteger(summary.additions),
|
||||
deletions: normalizeNonNegativeInteger(summary.deletions),
|
||||
counts: normalizeGitRunChangeCounts(summary.counts),
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeToolCallFileChange(
|
||||
change: ToolCallFileChangePreview,
|
||||
): ToolCallFileChangePreview | undefined {
|
||||
@@ -365,6 +605,7 @@ export function createSessionRunRecord(input: CreateSessionRunRecordInput): Sess
|
||||
requestId: input.requestId,
|
||||
projectId: input.project.id,
|
||||
projectPath: input.project.path,
|
||||
workingDirectory: normalizeOptionalString(input.workingDirectory),
|
||||
workspaceKind: input.workspaceKind,
|
||||
patternId: input.pattern.id,
|
||||
patternName: input.pattern.name,
|
||||
@@ -373,6 +614,9 @@ export function createSessionRunRecord(input: CreateSessionRunRecordInput): Sess
|
||||
startedAt: input.startedAt,
|
||||
status: 'running',
|
||||
completedAt: undefined,
|
||||
preRunGitSnapshot: normalizeWorkingTreeSnapshot(input.preRunGitSnapshot),
|
||||
preRunGitBaselineFiles: normalizeGitBaselineFiles(input.preRunGitBaselineFiles),
|
||||
postRunGitSummary: undefined,
|
||||
agents: input.pattern.agents
|
||||
.map((agent): RunTimelineAgentRecord => ({
|
||||
agentId: agent.id,
|
||||
@@ -408,6 +652,7 @@ export function normalizeSessionRunRecords(
|
||||
const requestId = normalizeOptionalString(run.requestId);
|
||||
const projectId = normalizeOptionalString(run.projectId);
|
||||
const projectPath = normalizeOptionalString(run.projectPath);
|
||||
const workingDirectory = normalizeOptionalString(run.workingDirectory);
|
||||
const patternId = normalizeOptionalString(run.patternId);
|
||||
const patternName = normalizeOptionalString(run.patternName);
|
||||
const triggerMessageId = normalizeOptionalString(run.triggerMessageId);
|
||||
@@ -422,6 +667,7 @@ export function normalizeSessionRunRecords(
|
||||
requestId,
|
||||
projectId,
|
||||
projectPath,
|
||||
workingDirectory,
|
||||
workspaceKind: run.workspaceKind === 'scratchpad' ? 'scratchpad' : 'project',
|
||||
patternId,
|
||||
patternName,
|
||||
@@ -430,6 +676,9 @@ export function normalizeSessionRunRecords(
|
||||
startedAt,
|
||||
completedAt: normalizeOptionalString(run.completedAt),
|
||||
status: run.status === 'error' ? 'error' : run.status === 'running' ? 'running' : run.status === 'cancelled' ? 'cancelled' : 'completed',
|
||||
preRunGitSnapshot: normalizeWorkingTreeSnapshot(run.preRunGitSnapshot),
|
||||
preRunGitBaselineFiles: normalizeGitBaselineFiles(run.preRunGitBaselineFiles),
|
||||
postRunGitSummary: normalizeGitRunChangeSummary(run.postRunGitSummary),
|
||||
agents: run.agents.flatMap((agent) => {
|
||||
const normalized = normalizeRunTimelineAgent(agent);
|
||||
return normalized ? [normalized] : [];
|
||||
@@ -677,3 +926,26 @@ export function failSessionRunRecord(
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
export function setSessionRunGitSummary(
|
||||
run: SessionRunRecord,
|
||||
summary: ProjectGitRunChangeSummary | undefined,
|
||||
): SessionRunRecord {
|
||||
const normalizedSummary = normalizeGitRunChangeSummary(summary);
|
||||
if (normalizedSummary === undefined && run.postRunGitSummary === undefined) {
|
||||
return run;
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedSummary !== undefined
|
||||
&& run.postRunGitSummary !== undefined
|
||||
&& JSON.stringify(normalizedSummary) === JSON.stringify(run.postRunGitSummary)
|
||||
) {
|
||||
return run;
|
||||
}
|
||||
|
||||
return {
|
||||
...run,
|
||||
postRunGitSummary: normalizedSummary,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface WorkspaceSettings {
|
||||
terminalHeight?: number;
|
||||
notificationsEnabled?: boolean;
|
||||
minimizeToTray?: boolean;
|
||||
gitAutoRefreshEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionToolingSelection {
|
||||
@@ -209,6 +210,7 @@ export function normalizeWorkspaceSettings(settings?: Partial<WorkspaceSettings>
|
||||
...(terminalHeight !== undefined ? { terminalHeight } : {}),
|
||||
...(settings?.notificationsEnabled !== undefined ? { notificationsEnabled: settings.notificationsEnabled } : {}),
|
||||
...(settings?.minimizeToTray !== undefined ? { minimizeToTray: settings.minimizeToTray } : {}),
|
||||
...(settings?.gitAutoRefreshEnabled !== undefined ? { gitAutoRefreshEnabled: settings.gitAutoRefreshEnabled } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import type { RunTurnCommand } from '@shared/contracts/sidecar';
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type {
|
||||
ProjectGitRunChangeSummary,
|
||||
ProjectGitWorkingTreeSnapshot,
|
||||
ProjectRecord,
|
||||
} from '@shared/domain/project';
|
||||
import { SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||
|
||||
const TIMESTAMP = '2026-03-31T00:00:00.000Z';
|
||||
|
||||
mock.module('electron', () => {
|
||||
const electronMock = {
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx',
|
||||
getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures',
|
||||
},
|
||||
dialog: {
|
||||
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
|
||||
},
|
||||
shell: {
|
||||
openPath: async () => '',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...electronMock,
|
||||
default: electronMock,
|
||||
};
|
||||
});
|
||||
|
||||
mock.module('keytar', () => ({
|
||||
default: {
|
||||
getPassword: async () => null,
|
||||
setPassword: async () => undefined,
|
||||
deletePassword: async () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
const { AryxAppService } = await import('@main/AryxAppService');
|
||||
|
||||
function createProject(overrides?: Partial<ProjectRecord>): ProjectRecord {
|
||||
return {
|
||||
id: 'project-alpha',
|
||||
name: 'alpha',
|
||||
path: 'C:\\workspace\\alpha',
|
||||
addedAt: TIMESTAMP,
|
||||
git: {
|
||||
status: 'ready',
|
||||
scannedAt: TIMESTAMP,
|
||||
repoRoot: 'C:\\workspace\\alpha',
|
||||
branch: 'main',
|
||||
isDirty: false,
|
||||
changedFileCount: 0,
|
||||
changes: {
|
||||
staged: 0,
|
||||
unstaged: 0,
|
||||
untracked: 0,
|
||||
conflicted: 0,
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createSession(projectId: string, patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
||||
return {
|
||||
id: 'session-alpha',
|
||||
projectId,
|
||||
patternId,
|
||||
title: 'Alpha session',
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
status: 'idle',
|
||||
messages: [],
|
||||
runs: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createFixture(overrides?: {
|
||||
project?: Partial<ProjectRecord>;
|
||||
session?: Partial<SessionRecord>;
|
||||
}): {
|
||||
workspace: WorkspaceState;
|
||||
pattern: PatternDefinition;
|
||||
project: ProjectRecord;
|
||||
session: SessionRecord;
|
||||
} {
|
||||
const workspace = createWorkspaceSeed();
|
||||
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
|
||||
if (!pattern) {
|
||||
throw new Error('Expected the workspace seed to include a single-agent pattern.');
|
||||
}
|
||||
|
||||
const project = createProject(overrides?.project);
|
||||
const session = createSession(project.id, pattern.id, overrides?.session);
|
||||
|
||||
workspace.projects = [project];
|
||||
workspace.sessions = [session];
|
||||
workspace.selectedProjectId = project.id;
|
||||
workspace.selectedPatternId = pattern.id;
|
||||
workspace.selectedSessionId = session.id;
|
||||
|
||||
return { workspace, pattern, project, session };
|
||||
}
|
||||
|
||||
function createSnapshot(): ProjectGitWorkingTreeSnapshot {
|
||||
return {
|
||||
scannedAt: TIMESTAMP,
|
||||
repoRoot: 'C:\\workspace\\alpha',
|
||||
branch: 'main',
|
||||
changedFileCount: 2,
|
||||
changes: {
|
||||
staged: 1,
|
||||
unstaged: 1,
|
||||
untracked: 0,
|
||||
conflicted: 0,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'src\\auth.ts',
|
||||
stagedStatus: 'modified',
|
||||
},
|
||||
{
|
||||
path: 'tests\\auth.test.ts',
|
||||
unstagedStatus: 'modified',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createRunSummary(): ProjectGitRunChangeSummary {
|
||||
return {
|
||||
generatedAt: TIMESTAMP,
|
||||
branchAtStart: 'main',
|
||||
branchAtEnd: 'main',
|
||||
fileCount: 1,
|
||||
additions: 4,
|
||||
deletions: 1,
|
||||
counts: {
|
||||
added: 1,
|
||||
modified: 0,
|
||||
deleted: 0,
|
||||
renamed: 0,
|
||||
copied: 0,
|
||||
typeChanged: 0,
|
||||
unmerged: 0,
|
||||
untracked: 0,
|
||||
cleaned: 0,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'src\\generated.ts',
|
||||
kind: 'added',
|
||||
origin: 'run-created',
|
||||
additions: 4,
|
||||
deletions: 1,
|
||||
canRevert: true,
|
||||
preview: {
|
||||
path: 'src\\generated.ts',
|
||||
diff: '@@ -0,0 +1,4 @@\n+export const generated = true;\n',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createService(
|
||||
workspace: WorkspaceState,
|
||||
pattern: PatternDefinition,
|
||||
options?: {
|
||||
snapshot?: ProjectGitWorkingTreeSnapshot;
|
||||
runSummary?: ProjectGitRunChangeSummary;
|
||||
onCaptureSnapshot?: (projectPath: string, scannedAt: string) => void;
|
||||
onComputeRunSummary?: (projectPath: string) => void;
|
||||
onScheduleRefresh?: (projectId?: string) => void;
|
||||
runTurn?: (command: RunTurnCommand) => Promise<[]>;
|
||||
},
|
||||
): InstanceType<typeof AryxAppService> {
|
||||
const service = new AryxAppService();
|
||||
const internals = service as unknown as Record<string, unknown>;
|
||||
internals.loadWorkspace = async () => {
|
||||
internals.workspace = workspace;
|
||||
return workspace;
|
||||
};
|
||||
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace;
|
||||
internals.buildEffectivePattern = async () => pattern;
|
||||
internals.awaitFinalResponseApproval = async () => undefined;
|
||||
internals.finalizeTurn = () => undefined;
|
||||
internals.emitSessionEvent = () => undefined;
|
||||
internals.pruneUnavailableApprovalTools = async () => false;
|
||||
internals.pruneUnavailableSessionToolingSelections = () => false;
|
||||
internals.scheduleProjectGitRefresh = (projectId?: string) => {
|
||||
options?.onScheduleRefresh?.(projectId);
|
||||
};
|
||||
|
||||
(
|
||||
service as unknown as {
|
||||
sidecar: {
|
||||
runTurn: (command: RunTurnCommand) => Promise<[]>;
|
||||
resolveApproval: () => Promise<void>;
|
||||
resolveUserInput: () => Promise<void>;
|
||||
};
|
||||
gitService: {
|
||||
captureWorkingTreeSnapshot: (
|
||||
projectPath: string,
|
||||
scannedAt: string,
|
||||
) => Promise<ProjectGitWorkingTreeSnapshot | undefined>;
|
||||
captureWorkingTreeBaseline: () => Promise<[]>;
|
||||
computeRunChangeSummary: (projectPath: string) => Promise<ProjectGitRunChangeSummary | undefined>;
|
||||
};
|
||||
}
|
||||
).sidecar = {
|
||||
runTurn: async (command) => options?.runTurn ? options.runTurn(command) : [],
|
||||
resolveApproval: async () => undefined,
|
||||
resolveUserInput: async () => undefined,
|
||||
};
|
||||
(
|
||||
service as unknown as {
|
||||
gitService: {
|
||||
captureWorkingTreeSnapshot: (
|
||||
projectPath: string,
|
||||
scannedAt: string,
|
||||
) => Promise<ProjectGitWorkingTreeSnapshot | undefined>;
|
||||
captureWorkingTreeBaseline: () => Promise<[]>;
|
||||
computeRunChangeSummary: (projectPath: string) => Promise<ProjectGitRunChangeSummary | undefined>;
|
||||
};
|
||||
}
|
||||
).gitService = {
|
||||
captureWorkingTreeSnapshot: async (projectPath, scannedAt) => {
|
||||
options?.onCaptureSnapshot?.(projectPath, scannedAt);
|
||||
return options?.snapshot;
|
||||
},
|
||||
captureWorkingTreeBaseline: async () => [],
|
||||
computeRunChangeSummary: async (projectPath) => {
|
||||
options?.onComputeRunSummary?.(projectPath);
|
||||
return options?.runSummary;
|
||||
},
|
||||
};
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
describe('AryxAppService git refresh integration', () => {
|
||||
test('sendSessionMessage stores a pre-run git snapshot on the created run', async () => {
|
||||
const { workspace, pattern, session } = createFixture();
|
||||
const snapshot = createSnapshot();
|
||||
const capturedProjectPaths: string[] = [];
|
||||
const service = createService(workspace, pattern, {
|
||||
snapshot,
|
||||
onCaptureSnapshot: (projectPath) => {
|
||||
capturedProjectPaths.push(projectPath);
|
||||
},
|
||||
});
|
||||
|
||||
await service.sendSessionMessage(session.id, 'Implement auth hardening.');
|
||||
|
||||
expect(capturedProjectPaths).toEqual(['C:\\workspace\\alpha']);
|
||||
expect(workspace.sessions[0]?.runs[0]?.preRunGitSnapshot).toEqual(snapshot);
|
||||
});
|
||||
|
||||
test('sendSessionMessage schedules a git refresh after a successful project turn', async () => {
|
||||
const { workspace, pattern, project, session } = createFixture();
|
||||
const scheduledProjectIds: Array<string | undefined> = [];
|
||||
const service = createService(workspace, pattern, {
|
||||
snapshot: createSnapshot(),
|
||||
onScheduleRefresh: (projectId) => {
|
||||
scheduledProjectIds.push(projectId);
|
||||
},
|
||||
});
|
||||
|
||||
await service.sendSessionMessage(session.id, 'Implement auth hardening.');
|
||||
|
||||
expect(scheduledProjectIds).toEqual([project.id]);
|
||||
});
|
||||
|
||||
test('sendSessionMessage stores a post-run git summary on the completed run', async () => {
|
||||
const { workspace, pattern, session, project } = createFixture();
|
||||
const computedProjectPaths: string[] = [];
|
||||
const service = createService(workspace, pattern, {
|
||||
snapshot: createSnapshot(),
|
||||
runSummary: createRunSummary(),
|
||||
onComputeRunSummary: (projectPath) => {
|
||||
computedProjectPaths.push(projectPath);
|
||||
},
|
||||
});
|
||||
|
||||
await service.sendSessionMessage(session.id, 'Implement auth hardening.');
|
||||
|
||||
expect(computedProjectPaths).toEqual([project.path]);
|
||||
expect(workspace.sessions[0]?.runs[0]?.postRunGitSummary).toEqual(createRunSummary());
|
||||
});
|
||||
|
||||
test('sendSessionMessage schedules a git refresh after a failed project turn', async () => {
|
||||
const { workspace, pattern, project, session } = createFixture();
|
||||
const scheduledProjectIds: Array<string | undefined> = [];
|
||||
const service = createService(workspace, pattern, {
|
||||
snapshot: createSnapshot(),
|
||||
onScheduleRefresh: (projectId) => {
|
||||
scheduledProjectIds.push(projectId);
|
||||
},
|
||||
runTurn: async () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
});
|
||||
|
||||
await service.sendSessionMessage(session.id, 'Implement auth hardening.');
|
||||
|
||||
expect(scheduledProjectIds).toEqual([project.id]);
|
||||
expect(session.status).toBe('error');
|
||||
expect(session.lastError).toBe('boom');
|
||||
expect(session.runs[0]?.status).toBe('error');
|
||||
});
|
||||
|
||||
test('scratchpad turns skip git snapshot capture and refresh scheduling', async () => {
|
||||
const { workspace, pattern, session } = createFixture({
|
||||
project: {
|
||||
id: SCRATCHPAD_PROJECT_ID,
|
||||
name: 'Scratchpad',
|
||||
path: 'C:\\workspace\\scratchpad',
|
||||
git: undefined,
|
||||
},
|
||||
session: {
|
||||
projectId: SCRATCHPAD_PROJECT_ID,
|
||||
},
|
||||
});
|
||||
let didCaptureSnapshot = false;
|
||||
const scheduledProjectIds: Array<string | undefined> = [];
|
||||
const service = createService(workspace, pattern, {
|
||||
snapshot: createSnapshot(),
|
||||
onCaptureSnapshot: () => {
|
||||
didCaptureSnapshot = true;
|
||||
},
|
||||
onScheduleRefresh: (projectId) => {
|
||||
scheduledProjectIds.push(projectId);
|
||||
},
|
||||
});
|
||||
|
||||
await service.sendSessionMessage(session.id, 'Draft a quick note.');
|
||||
|
||||
expect(didCaptureSnapshot).toBe(false);
|
||||
expect(scheduledProjectIds).toEqual([]);
|
||||
expect(workspace.sessions[0]?.runs[0]?.preRunGitSnapshot).toBeUndefined();
|
||||
});
|
||||
|
||||
test('setGitAutoRefreshEnabled persists the setting and returns updated workspace', async () => {
|
||||
const { workspace, pattern } = createFixture();
|
||||
const service = createService(workspace, pattern);
|
||||
|
||||
expect(service.isGitAutoRefreshEnabled()).toBe(true);
|
||||
|
||||
const result = await service.setGitAutoRefreshEnabled(false);
|
||||
expect(result.settings.gitAutoRefreshEnabled).toBe(false);
|
||||
expect(service.isGitAutoRefreshEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test('isGitAutoRefreshEnabled defaults to true when setting is undefined', async () => {
|
||||
const { workspace, pattern } = createFixture();
|
||||
const service = createService(workspace, pattern);
|
||||
|
||||
expect(workspace.settings.gitAutoRefreshEnabled).toBeUndefined();
|
||||
expect(service.isGitAutoRefreshEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import type { RunTurnCommand, WorkflowCheckpointSavedEvent, WorkflowCheckpointResume } from '@shared/contracts/sidecar';
|
||||
import { SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
|
||||
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
|
||||
import {
|
||||
createSessionRunRecord,
|
||||
type RunTimelineEventRecord,
|
||||
type SessionRunRecord,
|
||||
} from '@shared/domain/runTimeline';
|
||||
|
||||
mock.module('electron', () => {
|
||||
const electronMock = {
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx',
|
||||
getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures',
|
||||
},
|
||||
dialog: {
|
||||
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
|
||||
},
|
||||
shell: {
|
||||
openPath: async () => '',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...electronMock,
|
||||
default: electronMock,
|
||||
};
|
||||
});
|
||||
|
||||
mock.module('keytar', () => ({
|
||||
default: {
|
||||
getPassword: async () => null,
|
||||
setPassword: async () => undefined,
|
||||
deletePassword: async () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
const { AryxAppService } = await import('@main/AryxAppService');
|
||||
|
||||
describe('AryxAppService workflow checkpointing', () => {
|
||||
test('records workflow checkpoint recovery snapshots from turn-scoped events', async () => {
|
||||
const service = new AryxAppService();
|
||||
const { session, run } = createRunningSession();
|
||||
const checkpointEvent: WorkflowCheckpointSavedEvent = {
|
||||
type: 'workflow-checkpoint-saved',
|
||||
requestId: run.requestId,
|
||||
sessionId: session.id,
|
||||
workflowSessionId: run.requestId,
|
||||
checkpointId: 'checkpoint-1',
|
||||
storePath: 'C:\\Users\\tester\\AppData\\Local\\Aryx\\workflow-checkpoints\\session-1\\turn-1',
|
||||
stepNumber: 2,
|
||||
};
|
||||
|
||||
const internals = service as unknown as {
|
||||
workflowCheckpointRecoveries: Map<string, unknown>;
|
||||
handleTurnScopedEvent: (
|
||||
workspace: { sessions: SessionRecord[] },
|
||||
sessionId: string,
|
||||
event: WorkflowCheckpointSavedEvent,
|
||||
) => void | Promise<void>;
|
||||
};
|
||||
|
||||
await internals.handleTurnScopedEvent({ sessions: [session] }, session.id, checkpointEvent);
|
||||
|
||||
expect(internals.workflowCheckpointRecoveries.get(run.requestId)).toEqual({
|
||||
workflowSessionId: run.requestId,
|
||||
checkpointId: 'checkpoint-1',
|
||||
storePath: checkpointEvent.storePath,
|
||||
stepNumber: 2,
|
||||
sessionMessages: session.messages,
|
||||
runEvents: run.events,
|
||||
});
|
||||
});
|
||||
|
||||
test('retries a checkpointed turn with resume metadata after sidecar exit', async () => {
|
||||
const service = new AryxAppService();
|
||||
const { session, run } = createRunningSession();
|
||||
const workspace = { sessions: [session] };
|
||||
const checkpointRecovery = {
|
||||
workflowSessionId: run.requestId,
|
||||
checkpointId: 'checkpoint-7',
|
||||
storePath: 'C:\\Users\\tester\\AppData\\Local\\Aryx\\workflow-checkpoints\\session-1\\turn-1',
|
||||
stepNumber: 7,
|
||||
sessionMessages: structuredClone(session.messages),
|
||||
runEvents: structuredClone(run.events),
|
||||
};
|
||||
const invocations: RunTurnCommand[] = [];
|
||||
|
||||
session.messages.push({
|
||||
id: 'msg-partial',
|
||||
role: 'assistant',
|
||||
authorName: 'Primary',
|
||||
content: 'Partial output after the checkpoint.',
|
||||
createdAt: '2026-04-01T12:00:05.000Z',
|
||||
pending: true,
|
||||
});
|
||||
run.events = [
|
||||
...run.events,
|
||||
{
|
||||
id: 'run-event-extra',
|
||||
kind: 'message',
|
||||
occurredAt: '2026-04-01T12:00:05.000Z',
|
||||
status: 'running',
|
||||
messageId: 'msg-partial',
|
||||
content: 'Partial output after the checkpoint.',
|
||||
} satisfies RunTimelineEventRecord,
|
||||
];
|
||||
session.pendingUserInput = {
|
||||
id: 'user-input-1',
|
||||
status: 'pending',
|
||||
requestedAt: '2026-04-01T12:00:05.000Z',
|
||||
question: 'Need more detail?',
|
||||
choices: ['Yes', 'No'],
|
||||
allowFreeform: true,
|
||||
};
|
||||
|
||||
(
|
||||
service as unknown as {
|
||||
workflowCheckpointRecoveries: Map<string, unknown>;
|
||||
sidecar: {
|
||||
runTurn: (
|
||||
command: RunTurnCommand,
|
||||
) => Promise<ChatMessageRecord[]>;
|
||||
};
|
||||
persistAndBroadcast: (workspace: unknown) => Promise<void>;
|
||||
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
|
||||
runSidecarTurnWithCheckpointRecovery: (
|
||||
workspace: unknown,
|
||||
session: SessionRecord,
|
||||
requestId: string,
|
||||
createCommand: (resumeFromCheckpoint?: WorkflowCheckpointResume) => RunTurnCommand,
|
||||
onDelta: () => Promise<void>,
|
||||
onActivity: () => Promise<void>,
|
||||
onApproval: () => Promise<void>,
|
||||
onUserInput: () => Promise<void>,
|
||||
onMcpOAuthRequired: () => Promise<void>,
|
||||
onExitPlanMode: () => Promise<void>,
|
||||
onMessageReclassified: () => Promise<void>,
|
||||
onTurnScopedEvent: () => Promise<void>,
|
||||
) => Promise<ChatMessageRecord[]>;
|
||||
}
|
||||
).workflowCheckpointRecoveries.set(run.requestId, checkpointRecovery);
|
||||
(
|
||||
service as unknown as {
|
||||
sidecar: {
|
||||
runTurn: (command: RunTurnCommand) => Promise<ChatMessageRecord[]>;
|
||||
};
|
||||
}
|
||||
).sidecar = {
|
||||
runTurn: async (command: RunTurnCommand) => {
|
||||
invocations.push(structuredClone(command));
|
||||
if (invocations.length === 1) {
|
||||
throw new Error('The .NET sidecar exited unexpectedly with code 1.');
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
};
|
||||
(
|
||||
service as unknown as {
|
||||
persistAndBroadcast: (workspace: unknown) => Promise<void>;
|
||||
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
|
||||
}
|
||||
).persistAndBroadcast = async () => undefined;
|
||||
(
|
||||
service as unknown as {
|
||||
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
|
||||
}
|
||||
).emitRunUpdated = () => undefined;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
runSidecarTurnWithCheckpointRecovery: (
|
||||
workspace: unknown,
|
||||
session: SessionRecord,
|
||||
requestId: string,
|
||||
createCommand: (resumeFromCheckpoint?: WorkflowCheckpointResume) => RunTurnCommand,
|
||||
onDelta: () => Promise<void>,
|
||||
onActivity: () => Promise<void>,
|
||||
onApproval: () => Promise<void>,
|
||||
onUserInput: () => Promise<void>,
|
||||
onMcpOAuthRequired: () => Promise<void>,
|
||||
onExitPlanMode: () => Promise<void>,
|
||||
onMessageReclassified: () => Promise<void>,
|
||||
onTurnScopedEvent: () => Promise<void>,
|
||||
) => Promise<ChatMessageRecord[]>;
|
||||
}
|
||||
).runSidecarTurnWithCheckpointRecovery(
|
||||
workspace,
|
||||
session,
|
||||
run.requestId,
|
||||
(resumeFromCheckpoint?: WorkflowCheckpointResume): RunTurnCommand => ({
|
||||
type: 'run-turn',
|
||||
requestId: run.requestId,
|
||||
sessionId: session.id,
|
||||
projectPath: 'C:\\scratchpad',
|
||||
workspaceKind: 'scratchpad',
|
||||
mode: 'interactive',
|
||||
messageMode: 'enqueue',
|
||||
pattern: createPattern(),
|
||||
messages: session.messages,
|
||||
resumeFromCheckpoint,
|
||||
}),
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(invocations).toHaveLength(2);
|
||||
expect(invocations[0]?.resumeFromCheckpoint).toBeUndefined();
|
||||
expect(invocations[1]?.resumeFromCheckpoint).toEqual({
|
||||
workflowSessionId: run.requestId,
|
||||
checkpointId: 'checkpoint-7',
|
||||
storePath: checkpointRecovery.storePath,
|
||||
});
|
||||
expect(invocations[1]?.messages).toEqual(checkpointRecovery.sessionMessages);
|
||||
expect(session.messages).toEqual(checkpointRecovery.sessionMessages);
|
||||
expect(session.pendingUserInput).toBeUndefined();
|
||||
expect(session.runs[0]?.events).toEqual(checkpointRecovery.runEvents);
|
||||
});
|
||||
});
|
||||
|
||||
function createRunningSession(): { session: SessionRecord; run: SessionRunRecord } {
|
||||
const pattern = createPattern();
|
||||
const run = createSessionRunRecord({
|
||||
requestId: 'turn-1',
|
||||
project: {
|
||||
id: SCRATCHPAD_PROJECT_ID,
|
||||
path: 'C:\\scratchpad',
|
||||
},
|
||||
workingDirectory: 'C:\\scratchpad',
|
||||
workspaceKind: 'scratchpad',
|
||||
pattern,
|
||||
triggerMessageId: 'msg-user-1',
|
||||
startedAt: '2026-04-01T12:00:00.000Z',
|
||||
});
|
||||
const session: SessionRecord = {
|
||||
id: 'session-1',
|
||||
projectId: SCRATCHPAD_PROJECT_ID,
|
||||
patternId: pattern.id,
|
||||
title: 'Checkpoint session',
|
||||
createdAt: '2026-04-01T12:00:00.000Z',
|
||||
updatedAt: '2026-04-01T12:00:00.000Z',
|
||||
status: 'running',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-user-1',
|
||||
role: 'user',
|
||||
authorName: 'You',
|
||||
content: 'Continue the workflow.',
|
||||
createdAt: '2026-04-01T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-assistant-1',
|
||||
role: 'assistant',
|
||||
authorName: 'Primary',
|
||||
content: 'Working on it.',
|
||||
createdAt: '2026-04-01T12:00:01.000Z',
|
||||
pending: true,
|
||||
},
|
||||
],
|
||||
runs: [run],
|
||||
};
|
||||
|
||||
return { session, run };
|
||||
}
|
||||
|
||||
function createPattern() {
|
||||
return {
|
||||
id: 'pattern-handoff',
|
||||
name: 'Checkpointing flow',
|
||||
description: '',
|
||||
mode: 'handoff' as const,
|
||||
availability: 'available' as const,
|
||||
maxIterations: 4,
|
||||
agents: [
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'Primary',
|
||||
description: '',
|
||||
instructions: 'Help with the request.',
|
||||
model: 'gpt-5.4',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-04-01T00:00:00.000Z',
|
||||
updatedAt: '2026-04-01T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import type { SessionEventRecord } from '@shared/domain/event';
|
||||
import type { WorkflowDiagnosticEvent } from '@shared/contracts/sidecar';
|
||||
|
||||
mock.module('electron', () => {
|
||||
const electronMock = {
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx',
|
||||
getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures',
|
||||
},
|
||||
dialog: {
|
||||
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
|
||||
},
|
||||
shell: {
|
||||
openPath: async () => '',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...electronMock,
|
||||
default: electronMock,
|
||||
};
|
||||
});
|
||||
|
||||
mock.module('keytar', () => ({
|
||||
default: {
|
||||
getPassword: async () => null,
|
||||
setPassword: async () => undefined,
|
||||
deletePassword: async () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
const { AryxAppService } = await import('@main/AryxAppService');
|
||||
|
||||
describe('AryxAppService workflow diagnostics', () => {
|
||||
test('maps turn-scoped workflow diagnostics to session events', async () => {
|
||||
const service = new AryxAppService();
|
||||
const captured: SessionEventRecord[] = [];
|
||||
const workflowDiagnostic: WorkflowDiagnosticEvent = {
|
||||
type: 'workflow-diagnostic',
|
||||
requestId: 'turn-1',
|
||||
sessionId: 'session-1',
|
||||
severity: 'error',
|
||||
diagnosticKind: 'executor-failed',
|
||||
message: 'Tool crashed.',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'Primary',
|
||||
executorId: 'agent-1',
|
||||
exceptionType: 'InvalidOperationException',
|
||||
};
|
||||
|
||||
const internals = service as unknown as {
|
||||
emitSessionEvent: (event: SessionEventRecord) => void;
|
||||
handleTurnScopedEvent: (
|
||||
workspace: unknown,
|
||||
sessionId: string,
|
||||
event: WorkflowDiagnosticEvent,
|
||||
) => void | Promise<void>;
|
||||
};
|
||||
internals.emitSessionEvent = (event) => {
|
||||
captured.push(event);
|
||||
};
|
||||
|
||||
await internals.handleTurnScopedEvent({}, 'session-1', workflowDiagnostic);
|
||||
|
||||
expect(captured).toHaveLength(1);
|
||||
expect(captured[0]).toMatchObject({
|
||||
sessionId: 'session-1',
|
||||
kind: 'workflow-diagnostic',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'Primary',
|
||||
diagnosticSeverity: 'error',
|
||||
diagnosticKind: 'executor-failed',
|
||||
diagnosticMessage: 'Tool crashed.',
|
||||
executorId: 'agent-1',
|
||||
exceptionType: 'InvalidOperationException',
|
||||
});
|
||||
expect(captured[0]?.occurredAt).toEqual(expect.any(String));
|
||||
});
|
||||
});
|
||||
@@ -64,18 +64,20 @@ class FakeScheduler implements AutoUpdateScheduler {
|
||||
}
|
||||
|
||||
describe('AutoUpdateService', () => {
|
||||
test('does not schedule checks for unpackaged apps but manual checks still work', async () => {
|
||||
test('schedules checks for unpackaged apps using dev update config', async () => {
|
||||
const updater = new FakeUpdater();
|
||||
const scheduler = new FakeScheduler();
|
||||
const service = new AutoUpdateService({ isPackaged: false, scheduler, updater });
|
||||
|
||||
service.start();
|
||||
|
||||
expect(scheduler.timeouts).toHaveLength(0);
|
||||
expect(scheduler.intervals).toHaveLength(0);
|
||||
expect(updater.forceDevUpdateConfig).toBe(true);
|
||||
expect(scheduler.timeouts).toEqual([{ callback: expect.any(Function), delayMs: 10_000 }]);
|
||||
expect(scheduler.intervals).toEqual([{ callback: expect.any(Function), delayMs: 4 * 60 * 60 * 1000 }]);
|
||||
|
||||
await scheduler.runTimeout();
|
||||
await Promise.resolve();
|
||||
|
||||
await service.checkForUpdates();
|
||||
expect(updater.checkForUpdatesCalls).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { ProjectGitRunChangeSummary } from '@shared/domain/project';
|
||||
|
||||
import { buildProjectGitCommitMessageSuggestion } from '@main/git/gitCommitMessageSuggestion';
|
||||
|
||||
const baseSummary: ProjectGitRunChangeSummary = {
|
||||
generatedAt: '2026-03-31T00:00:00.000Z',
|
||||
branchAtStart: 'main',
|
||||
branchAtEnd: 'main',
|
||||
fileCount: 1,
|
||||
additions: 5,
|
||||
deletions: 2,
|
||||
counts: {
|
||||
added: 0,
|
||||
modified: 1,
|
||||
deleted: 0,
|
||||
renamed: 0,
|
||||
copied: 0,
|
||||
typeChanged: 0,
|
||||
unmerged: 0,
|
||||
untracked: 0,
|
||||
cleaned: 0,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'src\\auth.ts',
|
||||
kind: 'modified',
|
||||
origin: 'run-created',
|
||||
additions: 5,
|
||||
deletions: 2,
|
||||
canRevert: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('buildProjectGitCommitMessageSuggestion', () => {
|
||||
test('infers a fix commit from the triggering user prompt', () => {
|
||||
const suggestion = buildProjectGitCommitMessageSuggestion({
|
||||
session: {
|
||||
title: 'Auth hardening',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-user-1',
|
||||
role: 'user',
|
||||
authorName: 'You',
|
||||
content: 'Fix auth hardening for git integration.',
|
||||
createdAt: '2026-03-31T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
run: {
|
||||
triggerMessageId: 'msg-user-1',
|
||||
},
|
||||
summary: baseSummary,
|
||||
});
|
||||
|
||||
expect(suggestion).toEqual({
|
||||
type: 'fix',
|
||||
subject: 'auth hardening for git integration',
|
||||
message: 'fix: auth hardening for git integration',
|
||||
});
|
||||
});
|
||||
|
||||
test('infers docs commits from documentation-only changes', () => {
|
||||
const suggestion = buildProjectGitCommitMessageSuggestion({
|
||||
session: {
|
||||
title: 'Docs touch-up',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-user-1',
|
||||
role: 'user',
|
||||
authorName: 'You',
|
||||
content: '',
|
||||
createdAt: '2026-03-31T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
run: {
|
||||
triggerMessageId: 'msg-user-1',
|
||||
},
|
||||
summary: {
|
||||
...baseSummary,
|
||||
files: [
|
||||
{
|
||||
path: 'README.md',
|
||||
kind: 'modified',
|
||||
origin: 'run-created',
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
canRevert: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(suggestion.type).toBe('docs');
|
||||
expect(suggestion.message).toBe('docs: update readme');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { ProjectGitBaselineFile, ProjectGitWorkingTreeSnapshot } from '@shared/domain/project';
|
||||
|
||||
import { buildProjectGitRunChangeSummary } from '@main/git/gitRunChangeSummary';
|
||||
|
||||
const TIMESTAMP = '2026-03-31T00:00:00.000Z';
|
||||
|
||||
function createPreRunSnapshot(): ProjectGitWorkingTreeSnapshot {
|
||||
return {
|
||||
scannedAt: TIMESTAMP,
|
||||
repoRoot: 'C:\\workspace\\alpha',
|
||||
branch: 'main',
|
||||
changedFileCount: 2,
|
||||
changes: {
|
||||
staged: 0,
|
||||
unstaged: 1,
|
||||
untracked: 1,
|
||||
conflicted: 0,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'src\\auth.ts',
|
||||
unstagedStatus: 'modified',
|
||||
},
|
||||
{
|
||||
path: 'legacy.tmp',
|
||||
unstagedStatus: 'untracked',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createPostRunSnapshot(): ProjectGitWorkingTreeSnapshot {
|
||||
return {
|
||||
scannedAt: TIMESTAMP,
|
||||
repoRoot: 'C:\\workspace\\alpha',
|
||||
branch: 'main',
|
||||
changedFileCount: 2,
|
||||
changes: {
|
||||
staged: 0,
|
||||
unstaged: 1,
|
||||
untracked: 1,
|
||||
conflicted: 0,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'src\\auth.ts',
|
||||
unstagedStatus: 'modified',
|
||||
},
|
||||
{
|
||||
path: 'notes.txt',
|
||||
unstagedStatus: 'untracked',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createPreRunBaselines(): ProjectGitBaselineFile[] {
|
||||
return [
|
||||
{
|
||||
path: 'src\\auth.ts',
|
||||
combinedDiff: 'diff --git a/src/auth.ts b/src/auth.ts\n@@ -1 +1 @@\n-old\n+before\n',
|
||||
},
|
||||
{
|
||||
path: 'legacy.tmp',
|
||||
untrackedContentBase64: Buffer.from('legacy\n', 'utf8').toString('base64'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createPostRunBaselines(): ProjectGitBaselineFile[] {
|
||||
return [
|
||||
{
|
||||
path: 'src\\auth.ts',
|
||||
combinedDiff: 'diff --git a/src/auth.ts b/src/auth.ts\n@@ -1 +1 @@\n-old\n+after\n',
|
||||
},
|
||||
{
|
||||
path: 'notes.txt',
|
||||
untrackedContentBase64: Buffer.from('fresh notes\n', 'utf8').toString('base64'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe('buildProjectGitRunChangeSummary', () => {
|
||||
test('classifies run-created, pre-existing, and cleaned files', () => {
|
||||
const summary = buildProjectGitRunChangeSummary({
|
||||
generatedAt: TIMESTAMP,
|
||||
preRunSnapshot: createPreRunSnapshot(),
|
||||
preRunBaselineFiles: createPreRunBaselines(),
|
||||
postRunSnapshot: createPostRunSnapshot(),
|
||||
postRunBaselineFiles: createPostRunBaselines(),
|
||||
});
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
generatedAt: TIMESTAMP,
|
||||
branchAtStart: 'main',
|
||||
branchAtEnd: 'main',
|
||||
fileCount: 3,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
counts: {
|
||||
added: 0,
|
||||
modified: 1,
|
||||
deleted: 0,
|
||||
renamed: 0,
|
||||
copied: 0,
|
||||
typeChanged: 0,
|
||||
unmerged: 0,
|
||||
untracked: 1,
|
||||
cleaned: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary?.files).toEqual([
|
||||
{
|
||||
path: 'notes.txt',
|
||||
previousPath: undefined,
|
||||
kind: 'untracked',
|
||||
origin: 'run-created',
|
||||
stagedStatus: undefined,
|
||||
unstagedStatus: 'untracked',
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
canRevert: true,
|
||||
preview: {
|
||||
path: 'notes.txt',
|
||||
previousPath: undefined,
|
||||
newFileContents: 'fresh notes\n',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'legacy.tmp',
|
||||
previousPath: undefined,
|
||||
kind: 'cleaned',
|
||||
origin: 'pre-existing',
|
||||
stagedStatus: undefined,
|
||||
unstagedStatus: 'untracked',
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
canRevert: true,
|
||||
preview: {
|
||||
path: 'legacy.tmp',
|
||||
previousPath: undefined,
|
||||
newFileContents: 'legacy\n',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'src\\auth.ts',
|
||||
previousPath: undefined,
|
||||
kind: 'modified',
|
||||
origin: 'pre-existing',
|
||||
stagedStatus: undefined,
|
||||
unstagedStatus: 'modified',
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
canRevert: true,
|
||||
preview: {
|
||||
path: 'src\\auth.ts',
|
||||
previousPath: undefined,
|
||||
diff: 'diff --git a/src/auth.ts b/src/auth.ts\n@@ -1 +1 @@\n-old\n+after\n',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns undefined when nothing changed across the run', () => {
|
||||
const snapshot = createPreRunSnapshot();
|
||||
const baselines = createPreRunBaselines();
|
||||
|
||||
expect(buildProjectGitRunChangeSummary({
|
||||
generatedAt: TIMESTAMP,
|
||||
preRunSnapshot: snapshot,
|
||||
preRunBaselineFiles: baselines,
|
||||
postRunSnapshot: snapshot,
|
||||
postRunBaselineFiles: baselines,
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
test('marks pre-existing files as non-revertable when the baseline capture has no restore data', () => {
|
||||
const summary = buildProjectGitRunChangeSummary({
|
||||
generatedAt: TIMESTAMP,
|
||||
preRunSnapshot: createPreRunSnapshot(),
|
||||
preRunBaselineFiles: [
|
||||
{
|
||||
path: 'src\\auth.ts',
|
||||
},
|
||||
],
|
||||
postRunSnapshot: createPostRunSnapshot(),
|
||||
postRunBaselineFiles: createPostRunBaselines(),
|
||||
});
|
||||
|
||||
expect(summary?.files.find((file) => file.path === 'src\\auth.ts')).toMatchObject({
|
||||
path: 'src\\auth.ts',
|
||||
origin: 'pre-existing',
|
||||
canRevert: false,
|
||||
});
|
||||
|
||||
expect(summary?.files.find((file) => file.path === 'legacy.tmp')).toMatchObject({
|
||||
path: 'legacy.tmp',
|
||||
kind: 'cleaned',
|
||||
canRevert: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves empty untracked file previews', () => {
|
||||
const summary = buildProjectGitRunChangeSummary({
|
||||
generatedAt: TIMESTAMP,
|
||||
preRunSnapshot: {
|
||||
scannedAt: TIMESTAMP,
|
||||
repoRoot: 'C:\\workspace\\alpha',
|
||||
branch: 'main',
|
||||
changedFileCount: 1,
|
||||
changes: {
|
||||
staged: 0,
|
||||
unstaged: 0,
|
||||
untracked: 1,
|
||||
conflicted: 0,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'empty.txt',
|
||||
unstagedStatus: 'untracked',
|
||||
},
|
||||
],
|
||||
},
|
||||
preRunBaselineFiles: [
|
||||
{
|
||||
path: 'empty.txt',
|
||||
untrackedContentBase64: '',
|
||||
},
|
||||
],
|
||||
postRunSnapshot: {
|
||||
scannedAt: TIMESTAMP,
|
||||
repoRoot: 'C:\\workspace\\alpha',
|
||||
branch: 'main',
|
||||
changedFileCount: 0,
|
||||
changes: {
|
||||
staged: 0,
|
||||
unstaged: 0,
|
||||
untracked: 0,
|
||||
conflicted: 0,
|
||||
},
|
||||
files: [],
|
||||
},
|
||||
postRunBaselineFiles: [],
|
||||
});
|
||||
|
||||
expect(summary?.files).toEqual([
|
||||
{
|
||||
path: 'empty.txt',
|
||||
previousPath: undefined,
|
||||
kind: 'cleaned',
|
||||
origin: 'pre-existing',
|
||||
stagedStatus: undefined,
|
||||
unstagedStatus: 'untracked',
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
canRevert: true,
|
||||
preview: {
|
||||
path: 'empty.txt',
|
||||
previousPath: undefined,
|
||||
newFileContents: '',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,7 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { GitService } from '@main/git/gitService';
|
||||
@@ -121,4 +125,386 @@ describe('GitService', () => {
|
||||
head: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('captures a structured pre-run working tree snapshot', async () => {
|
||||
const service = createService({
|
||||
'rev-parse --show-toplevel': 'C:\\workspace\\repo\n',
|
||||
'status --porcelain=1 --untracked-files=all': 'R src\\old.ts -> src\\new.ts\n M src\\app.ts\n?? notes.txt\nUU conflict.txt\n',
|
||||
'branch --show-current': 'feature/git-context\n',
|
||||
});
|
||||
|
||||
await expect(service.captureWorkingTreeSnapshot('C:\\workspace\\repo', '2026-03-23T19:00:00.000Z')).resolves.toEqual({
|
||||
scannedAt: '2026-03-23T19:00:00.000Z',
|
||||
repoRoot: 'C:\\workspace\\repo',
|
||||
branch: 'feature/git-context',
|
||||
changedFileCount: 4,
|
||||
changes: {
|
||||
staged: 1,
|
||||
unstaged: 1,
|
||||
untracked: 1,
|
||||
conflicted: 1,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'src\\new.ts',
|
||||
previousPath: 'src\\old.ts',
|
||||
stagedStatus: 'renamed',
|
||||
unstagedStatus: undefined,
|
||||
},
|
||||
{
|
||||
path: 'src\\app.ts',
|
||||
stagedStatus: undefined,
|
||||
unstagedStatus: 'modified',
|
||||
},
|
||||
{
|
||||
path: 'notes.txt',
|
||||
unstagedStatus: 'untracked',
|
||||
},
|
||||
{
|
||||
path: 'conflict.txt',
|
||||
stagedStatus: 'unmerged',
|
||||
unstagedStatus: 'unmerged',
|
||||
isConflicted: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('returns no working tree snapshot outside a git repository', async () => {
|
||||
const service = createService({
|
||||
'rev-parse --show-toplevel': createGitError('fatal: not a git repository', {
|
||||
stderr: 'fatal: not a git repository (or any of the parent directories): .git',
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(service.captureWorkingTreeSnapshot('C:\\workspace\\not-a-repo', '2026-03-23T19:00:00.000Z')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('captures baseline data for tracked diffs and untracked files', async () => {
|
||||
const tempDirectory = await mkdtemp(join(tmpdir(), 'aryx-git-service-'));
|
||||
try {
|
||||
await writeFile(join(tempDirectory, 'notes.txt'), 'fresh notes\n', 'utf8');
|
||||
const service = createService({
|
||||
'diff --binary --no-ext-diff --no-renames HEAD -- src\\app.ts': 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n',
|
||||
});
|
||||
|
||||
await expect(service.captureWorkingTreeBaseline(tempDirectory, {
|
||||
scannedAt: '2026-03-23T19:00:00.000Z',
|
||||
repoRoot: tempDirectory,
|
||||
branch: 'main',
|
||||
changedFileCount: 2,
|
||||
changes: {
|
||||
staged: 0,
|
||||
unstaged: 1,
|
||||
untracked: 1,
|
||||
conflicted: 0,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'src\\app.ts',
|
||||
unstagedStatus: 'modified',
|
||||
},
|
||||
{
|
||||
path: 'notes.txt',
|
||||
unstagedStatus: 'untracked',
|
||||
},
|
||||
],
|
||||
})).resolves.toEqual([
|
||||
{
|
||||
path: 'src\\app.ts',
|
||||
previousPath: undefined,
|
||||
combinedDiff: 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n',
|
||||
},
|
||||
{
|
||||
path: 'notes.txt',
|
||||
previousPath: undefined,
|
||||
untrackedContentBase64: Buffer.from('fresh notes\n', 'utf8').toString('base64'),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await rm(tempDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('describes git details with branches and recent commits', async () => {
|
||||
const service = createService({
|
||||
'rev-parse --show-toplevel': 'C:\\workspace\\repo\n',
|
||||
'status --porcelain=1 --untracked-files=all': ' M src\\app.ts\n',
|
||||
'branch --show-current': 'main\n',
|
||||
'rev-parse --abbrev-ref --symbolic-full-name @{upstream}': 'origin/main\n',
|
||||
'rev-list --left-right --count @{upstream}...HEAD': '0\t2\n',
|
||||
'log -1 --format=%H%n%h%n%s%n%cI': '0123456789abcdef\n0123456\nAdd git detail plumbing\n2026-03-23T18:00:00+01:00\n',
|
||||
'for-each-ref --format=%(refname:short)%00%(HEAD)%00%(upstream:short) refs/heads': 'main\0*\0origin/main\nfeature/refactor\0 \0origin/feature/refactor\n',
|
||||
'log -n15 --format=%H%x00%h%x00%an%x00%s%x00%cI%x00%D%x1e': '0123456789abcdef\x00123456\x00Alice\x00Add git detail plumbing\x002026-03-23T18:00:00+01:00\x00HEAD -> main, origin/main\x1e',
|
||||
});
|
||||
|
||||
await expect(service.describeProjectGitDetails('C:\\workspace\\repo', '2026-03-23T19:00:00.000Z', 15)).resolves.toMatchObject({
|
||||
scannedAt: '2026-03-23T19:00:00.000Z',
|
||||
context: {
|
||||
status: 'ready',
|
||||
branch: 'main',
|
||||
upstream: 'origin/main',
|
||||
ahead: 2,
|
||||
behind: 0,
|
||||
},
|
||||
workingTree: {
|
||||
changedFileCount: 1,
|
||||
},
|
||||
branches: [
|
||||
{ name: 'main', isCurrent: true, upstream: 'origin/main' },
|
||||
{ name: 'feature/refactor', isCurrent: false, upstream: 'origin/feature/refactor' },
|
||||
],
|
||||
recentCommits: [
|
||||
{
|
||||
hash: '0123456789abcdef',
|
||||
shortHash: '123456',
|
||||
authorName: 'Alice',
|
||||
subject: 'Add git detail plumbing',
|
||||
committedAt: '2026-03-23T18:00:00+01:00',
|
||||
refNames: 'HEAD -> main, origin/main',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('dispatches commit workflow and branch commands to git', async () => {
|
||||
const executedCommands: string[] = [];
|
||||
const service = new GitService(async (_projectPath, args) => {
|
||||
executedCommands.push(args.join(' '));
|
||||
if (args.join(' ') === 'commit -m feat: update auth') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (args.join(' ') === 'log -1 --format=%H%n%h%n%s%n%cI') {
|
||||
return 'fedcba9876543210\nfedcba9\nfeat: update auth\n2026-03-23T18:00:00+01:00\n';
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
await service.stageFiles('C:\\workspace\\repo', [{ path: 'src\\auth.ts' }]);
|
||||
await service.unstageFiles('C:\\workspace\\repo', [{ path: 'src\\auth.ts' }]);
|
||||
await expect(service.commit('C:\\workspace\\repo', 'feat: update auth')).resolves.toEqual({
|
||||
hash: 'fedcba9876543210',
|
||||
shortHash: 'fedcba9',
|
||||
subject: 'feat: update auth',
|
||||
committedAt: '2026-03-23T18:00:00+01:00',
|
||||
});
|
||||
await service.push('C:\\workspace\\repo');
|
||||
await service.fetch('C:\\workspace\\repo');
|
||||
await service.pull('C:\\workspace\\repo', true);
|
||||
await service.createBranch('C:\\workspace\\repo', 'feature/git-panel', undefined, true);
|
||||
await service.switchBranch('C:\\workspace\\repo', 'main');
|
||||
await service.deleteBranch('C:\\workspace\\repo', 'feature/git-panel', true);
|
||||
|
||||
expect(executedCommands).toEqual([
|
||||
'add -- src\\auth.ts',
|
||||
'restore --staged -- src\\auth.ts',
|
||||
'commit -m feat: update auth',
|
||||
'log -1 --format=%H%n%h%n%s%n%cI',
|
||||
'push',
|
||||
'fetch --all --prune',
|
||||
'pull --rebase',
|
||||
'switch -c feature/git-panel',
|
||||
'switch main',
|
||||
'branch -D feature/git-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
test('discards run-created untracked files from the working tree', async () => {
|
||||
const tempDirectory = await mkdtemp(join(tmpdir(), 'aryx-git-discard-'));
|
||||
try {
|
||||
await writeFile(join(tempDirectory, 'notes.txt'), 'fresh notes\n', 'utf8');
|
||||
const executedCommands: string[] = [];
|
||||
const service = new GitService(async (_projectPath, args) => {
|
||||
executedCommands.push(args.join(' '));
|
||||
return '';
|
||||
});
|
||||
|
||||
await service.discardRunChanges(tempDirectory, {
|
||||
summary: {
|
||||
generatedAt: '2026-03-31T00:00:00.000Z',
|
||||
branchAtStart: 'main',
|
||||
branchAtEnd: 'main',
|
||||
fileCount: 1,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
counts: {
|
||||
added: 0,
|
||||
modified: 0,
|
||||
deleted: 0,
|
||||
renamed: 0,
|
||||
copied: 0,
|
||||
typeChanged: 0,
|
||||
unmerged: 0,
|
||||
untracked: 1,
|
||||
cleaned: 0,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'notes.txt',
|
||||
kind: 'untracked',
|
||||
origin: 'run-created',
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
canRevert: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(executedCommands).toEqual([
|
||||
'rm --cached --force --ignore-unmatch -- notes.txt',
|
||||
]);
|
||||
await expect(Bun.file(join(tempDirectory, 'notes.txt')).exists()).resolves.toBe(false);
|
||||
} finally {
|
||||
await rm(tempDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('restores cleaned pre-existing untracked files from the captured baseline', async () => {
|
||||
const tempDirectory = await mkdtemp(join(tmpdir(), 'aryx-git-restore-'));
|
||||
try {
|
||||
const executedCommands: string[] = [];
|
||||
const service = new GitService(async (_projectPath, args) => {
|
||||
executedCommands.push(args.join(' '));
|
||||
return '';
|
||||
});
|
||||
|
||||
await service.discardRunChanges(tempDirectory, {
|
||||
summary: {
|
||||
generatedAt: '2026-03-31T00:00:00.000Z',
|
||||
branchAtStart: 'main',
|
||||
branchAtEnd: 'main',
|
||||
fileCount: 1,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
counts: {
|
||||
added: 0,
|
||||
modified: 0,
|
||||
deleted: 0,
|
||||
renamed: 0,
|
||||
copied: 0,
|
||||
typeChanged: 0,
|
||||
unmerged: 0,
|
||||
untracked: 0,
|
||||
cleaned: 1,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'legacy.txt',
|
||||
kind: 'cleaned',
|
||||
origin: 'pre-existing',
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
canRevert: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
preRunBaselineFiles: [
|
||||
{
|
||||
path: 'legacy.txt',
|
||||
untrackedContentBase64: Buffer.from('legacy\n', 'utf8').toString('base64'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(executedCommands).toEqual([
|
||||
'restore --source=HEAD --staged --worktree -- legacy.txt',
|
||||
]);
|
||||
await expect(Bun.file(join(tempDirectory, 'legacy.txt')).text()).resolves.toBe('legacy\n');
|
||||
} finally {
|
||||
await rm(tempDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('restores cleaned pre-existing empty untracked files from the captured baseline', async () => {
|
||||
const tempDirectory = await mkdtemp(join(tmpdir(), 'aryx-git-restore-empty-'));
|
||||
try {
|
||||
const service = new GitService(async () => '');
|
||||
|
||||
await service.discardRunChanges(tempDirectory, {
|
||||
summary: {
|
||||
generatedAt: '2026-03-31T00:00:00.000Z',
|
||||
branchAtStart: 'main',
|
||||
branchAtEnd: 'main',
|
||||
fileCount: 1,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
counts: {
|
||||
added: 0,
|
||||
modified: 0,
|
||||
deleted: 0,
|
||||
renamed: 0,
|
||||
copied: 0,
|
||||
typeChanged: 0,
|
||||
unmerged: 0,
|
||||
untracked: 0,
|
||||
cleaned: 1,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'empty.txt',
|
||||
kind: 'cleaned',
|
||||
origin: 'pre-existing',
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
canRevert: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
preRunBaselineFiles: [
|
||||
{
|
||||
path: 'empty.txt',
|
||||
untrackedContentBase64: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(Bun.file(join(tempDirectory, 'empty.txt')).text()).resolves.toBe('');
|
||||
} finally {
|
||||
await rm(tempDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects restoring pre-existing files when no restorable baseline was captured', async () => {
|
||||
const service = new GitService(async () => '');
|
||||
|
||||
await expect(service.discardRunChanges('C:\\workspace\\repo', {
|
||||
summary: {
|
||||
generatedAt: '2026-03-31T00:00:00.000Z',
|
||||
branchAtStart: 'main',
|
||||
branchAtEnd: 'main',
|
||||
fileCount: 1,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
counts: {
|
||||
added: 0,
|
||||
modified: 1,
|
||||
deleted: 0,
|
||||
renamed: 0,
|
||||
copied: 0,
|
||||
typeChanged: 0,
|
||||
unmerged: 0,
|
||||
untracked: 0,
|
||||
cleaned: 0,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'src\\auth.ts',
|
||||
kind: 'modified',
|
||||
origin: 'pre-existing',
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
canRevert: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
preRunBaselineFiles: [
|
||||
{
|
||||
path: 'src\\auth.ts',
|
||||
},
|
||||
],
|
||||
})).rejects.toThrow('no restorable baseline was captured');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,12 @@ import { EventEmitter } from 'node:events';
|
||||
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import type { SidecarCapabilities } from '@shared/contracts/sidecar';
|
||||
import type {
|
||||
RunTurnCommand,
|
||||
SidecarCapabilities,
|
||||
WorkflowCheckpointSavedEvent,
|
||||
WorkflowDiagnosticEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
|
||||
class FakeReadableStream extends EventEmitter {
|
||||
setEncoding(_encoding: BufferEncoding): void {}
|
||||
@@ -142,4 +147,192 @@ describe('SidecarClient', () => {
|
||||
spawnedProcesses[1]!.completeExit();
|
||||
await finalDispose;
|
||||
});
|
||||
|
||||
test('routes workflow diagnostic events through the turn-scoped callback', async () => {
|
||||
spawnedProcesses.length = 0;
|
||||
const client = new SidecarClient();
|
||||
const diagnostics: WorkflowDiagnosticEvent[] = [];
|
||||
const command: RunTurnCommand = {
|
||||
type: 'run-turn',
|
||||
requestId: 'turn-1',
|
||||
sessionId: 'session-1',
|
||||
projectPath: 'C:\\workspace\\project',
|
||||
pattern: {
|
||||
id: 'pattern-1',
|
||||
name: 'Single Agent',
|
||||
description: '',
|
||||
mode: 'single',
|
||||
availability: 'available',
|
||||
maxIterations: 1,
|
||||
agents: [
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'Primary',
|
||||
description: '',
|
||||
instructions: 'Help with the request.',
|
||||
model: 'gpt-5.4',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-04-01T00:00:00.000Z',
|
||||
updatedAt: '2026-04-01T00:00:00.000Z',
|
||||
},
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const turn = client.runTurn(
|
||||
command,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async (event) => {
|
||||
if (event.type === 'workflow-diagnostic') {
|
||||
diagnostics.push(event);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(spawnedProcesses).toHaveLength(1);
|
||||
|
||||
spawnedProcesses[0]!.emitStdout(
|
||||
`${JSON.stringify({
|
||||
type: 'workflow-diagnostic',
|
||||
requestId: command.requestId,
|
||||
sessionId: command.sessionId,
|
||||
severity: 'error',
|
||||
diagnosticKind: 'executor-failed',
|
||||
message: 'Tool crashed.',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'Primary',
|
||||
executorId: 'agent-1',
|
||||
exceptionType: 'InvalidOperationException',
|
||||
} satisfies WorkflowDiagnosticEvent)}\n`,
|
||||
);
|
||||
spawnedProcesses[0]!.emitStdout(
|
||||
`${JSON.stringify({
|
||||
type: 'turn-complete',
|
||||
requestId: command.requestId,
|
||||
sessionId: command.sessionId,
|
||||
messages: [],
|
||||
cancelled: false,
|
||||
})}\n`,
|
||||
);
|
||||
spawnedProcesses[0]!.emitStdout(
|
||||
`${JSON.stringify({
|
||||
type: 'command-complete',
|
||||
requestId: command.requestId,
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
await expect(turn).resolves.toEqual([]);
|
||||
expect(diagnostics).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'workflow-diagnostic',
|
||||
severity: 'error',
|
||||
diagnosticKind: 'executor-failed',
|
||||
message: 'Tool crashed.',
|
||||
executorId: 'agent-1',
|
||||
}),
|
||||
]);
|
||||
|
||||
const dispose = client.dispose();
|
||||
spawnedProcesses[0]!.completeExit();
|
||||
await dispose;
|
||||
});
|
||||
|
||||
test('routes workflow checkpoint events through the turn-scoped callback', async () => {
|
||||
spawnedProcesses.length = 0;
|
||||
const client = new SidecarClient();
|
||||
const checkpoints: WorkflowCheckpointSavedEvent[] = [];
|
||||
const command: RunTurnCommand = {
|
||||
type: 'run-turn',
|
||||
requestId: 'turn-1',
|
||||
sessionId: 'session-1',
|
||||
projectPath: 'C:\\workspace\\project',
|
||||
pattern: {
|
||||
id: 'pattern-1',
|
||||
name: 'Handoff',
|
||||
description: '',
|
||||
mode: 'handoff',
|
||||
availability: 'available',
|
||||
maxIterations: 1,
|
||||
agents: [
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'Primary',
|
||||
description: '',
|
||||
instructions: 'Help with the request.',
|
||||
model: 'gpt-5.4',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-04-01T00:00:00.000Z',
|
||||
updatedAt: '2026-04-01T00:00:00.000Z',
|
||||
},
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const turn = client.runTurn(
|
||||
command,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async (event) => {
|
||||
if (event.type === 'workflow-checkpoint-saved') {
|
||||
checkpoints.push(event);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(spawnedProcesses).toHaveLength(1);
|
||||
|
||||
spawnedProcesses[0]!.emitStdout(
|
||||
`${JSON.stringify({
|
||||
type: 'workflow-checkpoint-saved',
|
||||
requestId: command.requestId,
|
||||
sessionId: command.sessionId,
|
||||
workflowSessionId: 'turn-1',
|
||||
checkpointId: 'checkpoint-1',
|
||||
storePath: 'C:\\Users\\tester\\AppData\\Local\\Aryx\\workflow-checkpoints\\session-1\\turn-1',
|
||||
stepNumber: 2,
|
||||
} satisfies WorkflowCheckpointSavedEvent)}\n`,
|
||||
);
|
||||
spawnedProcesses[0]!.emitStdout(
|
||||
`${JSON.stringify({
|
||||
type: 'turn-complete',
|
||||
requestId: command.requestId,
|
||||
sessionId: command.sessionId,
|
||||
messages: [],
|
||||
cancelled: false,
|
||||
})}\n`,
|
||||
);
|
||||
spawnedProcesses[0]!.emitStdout(
|
||||
`${JSON.stringify({
|
||||
type: 'command-complete',
|
||||
requestId: command.requestId,
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
await expect(turn).resolves.toEqual([]);
|
||||
expect(checkpoints).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'workflow-checkpoint-saved',
|
||||
workflowSessionId: 'turn-1',
|
||||
checkpointId: 'checkpoint-1',
|
||||
stepNumber: 2,
|
||||
}),
|
||||
]);
|
||||
|
||||
const dispose = client.dispose();
|
||||
spawnedProcesses[0]!.completeExit();
|
||||
await dispose;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('assistant message phase', () => {
|
||||
},
|
||||
], 'running');
|
||||
|
||||
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('thinking');
|
||||
expect(getAssistantMessagePhase(session, session.messages[0])).toBe('thinking');
|
||||
});
|
||||
|
||||
test('marks the last completed assistant message as final when the session is idle', () => {
|
||||
@@ -54,8 +54,8 @@ describe('assistant message phase', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default');
|
||||
expect(getAssistantMessagePhase(session, session.messages[1], 1)).toBe('final');
|
||||
expect(getAssistantMessagePhase(session, session.messages[0])).toBe('default');
|
||||
expect(getAssistantMessagePhase(session, session.messages[1])).toBe('final');
|
||||
});
|
||||
|
||||
test('does not mark completed assistant messages as final while the session is still running', () => {
|
||||
@@ -69,7 +69,7 @@ describe('assistant message phase', () => {
|
||||
},
|
||||
], 'running');
|
||||
|
||||
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default');
|
||||
expect(getAssistantMessagePhase(session, session.messages[0])).toBe('default');
|
||||
});
|
||||
|
||||
test('ignores non-assistant messages', () => {
|
||||
@@ -83,7 +83,7 @@ describe('assistant message phase', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default');
|
||||
expect(getAssistantMessagePhase(session, session.messages[0])).toBe('default');
|
||||
});
|
||||
|
||||
test('returns default for thinking-kind messages regardless of other state', () => {
|
||||
@@ -105,8 +105,8 @@ describe('assistant message phase', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getAssistantMessagePhase(session, session.messages[0], 0)).toBe('default');
|
||||
expect(getAssistantMessagePhase(session, session.messages[1], 1)).toBe('final');
|
||||
expect(getAssistantMessagePhase(session, session.messages[0])).toBe('default');
|
||||
expect(getAssistantMessagePhase(session, session.messages[1])).toBe('final');
|
||||
});
|
||||
|
||||
test('skips thinking messages when determining the last completed assistant', () => {
|
||||
@@ -136,8 +136,8 @@ describe('assistant message phase', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
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');
|
||||
expect(getAssistantMessagePhase(session, session.messages[0])).toBe('default');
|
||||
expect(getAssistantMessagePhase(session, session.messages[1])).toBe('default');
|
||||
expect(getAssistantMessagePhase(session, session.messages[2])).toBe('final');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
applySessionEventActivity,
|
||||
applyAssistantUsageEvent,
|
||||
applyTurnEventLog,
|
||||
buildAgentActivityRows,
|
||||
formatAgentActivityLabel,
|
||||
formatDuration,
|
||||
@@ -527,3 +528,79 @@ describe('usage formatting helpers', () => {
|
||||
expect(formatDuration(150_000)).toBe('2.5m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflow diagnostic turn events', () => {
|
||||
function makeDiagnosticEvent(overrides: Partial<SessionEventRecord> = {}): SessionEventRecord {
|
||||
return {
|
||||
sessionId: 'session-1',
|
||||
kind: 'workflow-diagnostic',
|
||||
occurredAt: '2026-03-23T00:00:00.000Z',
|
||||
diagnosticSeverity: 'error',
|
||||
diagnosticKind: 'executor-failed',
|
||||
diagnosticMessage: 'Tool crashed.',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('formats executor-failed with full metadata', () => {
|
||||
const result = applyTurnEventLog({}, makeDiagnosticEvent({
|
||||
executorId: 'Primary',
|
||||
exceptionType: 'InvalidOperationException',
|
||||
}));
|
||||
const entries = result['session-1']!;
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].label).toBe('Executor failed');
|
||||
expect(entries[0].detail).toBe('Primary · InvalidOperationException · Tool crashed.');
|
||||
expect(entries[0].success).toBe(false);
|
||||
});
|
||||
|
||||
test('formats workflow-warning with message only', () => {
|
||||
const result = applyTurnEventLog({}, makeDiagnosticEvent({
|
||||
diagnosticSeverity: 'warning',
|
||||
diagnosticKind: 'workflow-warning',
|
||||
diagnosticMessage: 'Token budget is nearly exhausted.',
|
||||
executorId: undefined,
|
||||
exceptionType: undefined,
|
||||
}));
|
||||
const entries = result['session-1']!;
|
||||
expect(entries[0].label).toBe('Workflow warning');
|
||||
expect(entries[0].detail).toBe('Token budget is nearly exhausted.');
|
||||
expect(entries[0].success).toBeUndefined();
|
||||
});
|
||||
|
||||
test('formats subworkflow-error with subworkflow ID', () => {
|
||||
const result = applyTurnEventLog({}, makeDiagnosticEvent({
|
||||
diagnosticKind: 'subworkflow-error',
|
||||
subworkflowId: 'subworkflow-review',
|
||||
exceptionType: 'InvalidOperationException',
|
||||
diagnosticMessage: 'Reviewer agent failed.',
|
||||
}));
|
||||
const entries = result['session-1']!;
|
||||
expect(entries[0].label).toBe('Subworkflow error');
|
||||
expect(entries[0].detail).toBe('subworkflow-review · InvalidOperationException · Reviewer agent failed.');
|
||||
});
|
||||
|
||||
test('formats workflow-error without optional fields', () => {
|
||||
const result = applyTurnEventLog({}, makeDiagnosticEvent({
|
||||
diagnosticKind: 'workflow-error',
|
||||
diagnosticMessage: 'Workflow terminated unexpectedly.',
|
||||
executorId: undefined,
|
||||
subworkflowId: undefined,
|
||||
exceptionType: undefined,
|
||||
}));
|
||||
const entries = result['session-1']!;
|
||||
expect(entries[0].label).toBe('Workflow error');
|
||||
expect(entries[0].detail).toBe('Workflow terminated unexpectedly.');
|
||||
expect(entries[0].success).toBe(false);
|
||||
});
|
||||
|
||||
test('falls back to severity when diagnosticKind is missing', () => {
|
||||
const result = applyTurnEventLog({}, makeDiagnosticEvent({
|
||||
diagnosticKind: undefined,
|
||||
diagnosticSeverity: 'warning',
|
||||
diagnosticMessage: 'Something odd happened.',
|
||||
}));
|
||||
const entries = result['session-1']!;
|
||||
expect(entries[0].label).toBe('Workflow warning');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,22 +55,37 @@ describe('run timeline helpers', () => {
|
||||
const run = createSessionRunRecord({
|
||||
requestId: 'turn-1',
|
||||
project: createProject(),
|
||||
workingDirectory: 'C:\\workspace\\alpha\\packages\\app',
|
||||
workspaceKind: 'project',
|
||||
pattern: createPattern(),
|
||||
triggerMessageId: 'msg-user-1',
|
||||
startedAt: '2026-03-23T00:00:01.000Z',
|
||||
preRunGitBaselineFiles: [
|
||||
{
|
||||
path: 'src\\alpha.ts',
|
||||
combinedDiff: '@@ -1 +1 @@\n-old\n+new\n',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(run).toMatchObject({
|
||||
requestId: 'turn-1',
|
||||
projectId: 'project-1',
|
||||
projectPath: 'C:\\workspace\\alpha',
|
||||
workingDirectory: 'C:\\workspace\\alpha\\packages\\app',
|
||||
patternId: 'pattern-sequential',
|
||||
patternName: 'Sequential Trio Review',
|
||||
patternMode: 'sequential',
|
||||
triggerMessageId: 'msg-user-1',
|
||||
status: 'running',
|
||||
});
|
||||
expect(run.preRunGitBaselineFiles).toEqual([
|
||||
{
|
||||
path: 'src\\alpha.ts',
|
||||
previousPath: undefined,
|
||||
combinedDiff: '@@ -1 +1 @@\n-old\n+new\n',
|
||||
},
|
||||
]);
|
||||
expect(run.agents).toEqual([
|
||||
{
|
||||
agentId: 'agent-writer',
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 345 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 183 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 382 KiB After Width: | Height: | Size: 383 KiB |
@@ -42,6 +42,15 @@ const {
|
||||
/>
|
||||
|
||||
<title>{title}</title>
|
||||
|
||||
<!-- Theme init (FOUC prevention) -->
|
||||
<script is:inline>
|
||||
(function () {
|
||||
var saved = localStorage.getItem('theme');
|
||||
var theme = saved || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||
if (theme === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-deep font-body text-warm-100 antialiased">
|
||||
<!-- Navigation -->
|
||||
@@ -63,6 +72,19 @@ const {
|
||||
<a href="#get-started" class="text-sm text-warm-400 transition hover:text-warm-50"
|
||||
>Get Started</a
|
||||
>
|
||||
<button
|
||||
class="theme-toggle"
|
||||
id="theme-toggle-desktop"
|
||||
type="button"
|
||||
aria-label="Switch to light theme"
|
||||
>
|
||||
<svg class="icon-sun" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
||||
</svg>
|
||||
<svg class="icon-moon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/davidkaya/aryx"
|
||||
target="_blank"
|
||||
@@ -108,6 +130,19 @@ const {
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-warm-400 transition hover:text-warm-50">GitHub</a
|
||||
>
|
||||
<button
|
||||
class="theme-toggle self-start"
|
||||
id="theme-toggle-mobile"
|
||||
type="button"
|
||||
aria-label="Switch to light theme"
|
||||
>
|
||||
<svg class="icon-sun" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
||||
</svg>
|
||||
<svg class="icon-moon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -185,5 +220,55 @@ const {
|
||||
);
|
||||
document.querySelectorAll('[data-reveal]').forEach((el) => observer.observe(el));
|
||||
</script>
|
||||
|
||||
<!-- Theme toggle + screenshot swap -->
|
||||
<script>
|
||||
function getTheme() {
|
||||
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
// Update meta theme-color
|
||||
var meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute('content', theme === 'light' ? '#f6f4f1' : '#08080a');
|
||||
|
||||
// Update aria-labels
|
||||
var label = theme === 'light' ? 'Switch to dark theme' : 'Switch to light theme';
|
||||
document.querySelectorAll('.theme-toggle').forEach(function (btn) {
|
||||
btn.setAttribute('aria-label', label);
|
||||
});
|
||||
|
||||
// Swap themed images
|
||||
document.querySelectorAll('[data-src-dark]').forEach(function (img) {
|
||||
img.setAttribute('src', theme === 'light' ? img.getAttribute('data-src-light') : img.getAttribute('data-src-dark'));
|
||||
});
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
applyTheme(getTheme() === 'light' ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
// Bind toggle buttons
|
||||
document.querySelectorAll('.theme-toggle').forEach(function (btn) {
|
||||
btn.addEventListener('click', toggleTheme);
|
||||
});
|
||||
|
||||
// Apply on load (screenshots + aria)
|
||||
applyTheme(getTheme());
|
||||
|
||||
// Listen for system preference changes
|
||||
matchMedia('(prefers-color-scheme: light)').addEventListener('change', function (e) {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
applyTheme(e.matches ? 'light' : 'dark');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -109,6 +109,8 @@ import Base from '../layouts/Base.astro';
|
||||
<div class="overflow-hidden rounded-[13px]">
|
||||
<img
|
||||
src="/images/screenshot.png"
|
||||
data-src-dark="/images/screenshot.png"
|
||||
data-src-light="/images/screenshot-light.png"
|
||||
alt="Aryx application screenshot showing the workspace with chat, sidebar, and activity panel"
|
||||
class="w-full"
|
||||
width="1280"
|
||||
@@ -686,6 +688,8 @@ import Base from '../layouts/Base.astro';
|
||||
<div class="overflow-hidden rounded-[13px]">
|
||||
<img
|
||||
src="/images/screenshot-orchestration.png"
|
||||
data-src-dark="/images/screenshot-orchestration.png"
|
||||
data-src-light="/images/screenshot-orchestration-light.png"
|
||||
alt="Aryx workspace showing multi-agent orchestration in action"
|
||||
class="w-full"
|
||||
width="1280"
|
||||
|
||||
@@ -30,6 +30,31 @@
|
||||
--color-accent-dim: #5A20E0;
|
||||
}
|
||||
|
||||
/* ── Light mode token overrides ── */
|
||||
[data-theme='light'] {
|
||||
--color-deep: #f6f4f1;
|
||||
--color-base: #efede9;
|
||||
--color-raised: #e7e4df;
|
||||
--color-card: #ffffff;
|
||||
--color-card-hover: #f9f8f6;
|
||||
--color-border: #d8d4cd;
|
||||
--color-border-subtle: #e5e2dc;
|
||||
|
||||
--color-warm-50: #1a1917;
|
||||
--color-warm-100: #2e2c28;
|
||||
--color-warm-200: #403d38;
|
||||
--color-warm-300: #5e5950;
|
||||
--color-warm-400: #7d776e;
|
||||
--color-warm-500: #9e9890;
|
||||
--color-warm-600: #c4bfb5;
|
||||
--color-warm-700: #d8d4cd;
|
||||
--color-warm-800: #eae7e2;
|
||||
|
||||
--color-brand: #1a4fd4;
|
||||
--color-brand-bright: #1d7ae0;
|
||||
--color-accent: #7522c9;
|
||||
}
|
||||
|
||||
/* ── Grain overlay ── */
|
||||
body::after {
|
||||
content: '';
|
||||
@@ -42,6 +67,9 @@ body::after {
|
||||
background-repeat: repeat;
|
||||
background-size: 256px 256px;
|
||||
}
|
||||
[data-theme='light'] body::after {
|
||||
opacity: 0.04;
|
||||
}
|
||||
|
||||
/* ── Dot grid ── */
|
||||
.dot-grid {
|
||||
@@ -52,6 +80,13 @@ body::after {
|
||||
);
|
||||
background-size: 28px 28px;
|
||||
}
|
||||
[data-theme='light'] .dot-grid {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
rgba(26, 79, 212, 0.10) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Screenshot perspective tilt ── */
|
||||
.screenshot-frame {
|
||||
@@ -116,3 +151,44 @@ body::after {
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
[data-theme='light'] .text-gradient-brand {
|
||||
background: linear-gradient(135deg, #1a4fd4, #7522c9);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* ── Screenshot frame light-mode shadow ── */
|
||||
[data-theme='light'] .screenshot-frame {
|
||||
--tw-shadow-color: rgba(0, 0, 0, 0.08);
|
||||
--tw-ring-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* ── Theme toggle ── */
|
||||
.theme-toggle {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--color-warm-400);
|
||||
transition: color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
.theme-toggle:hover {
|
||||
color: var(--color-warm-50);
|
||||
background: var(--color-raised);
|
||||
}
|
||||
.theme-toggle svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
.theme-toggle .icon-sun { display: none; }
|
||||
.theme-toggle .icon-moon { display: block; }
|
||||
[data-theme='light'] .theme-toggle .icon-sun { display: block; }
|
||||
[data-theme='light'] .theme-toggle .icon-moon { display: none; }
|
||||
|
||||
Reference in New Issue
Block a user