mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 11:48:46 +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:
+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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user