feat: add git integration enhancements (phase 1)

Real-time git awareness:
- Auto-refresh git context on window focus and after run completion
- Periodic background polling (60s interval, configurable via settings)
- Pre-run working tree snapshot capture for project-backed runs
- Debounced refresh scheduling to coalesce rapid triggers

Backend (main process):
- GitService.captureWorkingTreeSnapshot() with per-file metadata
- Enriched parseWorkingTree() producing ProjectGitWorkingTreeFile entries
- AryxAppService: scheduleProjectGitRefresh(), periodic timer, focus hook
- preRunGitSnapshot persisted on SessionRunRecord with normalization

Frontend (renderer):
- Settings toggle for auto-refresh (General > Git section)
- RunTimeline: git baseline indicator showing branch and change summary
- Enhanced GitContextBadge tooltip with change breakdown details
- ChatPane: enriched git tooltip with staged/modified/untracked counts
- New IPC channel: setGitAutoRefreshEnabled

Tests: 8 new tests covering snapshot capture, refresh scheduling,
scratchpad skip behavior, and auto-refresh setting persistence.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-31 16:37:02 +01:00
co-authored by Copilot
parent 49933f218b
commit 44d0ab07db
17 changed files with 845 additions and 24 deletions
+5 -3
View File
@@ -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 and refresh 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, 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 and captures a structured pre-run working-tree snapshot on each run record. That keeps git CLI access inside the privileged process while giving later renderer features a typed baseline for attributing post-run file changes to a specific turn.
### Patterns
Patterns describe how agents collaborate. The architecture supports:
+125 -2
View File
@@ -185,6 +185,8 @@ 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();
@@ -201,7 +203,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
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 +260,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 +281,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 +569,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 +1357,12 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
): Promise<void> {
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
const preRunGitSnapshot = workspaceKind === 'project'
? await this.gitService.captureWorkingTreeSnapshot(session.cwd ?? project.path, occurredAt)
: 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';
@@ -1317,6 +1378,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
pattern: effectivePattern,
triggerMessageId,
startedAt: occurredAt,
preRunGitSnapshot,
}),
...session.runs,
];
@@ -1376,10 +1438,16 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages);
this.finalizeTurn(workspace, session.id, requestId, responseMessages);
await this.persistAndBroadcast(workspace);
if (workspaceKind === 'project') {
this.scheduleProjectGitRefresh(project.id);
}
} catch (error) {
if (error instanceof TurnCancelledError) {
this.finalizeCancelledTurn(workspace, session, requestId);
await this.persistAndBroadcast(workspace);
if (workspaceKind === 'project') {
this.scheduleProjectGitRefresh(project.id);
}
return;
}
@@ -1402,6 +1470,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
}
await this.persistAndBroadcast(workspace);
if (workspaceKind === 'project') {
this.scheduleProjectGitRefresh(project.id);
}
}
}
@@ -1486,9 +1557,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
}
async refreshProjectGitContext(projectId?: string): Promise<WorkspaceState> {
return this.refreshProjectGitContexts(projectId ? [projectId] : undefined);
}
private async refreshProjectGitContexts(projectIds?: readonly string[]): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const projects = projectId
? [this.requireProject(workspace, projectId)]
const projects = projectIds?.length
? projectIds.map((currentProjectId) => this.requireProject(workspace, currentProjectId))
: workspace.projects;
let didRefreshGit = false;
@@ -1603,6 +1678,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) {
+101 -6
View File
@@ -1,7 +1,14 @@
import { createRequire } from 'node:module';
import { promisify } from 'node:util';
import type { ProjectGitChangeSummary, ProjectGitCommitSummary, ProjectGitContext } from '@shared/domain/project';
import type {
ProjectGitChangeSummary,
ProjectGitCommitSummary,
ProjectGitContext,
ProjectGitWorkingTreeFile,
ProjectGitWorkingTreeFileStatus,
ProjectGitWorkingTreeSnapshot,
} from '@shared/domain/project';
import { nowIso } from '@shared/utils/ids';
type ExecFileException = import('node:child_process').ExecFileException;
@@ -102,9 +109,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 +156,7 @@ function parseChangeSummary(stdout: string): {
untracked: 0,
conflicted: 0,
};
const files: ProjectGitWorkingTreeFile[] = [];
const lines = stdout
.split(/\r?\n/)
@@ -122,20 +167,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 +219,7 @@ function parseChangeSummary(stdout: string): {
return {
changedFileCount,
changes: summary,
files,
};
}
@@ -219,7 +287,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,6 +306,33 @@ export class GitService {
};
}
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,
};
}
private async tryRun(projectPath: string, args: string[]): Promise<GitCommandResult> {
try {
return {
+10
View File
@@ -52,6 +52,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());
@@ -105,6 +111,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();
+1
View File
@@ -28,6 +28,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),
+2
View File
@@ -692,6 +692,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();
+28 -11
View File
@@ -298,17 +298,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">
+40
View File
@@ -8,6 +8,7 @@ import {
ChevronDown,
ChevronRight,
CircleDot,
GitBranch,
MessageSquare,
Play,
Wrench,
@@ -25,6 +26,7 @@ import {
type CollapsedTimelineEvent,
} from '@renderer/lib/runTimelineFormatting';
import type { OrchestrationMode } from '@shared/domain/pattern';
import type { ProjectGitWorkingTreeSnapshot } from '@shared/domain/project';
import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline';
import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
@@ -229,6 +231,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({
@@ -294,6 +329,11 @@ function RunCard({
</div>
)}
{/* Git baseline */}
{run.preRunGitSnapshot && (
<RunGitBaseline snapshot={run.preRunGitSnapshot} />
)}
{/* Timeline events */}
<div>
{collapsedEvents.map((item, index) => (
+34
View File
@@ -46,6 +46,8 @@ 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;
@@ -129,6 +131,8 @@ export function SettingsPanel({
onSetNotificationsEnabled,
minimizeToTray,
onSetMinimizeToTray,
gitAutoRefreshEnabled,
onSetGitAutoRefreshEnabled,
onOpenAppDataFolder,
onResetLocalWorkspace,
onResolveUserDiscoveredTooling,
@@ -274,6 +278,8 @@ export function SettingsPanel({
onSetNotificationsEnabled={onSetNotificationsEnabled}
minimizeToTray={minimizeToTray}
onSetMinimizeToTray={onSetMinimizeToTray}
gitAutoRefreshEnabled={gitAutoRefreshEnabled}
onSetGitAutoRefreshEnabled={onSetGitAutoRefreshEnabled}
/>
)}
{activeSection === 'connection' && (
@@ -338,6 +344,8 @@ function AppearanceSection({
onSetNotificationsEnabled,
minimizeToTray,
onSetMinimizeToTray,
gitAutoRefreshEnabled,
onSetGitAutoRefreshEnabled,
}: {
theme: AppearanceTheme;
onSetTheme: (theme: AppearanceTheme) => void;
@@ -345,6 +353,8 @@ function AppearanceSection({
onSetNotificationsEnabled: (enabled: boolean) => void;
minimizeToTray: boolean;
onSetMinimizeToTray: (enabled: boolean) => void;
gitAutoRefreshEnabled: boolean;
onSetGitAutoRefreshEnabled: (enabled: boolean) => void;
}){
return (
<div>
@@ -434,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>
);
}
+18 -1
View File
@@ -122,8 +122,25 @@ 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-[140px] truncate font-mono">{branchLabel}</span>
{git.isDirty && <Circle className="size-1.5 shrink-0 fill-amber-500 text-amber-500" />}
+1
View File
@@ -17,6 +17,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',
+1
View File
@@ -241,6 +241,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>;
+27
View File
@@ -18,6 +18,33 @@ export interface ProjectGitCommitSummary {
committedAt: 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 ProjectGitWorkingTreeSnapshot {
scannedAt: string;
repoRoot: string;
branch?: string;
changedFileCount: number;
changes: ProjectGitChangeSummary;
files: ProjectGitWorkingTreeFile[];
}
export interface ProjectGitContext {
status: ProjectGitContextStatus;
scannedAt: string;
+94 -1
View File
@@ -5,7 +5,13 @@ 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 {
ProjectGitChangeSummary,
ProjectGitWorkingTreeFile,
ProjectGitWorkingTreeFileStatus,
ProjectGitWorkingTreeSnapshot,
ProjectRecord,
} from '@shared/domain/project';
import { createId } from '@shared/utils/ids';
export type SessionRunStatus = 'running' | 'completed' | 'cancelled' | 'error';
@@ -70,6 +76,7 @@ export interface SessionRunRecord {
status: SessionRunStatus;
agents: RunTimelineAgentRecord[];
events: RunTimelineEventRecord[];
preRunGitSnapshot?: ProjectGitWorkingTreeSnapshot;
}
export interface CreateSessionRunRecordInput {
@@ -79,6 +86,7 @@ export interface CreateSessionRunRecordInput {
pattern: Pick<PatternDefinition, 'id' | 'name' | 'mode' | 'agents'>;
triggerMessageId: string;
startedAt: string;
preRunGitSnapshot?: ProjectGitWorkingTreeSnapshot;
}
export interface AppendRunActivityEventInput {
@@ -122,6 +130,89 @@ 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 normalizeToolCallFileChange(
change: ToolCallFileChangePreview,
): ToolCallFileChangePreview | undefined {
@@ -373,6 +464,7 @@ export function createSessionRunRecord(input: CreateSessionRunRecordInput): Sess
startedAt: input.startedAt,
status: 'running',
completedAt: undefined,
preRunGitSnapshot: normalizeWorkingTreeSnapshot(input.preRunGitSnapshot),
agents: input.pattern.agents
.map((agent): RunTimelineAgentRecord => ({
agentId: agent.id,
@@ -430,6 +522,7 @@ 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),
agents: run.agents.flatMap((agent) => {
const normalized = normalizeRunTimelineAgent(agent);
return normalized ? [normalized] : [];
+2
View File
@@ -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 } : {}),
};
}
+302
View File
@@ -0,0 +1,302 @@
import { describe, expect, mock, test } from 'bun:test';
import type { RunTurnCommand } from '@shared/contracts/sidecar';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { 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 createService(
workspace: WorkspaceState,
pattern: PatternDefinition,
options?: {
snapshot?: ProjectGitWorkingTreeSnapshot;
onCaptureSnapshot?: (projectPath: string, scannedAt: 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>;
};
}
).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>;
};
}
).gitService = {
captureWorkingTreeSnapshot: async (projectPath, scannedAt) => {
options?.onCaptureSnapshot?.(projectPath, scannedAt);
return options?.snapshot;
},
};
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 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);
});
});
+54
View File
@@ -121,4 +121,58 @@ 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();
});
});