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
+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 } : {}),
};
}