mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 21:48:36 +02:00
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:
+5
-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 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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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] : [];
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user