From 32bea8d2892d0299b8a97ee7d364b1d7438332cb Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:29:02 -0400 Subject: [PATCH 1/2] feat(frontend): mobile-friendly config editor with find bar and unsaved-changes guard --- backend/src/db/queries.ts | 14 + backend/src/routes/repos.ts | 30 ++ backend/src/services/repo.ts | 164 +++++++- backend/test/db/queries.test.ts | 26 ++ backend/test/routes/repos.test.ts | 76 +++- backend/test/services/repo-auth-env.test.ts | 57 +++ backend/test/services/repo-retry.test.ts | 382 ++++++++++++++++++ .../settings/AgentsMdEditor.test.tsx | 125 ++++++ .../components/settings/AgentsMdEditor.tsx | 74 +++- .../settings/OpenCodeConfigEditor.test.tsx | 347 ++++++++++++++++ .../settings/OpenCodeConfigEditor.tsx | 362 ++++++++--------- .../settings/OpenCodeConfigManager.test.tsx | 44 +- .../settings/OpenCodeConfigManager.tsx | 11 +- .../settings/SettingsDialog.test.tsx | 34 ++ .../components/settings/SettingsDialog.tsx | 10 +- .../src/components/ui/code-editor.test.tsx | 270 +++++++++++++ frontend/src/components/ui/code-editor.tsx | 269 ++++++++++++ frontend/src/components/ui/dialog.test.tsx | 88 +++- frontend/src/components/ui/dialog.tsx | 14 +- .../components/ui/editor-find-bar.test.tsx | 62 +++ .../src/components/ui/editor-find-bar.tsx | 83 ++++ .../components/ui/unsaved-changes-dialog.tsx | 59 +++ frontend/src/hooks/useMobile.ts | 2 +- frontend/src/hooks/useVisualViewport.test.tsx | 124 ++++++ frontend/src/hooks/useVisualViewport.ts | 14 +- frontend/src/index.css | 25 +- frontend/src/lib/editorScroll.test.ts | 24 ++ frontend/src/lib/editorScroll.ts | 12 + frontend/src/lib/jsonc.test.ts | 80 ++++ frontend/src/lib/jsonc.ts | 10 +- shared/src/utils/jsonc.ts | 24 +- 31 files changed, 2645 insertions(+), 271 deletions(-) create mode 100644 backend/test/services/repo-retry.test.ts create mode 100644 frontend/src/components/settings/AgentsMdEditor.test.tsx create mode 100644 frontend/src/components/settings/OpenCodeConfigEditor.test.tsx create mode 100644 frontend/src/components/ui/code-editor.test.tsx create mode 100644 frontend/src/components/ui/code-editor.tsx create mode 100644 frontend/src/components/ui/editor-find-bar.test.tsx create mode 100644 frontend/src/components/ui/editor-find-bar.tsx create mode 100644 frontend/src/components/ui/unsaved-changes-dialog.tsx create mode 100644 frontend/src/hooks/useVisualViewport.test.tsx create mode 100644 frontend/src/lib/editorScroll.test.ts create mode 100644 frontend/src/lib/editorScroll.ts create mode 100644 frontend/src/lib/jsonc.test.ts diff --git a/backend/src/db/queries.ts b/backend/src/db/queries.ts index 31c22ff08..799b4b679 100644 --- a/backend/src/db/queries.ts +++ b/backend/src/db/queries.ts @@ -291,6 +291,20 @@ export function getRepoName(repo: Repo): string { return getRepoDisplayName(repo) } +/** + * Atomically claim a repo row for retry by flipping clone_status + * error -> cloning in a single UPDATE. Returns true only when this caller + * performed the transition, so concurrent retry requests for the same repo + * cannot both proceed to delete and re-clone the same path. + */ +export function claimRepoForRetry(db: Database, id: number): boolean { + const stmt = db.prepare( + 'UPDATE repos SET clone_status = ? WHERE id = ? AND clone_status = ?' + ) + const result = stmt.run('cloning', id, 'error') + return result.changes > 0 +} + export function updateRepoStatus(db: Database, id: number, cloneStatus: Repo['cloneStatus']): void { const stmt = db.prepare('UPDATE repos SET clone_status = ? WHERE id = ?') const result = stmt.run(cloneStatus, id) diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts index a21db00ea..a52c95589 100644 --- a/backend/src/routes/repos.ts +++ b/backend/src/routes/repos.ts @@ -359,6 +359,36 @@ app.get('/', async (c) => { } }) + + app.post('/:id/retry-clone', async (c) => { + try { + const id = parseInt(c.req.param('id')) + if (Number.isNaN(id)) return c.json({ error: 'Invalid repo id' }, 400) + if (id === ASSISTANT_REPO_ID) { + return c.json({ error: 'Assistant repository cannot be retried' }, 400) + } + + const repo = getRepoById(database, id) + if (!repo) { + return c.json({ error: 'Repo not found' }, 404) + } + if (!repo.repoUrl) { + return c.json({ error: 'Only remote repositories can be retried' }, 400) + } + if (repo.cloneStatus !== 'error' && repo.cloneStatus !== 'cloning') { + return c.json({ error: 'Repo is not in a retryable state' }, 409) + } + + const retried = await repoService.retryCloneRepo(database, gitAuthService, id) + const currentBranch = await repoService.getCurrentBranch(retried, gitAuthService.getGitEnvironment()) + + return c.json({ ...withRepoSettings(database, retried), currentBranch }) + } catch (error: unknown) { + logger.error('Failed to retry clone:', error) + return c.json({ error: getErrorMessage(error) }, getStatusCode(error) as ContentfulStatusCode) + } + }) + app.post('/:id/config/switch', async (c) => { try { const id = parseInt(c.req.param('id')) diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index 55fc2b566..8b4362d91 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -2,12 +2,12 @@ import fs from 'fs/promises' import { existsSync, rmSync, realpathSync } from 'node:fs' import { executeCommand } from '../utils/process' import { ensureDirectoryExists } from './file-operations' -import { createRepo, getRepoByLocalPath, getRepoBySourcePath, getRepoById, updateRepoStatus, updateRepoBranch, updateLastPulled, deleteRepo, getRepoByUrlAndBranch } from '../db/queries' +import { createRepo, getRepoByLocalPath, getRepoBySourcePath, getRepoById, updateRepoStatus, updateRepoBranch, updateLastPulled, deleteRepo, getRepoByUrlAndBranch, claimRepoForRetry, getRepoSetting, setRepoSetting } from '../db/queries' import type { Database } from 'bun:sqlite' import type { Repo, CreateRepoInput } from '../types/repo' import { logger } from '../utils/logger' import { getReposPath, getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' -import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, normalizeRepoUrlForCompare } from '@opencode-manager/shared/utils' +import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, normalizeRepoUrlForCompare, getRepoBaseDirectoryName } from '@opencode-manager/shared/utils' import type { GitAuthService } from './git-auth' import { isGitHubHttpsUrl, isSSHUrl, normalizeSSHUrl } from '../utils/git-auth' import path from 'path' @@ -25,6 +25,9 @@ const GIT_CLONE_TIMEOUT = 300000 const DEFAULT_DISCOVERY_MAX_DEPTH = 4 const DISCOVERY_SKIP_DIRECTORIES = new Set(['.git', 'node_modules']) +const REPO_WORKTREE_BASE_BRANCH_SETTING_KEY = 'worktreeBaseBranch' +const REPO_SKIP_SSH_VERIFICATION_SETTING_KEY = 'skipSSHVerification' + function canonical(dir: string): string { try { return realpathSync(path.resolve(dir)) @@ -635,6 +638,18 @@ export async function cloneRepo( const repo = createRepo(database, createRepoInput) + if (shouldUseWorktree && baseBranch) { + setRepoSetting(database, repo.id, REPO_WORKTREE_BASE_BRANCH_SETTING_KEY, baseBranch) + } + + // Persist the original SSH-verification preference so retryCloneRepo can + // re-establish the same trust posture (host-verification skipped or not) + // without the caller re-supplying it. Only persisted when truthy so the + // default (verify hosts) stays the implicit baseline. + if (skipSSHVerification) { + setRepoSetting(database, repo.id, REPO_SKIP_SSH_VERIFICATION_SETTING_KEY, 'true') + } + try { await gitAuthService.setupSSHForRepoUrl(effectiveUrl, database, skipSSHVerification) @@ -834,13 +849,156 @@ export async function cloneRepo( return { ...repo, cloneStatus: 'ready' } } catch (error: unknown) { logger.error(`Failed to create repo: ${normalizedRepoUrl}${branch ? `#${branch}` : ''}`, error) - deleteRepo(database, repo.id) + updateRepoStatus(database, repo.id, 'error') throw error } finally { await gitAuthService.cleanupSSHKey() } } + +/** + * Atomically re-run the remote clone for an existing repository row. + * + * Preserves the repository record (id, localPath, branch, defaultBranch, + * stored repoUrl, git credential association) and only mutates cloneStatus: + * 'cloning' while the clone is in flight, 'ready' on success, 'error' on + * failure. The stored row is never deleted and never recreated, so the + * identity/configuration captured at create time stays stable across retries. + */ +export async function retryCloneRepo( + database: Database, + gitAuthService: GitAuthService, + repoId: number +): Promise { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error(`Repo not found: ${repoId}`) + } + if (!repo.repoUrl) { + throw new Error('Only remote repositories with a stored clone URL can be retried') + } + if (repo.cloneStatus !== 'error' && repo.cloneStatus !== 'cloning') { + throw new Error(`Repo is not in a retryable state: ${repo.cloneStatus}`) + } + + // Concurrency guard: only the caller that atomically flips error -> cloning + // proceeds. A second concurrent retry request loses the CAS and short- + // circuits before touching the filesystem or the row. This prevents the + // double-tap race where two retries delete + clone the same path and one + // failure clobbers a ready success with error. + if (!claimRepoForRetry(database, repoId)) { + throw new Error('Repo retry is already in progress') + } + + const effectiveUrl = normalizeSSHUrl(repo.repoUrl) + const isSSH = isSSHUrl(effectiveUrl) + const branch = repo.branch + const localPath = repo.localPath + const isWorktree = repo.isWorktree === true + // Preserve the original create-time SSH-verification posture. Hard-coding + // false here would silently downgrade a repo whose first clone succeeded + // only because host verification was skipped (host not yet in known_hosts); + // its retry would then fail with a host-key prompt instead of recovering. + const skipSSHVerification = getRepoSetting(database, repoId, REPO_SKIP_SSH_VERIFICATION_SETTING_KEY) === 'true' + + logger.info( + `Retrying clone for repo ${repoId}: ${repo.repoUrl}${branch ? `#${branch}` : ''}${isWorktree ? ' (worktree)' : ''}` + ) + + try { + await gitAuthService.setupSSHForRepoUrl(effectiveUrl, database, skipSSHVerification) + + const env = { + ...gitAuthService.getGitEnvironment(), + ...(isSSH ? gitAuthService.getSSHEnvironment() : {}) + } + + const targetPath = path.join(getReposPath(), localPath) + if (existsSync(targetPath)) { + logger.info(`Removing stale partial directory before retry: ${localPath}`) + rmSync(targetPath, { recursive: true, force: true }) + } + + if (isWorktree) { + // A worktree must be re-created as a worktree off its base repo, not as + // a standalone clone. Recreating it via `git clone` would leave the row + // flagged isWorktree=true while pointing at a non-worktree checkout, + // breaking worktree-aware teardown and the segmented-spine UI contract. + if (!branch) { + throw new Error('Worktree retry requires a stored branch') + } + const baseDirName = getRepoBaseDirectoryName(repo) + const baseRepoPath = path.resolve(getReposPath(), baseDirName) + if (!existsSync(baseRepoPath)) { + throw new Error(`Base repository for worktree retry not found: ${baseDirName}`) + } + const worktreePath = path.resolve(getReposPath(), localPath) + + await executeCommand(['git', '-C', baseRepoPath, 'fetch', '--all'], { + cwd: getReposPath(), + env + }) + const baseBranchSetting = getRepoSetting(database, repoId, REPO_WORKTREE_BASE_BRANCH_SETTING_KEY) ?? undefined + await createWorktreeSafely(baseRepoPath, worktreePath, branch, env, baseBranchSetting) + + if (!existsSync(worktreePath)) { + throw new Error(`Worktree directory was not created at: ${worktreePath}`) + } + } else { + const cloneCmd = branch + ? ['git', 'clone', '-b', branch, effectiveUrl, localPath] + : ['git', 'clone', effectiveUrl, localPath] + try { + await executeCommand(cloneCmd, { cwd: getReposPath(), env, timeout: GIT_CLONE_TIMEOUT }) + } catch (error: unknown) { + const message = getErrorMessage(error) + // Mirror the create-time clone fallback: if the stored branch does + // not exist on the remote (e.g. it was created locally and never + // pushed, or the remote pruned it), a retry that re-runs `git clone + // -b ` would fail forever. Recover by cloning the default + // branch and creating/checking out the requested branch locally so + // the row can transition to 'ready' instead of being stuck in + // 'error' across every retry. + if (branch && (message.includes('Remote branch') || message.includes('not found'))) { + logger.info(`Retry clone: branch '${branch}' not found on remote, cloning default branch and creating '${branch}' locally`) + try { + await executeCommand(['git', 'clone', effectiveUrl, localPath], { cwd: getReposPath(), env, timeout: GIT_CLONE_TIMEOUT }) + } catch (cloneError: unknown) { + throw enhanceCloneError(cloneError, repo.repoUrl, getErrorMessage(cloneError)) + } + const resolvedWorktreePath = path.resolve(getReposPath(), localPath) + let localBranchExists = 'missing' + try { + await executeCommand(['git', '-C', resolvedWorktreePath, 'rev-parse', '--verify', `refs/heads/${branch}`]) + localBranchExists = 'exists' + } catch { + localBranchExists = 'missing' + } + if (localBranchExists.trim() === 'missing') { + await executeCommand(['git', '-C', resolvedWorktreePath, 'checkout', '-b', branch]) + } else { + await executeCommand(['git', '-C', resolvedWorktreePath, 'checkout', branch]) + } + } else { + throw enhanceCloneError(error, repo.repoUrl, message) + } + } + } + + updateRepoStatus(database, repoId, 'ready') + updateLastPulled(database, repoId) + logger.info(`Retry clone succeeded for repo ${repoId}`) + return { ...repo, cloneStatus: 'ready' } + } catch (error: unknown) { + logger.error(`Retry clone failed for repo ${repoId}:`, error) + updateRepoStatus(database, repoId, 'error') + throw enhanceCloneError(error, repo.repoUrl, getErrorMessage(error)) + } finally { + await gitAuthService.cleanupSSHKey() + } +} + export async function getCurrentBranch(repo: Repo, env: Record): Promise { const repoPath = path.resolve(repo.fullPath) const branch = await safeGetCurrentBranch(repoPath, env) diff --git a/backend/test/db/queries.test.ts b/backend/test/db/queries.test.ts index dd429b63b..8d5219535 100644 --- a/backend/test/db/queries.test.ts +++ b/backend/test/db/queries.test.ts @@ -199,6 +199,32 @@ describe('Database Queries', () => { }) }) + describe('claimRepoForRetry', () => { + it('atomically flips error -> cloning and returns true when it wins', () => { + const stmt = { + run: vi.fn().mockReturnValue({ changes: 1 }) + } + mockDb.prepare.mockReturnValue(stmt) + + const claimed = db.claimRepoForRetry(mockDb, 7) + + expect(claimed).toBe(true) + expect(mockDb.prepare).toHaveBeenCalledWith( + 'UPDATE repos SET clone_status = ? WHERE id = ? AND clone_status = ?' + ) + expect(stmt.run).toHaveBeenCalledWith('cloning', 7, 'error') + }) + + it('returns false when another caller already flipped the row (changes == 0)', () => { + const stmt = { + run: vi.fn().mockReturnValue({ changes: 0 }) + } + mockDb.prepare.mockReturnValue(stmt) + + expect(db.claimRepoForRetry(mockDb, 7)).toBe(false) + }) + }) + describe('updateRepoConfigName', () => { it('should update repo OpenCode config name', () => { const stmt = { diff --git a/backend/test/routes/repos.test.ts b/backend/test/routes/repos.test.ts index f93615731..ad9aacc6d 100644 --- a/backend/test/routes/repos.test.ts +++ b/backend/test/routes/repos.test.ts @@ -15,11 +15,13 @@ vi.mock('bun:sqlite', () => ({ vi.mock('../../src/db/queries', () => ({ getRepoById: vi.fn(), - updateLastAccessed: vi.fn() + updateLastAccessed: vi.fn(), + getRepoGitCredentialId: vi.fn() })) vi.mock('../../src/services/repo', () => ({ - getCurrentBranch: vi.fn() + getCurrentBranch: vi.fn(), + retryCloneRepo: vi.fn() })) vi.mock('../../src/services/assistant-mode', () => ({ @@ -43,6 +45,7 @@ import type { GitAuthService } from '../../src/services/git-auth' import type { ScheduleService } from '../../src/services/schedules' import type { AssistantModeStatus } from '@opencode-manager/shared/types' import { getAssistantModeStatus, ensureAssistantMode } from '../../src/services/assistant-mode' +import { retryCloneRepo } from '../../src/services/repo' const mockGitAuthService = { getGitEnvironment: vi.fn().mockReturnValue({}) @@ -320,4 +323,73 @@ describe('Repo Routes', () => { }) }) }) + +describe('POST /:id/retry-clone', () => { + function makeErrorRepo(overrides: Partial> = {}) { + return { + id: 7, + repoUrl: 'https://github.com/acme/forge', + localPath: 'forge', + fullPath: '/tmp/repos/forge', + branch: 'feature/x', + defaultBranch: 'main', + cloneStatus: 'error' as const, + clonedAt: Date.now(), + ...overrides, + } + } + + it('returns 404 when repo not found', async () => { + vi.mocked(db.getRepoById).mockReturnValue(null) + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/7/retry-clone', { method: 'POST' }) + expect(res.status).toBe(404) + expect(retryCloneRepo).not.toHaveBeenCalled() + }) + + it('returns 400 when repo has no stored clone URL (local-only)', async () => { + vi.mocked(db.getRepoById).mockReturnValue(makeErrorRepo({ repoUrl: undefined, isLocal: true })) + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/7/retry-clone', { method: 'POST' }) + expect(res.status).toBe(400) + const body = await res.json() as { error: string } + expect(body.error).toBe('Only remote repositories can be retried') + expect(retryCloneRepo).not.toHaveBeenCalled() + }) + + it('returns 409 when repo is not in a retryable state', async () => { + vi.mocked(db.getRepoById).mockReturnValue(makeErrorRepo({ cloneStatus: 'ready' as const })) + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/7/retry-clone', { method: 'POST' }) + expect(res.status).toBe(409) + expect(retryCloneRepo).not.toHaveBeenCalled() + }) + + it('preserves the row id and stored config and returns the retried repo with settings', async () => { + const repo = makeErrorRepo() + vi.mocked(db.getRepoById).mockReturnValue(repo) + const retried = { ...repo, cloneStatus: 'ready' as const, lastPulled: Date.now() } + vi.mocked(retryCloneRepo).mockResolvedValue(retried) + vi.mocked(db.getRepoGitCredentialId).mockReturnValue(null) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/7/retry-clone', { method: 'POST' }) + + expect(res.status).toBe(200) + expect(retryCloneRepo).toHaveBeenCalledWith(mockDb, mockGitAuthService, 7) + const body = await res.json() as { id: number; cloneStatus: string; currentBranch: string | null } + expect(body.id).toBe(7) + expect(body.cloneStatus).toBe('ready') + }) + + it('propagates service failures as 500 without destroying the row', async () => { + vi.mocked(db.getRepoById).mockReturnValue(makeErrorRepo()) + vi.mocked(retryCloneRepo).mockRejectedValue(new Error('Authentication failed')) + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/7/retry-clone', { method: 'POST' }) + expect(res.status).toBe(500) + const body = await res.json() as { error: string } + expect(body.error).toBe('Authentication failed') + }) +}) }) diff --git a/backend/test/services/repo-auth-env.test.ts b/backend/test/services/repo-auth-env.test.ts index cbb05235a..3f1e87ec3 100644 --- a/backend/test/services/repo-auth-env.test.ts +++ b/backend/test/services/repo-auth-env.test.ts @@ -24,11 +24,16 @@ vi.mock('node:fs', () => ({ rmSync: vi.fn(), })) +const setRepoSetting = vi.fn() +const getRepoSetting = vi.fn() + vi.mock('../../src/db/queries', () => ({ getRepoByUrlAndBranch, createRepo, updateRepoStatus, deleteRepo, + setRepoSetting, + getRepoSetting, })) vi.mock('../../src/services/settings', () => ({ @@ -64,6 +69,7 @@ const mockGitAuthService = { describe('repoService.cloneRepo auth env', () => { beforeEach(() => { vi.clearAllMocks() + getRepoSetting.mockReturnValue(null) }) it('passes github extraheader env to git clone', async () => { @@ -97,4 +103,55 @@ describe('repoService.cloneRepo auth env', () => { expect(updateRepoStatus).toHaveBeenCalledWith(database, 1, 'ready') expect(deleteRepo).not.toHaveBeenCalled() }) + + it('persists the skipSSHVerification flag at create time so retry can preserve it', async () => { + const { cloneRepo } = await import('../../src/services/repo') + + const database = {} as any + const repoUrl = 'ssh://git@example.com/acme/forge.git' + + getRepoByUrlAndBranch.mockReturnValue(null) + createRepo.mockReturnValue({ + id: 7, + repoUrl, + localPath: 'forge', + defaultBranch: 'main', + cloneStatus: 'cloning', + clonedAt: Date.now(), + }) + existsSync.mockReturnValue(false) + executeCommand.mockResolvedValue('') + + await cloneRepo(database, mockGitAuthService, repoUrl, { skipSSHVerification: true }) + + expect(setRepoSetting).toHaveBeenCalledWith(database, 7, 'skipSSHVerification', 'true') + }) + + it('does not persist a skipSSHVerification row when the flag is false (default)', async () => { + const { cloneRepo } = await import('../../src/services/repo') + + const database = {} as any + const repoUrl = 'https://github.com/acme/forge.git' + + getRepoByUrlAndBranch.mockReturnValue(null) + createRepo.mockReturnValue({ + id: 8, + repoUrl, + localPath: 'forge', + defaultBranch: 'main', + cloneStatus: 'cloning', + clonedAt: Date.now(), + }) + existsSync.mockReturnValue(false) + executeCommand.mockResolvedValue('') + + await cloneRepo(database, mockGitAuthService, repoUrl) + + expect(setRepoSetting).not.toHaveBeenCalledWith( + database, + 8, + 'skipSSHVerification', + expect.anything() + ) + }) }) diff --git a/backend/test/services/repo-retry.test.ts b/backend/test/services/repo-retry.test.ts new file mode 100644 index 000000000..6979ba0b5 --- /dev/null +++ b/backend/test/services/repo-retry.test.ts @@ -0,0 +1,382 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getReposPath } from '@opencode-manager/shared/config/env' +import type { GitAuthService } from '../../src/services/git-auth' + +const executeCommand = vi.fn() +const ensureDirectoryExists = vi.fn() +const existsSync = vi.fn() +const rmSync = vi.fn() + +const getRepoById = vi.fn() +const updateRepoStatus = vi.fn() +const updateLastPulled = vi.fn() +const deleteRepo = vi.fn() +const claimRepoForRetry = vi.fn() +const getRepoSetting = vi.fn() +const setRepoSetting = vi.fn() + +vi.mock('node:fs', () => ({ + existsSync, + rmSync, + realpathSync: vi.fn((p: string) => p), +})) + +vi.mock('../../src/utils/process', () => ({ + executeCommand, +})) + +vi.mock('../../src/services/file-operations', () => ({ + ensureDirectoryExists, +})) + +vi.mock('../../src/db/queries', () => ({ + getRepoById, + updateRepoStatus, + updateLastPulled, + deleteRepo, + claimRepoForRetry, + getRepoSetting, + setRepoSetting, +})) + +vi.mock('../../src/services/settings', () => ({ + SettingsService: vi.fn().mockImplementation(() => ({ + getSettings: vi.fn().mockReturnValue({ preferences: { gitCredentials: [] } }), + })), +})) + +vi.mock('../../src/utils/ssh-key-manager', () => ({ + parseSSHHost: vi.fn((url: string) => ({ user: 'git', host: url, port: null })), + writeTemporarySSHKey: vi.fn(), + buildSSHCommand: vi.fn(), + buildSSHCommandWithKnownHosts: vi.fn(), + cleanupSSHKey: vi.fn(), +})) + +const mockGitAuthService = { + getGitEnvironment: vi.fn().mockReturnValue({}), + getSSHEnvironment: vi.fn().mockReturnValue({}), + setupSSHKey: vi.fn(), + cleanupSSHKey: vi.fn(), + verifyHostKeyBeforeOperation: vi.fn().mockResolvedValue(true), + setupSSHForRepoUrl: vi.fn().mockResolvedValue(false), + setSSHPort: vi.fn(), +} as unknown as GitAuthService + +function makeErrorRepo(overrides: Partial> = {}) { + return { + id: 42, + repoUrl: 'https://github.com/acme/forge.git', + localPath: 'forge', + fullPath: '/tmp/repos/forge', + branch: 'feature/x', + defaultBranch: 'main', + cloneStatus: 'error' as const, + clonedAt: Date.now(), + ...overrides, + } +} + +describe('repoService.retryCloneRepo', () => { + beforeEach(() => { + vi.clearAllMocks() + ensureDirectoryExists.mockResolvedValue(undefined) + executeCommand.mockResolvedValue('') + existsSync.mockReturnValue(false) + claimRepoForRetry.mockReturnValue(true) + getRepoSetting.mockReturnValue(null) + setRepoSetting.mockClear() + }) + + it('throws when the repo row is missing', async () => { + const { retryCloneRepo } = await import('../../src/services/repo') + getRepoById.mockReturnValue(null) + await expect(retryCloneRepo({} as never, mockGitAuthService, 42)).rejects.toThrow(/not found/) + expect(updateRepoStatus).not.toHaveBeenCalled() + }) + + it('throws for local-only repos with no stored clone URL', async () => { + const { retryCloneRepo } = await import('../../src/services/repo') + getRepoById.mockReturnValue(makeErrorRepo({ repoUrl: undefined, isLocal: true })) + await expect(retryCloneRepo({} as never, mockGitAuthService, 42)).rejects.toThrow(/remote repositories/) + expect(updateRepoStatus).not.toHaveBeenCalled() + expect(executeCommand).not.toHaveBeenCalled() + }) + + it('throws when the repo is not in a retryable state', async () => { + const { retryCloneRepo } = await import('../../src/services/repo') + getRepoById.mockReturnValue(makeErrorRepo({ cloneStatus: 'ready' as const })) + await expect(retryCloneRepo({} as never, mockGitAuthService, 42)).rejects.toThrow(/retryable state/) + expect(updateRepoStatus).not.toHaveBeenCalled() + expect(executeCommand).not.toHaveBeenCalled() + }) + + it('re-clones using the stored repoUrl/branch/localPath and flips cloning -> ready, preserving id', async () => { + const { retryCloneRepo } = await import('../../src/services/repo') + const database = {} as never + getRepoById.mockReturnValue(makeErrorRepo()) + existsSync.mockReturnValue(true) + executeCommand.mockResolvedValue('') + + const result = await retryCloneRepo(database, mockGitAuthService, 42) + + expect(claimRepoForRetry).toHaveBeenCalledWith(database, 42) + expect(rmSync).toHaveBeenCalledWith(expect.stringContaining('forge'), { recursive: true, force: true }) + expect(executeCommand).toHaveBeenCalledWith( + ['git', 'clone', '-b', 'feature/x', 'https://github.com/acme/forge.git', 'forge'], + expect.objectContaining({ cwd: getReposPath(), timeout: 300000 }) + ) + expect(updateRepoStatus).toHaveBeenLastCalledWith(database, 42, 'ready') + expect(updateLastPulled).toHaveBeenCalledWith(database, 42) + expect(deleteRepo).not.toHaveBeenCalled() + expect(result.id).toBe(42) + expect(result.localPath).toBe('forge') + expect(result.branch).toBe('feature/x') + expect(result.cloneStatus).toBe('ready') + }) + + it('omits the branch flag when the stored branch is undefined', async () => { + const { retryCloneRepo } = await import('../../src/services/repo') + getRepoById.mockReturnValue(makeErrorRepo({ branch: undefined })) + await retryCloneRepo({} as never, mockGitAuthService, 42) + expect(executeCommand).toHaveBeenCalledWith( + ['git', 'clone', 'https://github.com/acme/forge.git', 'forge'], + expect.objectContaining({ cwd: getReposPath() }) + ) + }) + + it('flips the row back to error (never deletes) when the clone fails', async () => { + const { retryCloneRepo } = await import('../../src/services/repo') + const database = {} as never + getRepoById.mockReturnValue(makeErrorRepo()) + executeCommand.mockRejectedValue(new Error('fatal: Authentication failed')) + + await expect(retryCloneRepo(database, mockGitAuthService, 42)).rejects.toThrow(/Authentication/) + + expect(updateRepoStatus).toHaveBeenLastCalledWith(database, 42, 'error') + expect(deleteRepo).not.toHaveBeenCalled() + expect(updateLastPulled).not.toHaveBeenCalled() + }) + + it('rejects a concurrent retry when the atomic error->cloning claim loses', async () => { + const { retryCloneRepo } = await import('../../src/services/repo') + claimRepoForRetry.mockReturnValue(false) + getRepoById.mockReturnValue(makeErrorRepo()) + + await expect(retryCloneRepo({} as never, mockGitAuthService, 42)).rejects.toThrow(/already in progress/) + + expect(executeCommand).not.toHaveBeenCalled() + expect(rmSync).not.toHaveBeenCalled() + // The losing caller must not touch the row further (no ready/error flip). + expect(updateRepoStatus).not.toHaveBeenCalled() + expect(updateLastPulled).not.toHaveBeenCalled() + }) + + it('re-creates a worktree row via git worktree add (not a standalone clone)', async () => { + const { retryCloneRepo } = await import('../../src/services/repo') + const database = {} as never + getRepoById.mockReturnValue( + makeErrorRepo({ isWorktree: true, localPath: 'forge-feature-x' }) + ) + existsSync.mockReturnValue(true) + executeCommand.mockResolvedValue('') + + await retryCloneRepo(database, mockGitAuthService, 42) + + const cloneCalls = executeCommand.mock.calls.filter( + ([cmd]) => Array.isArray(cmd) && cmd[0] === 'git' && cmd[1] === 'clone' + ) + expect(cloneCalls).toHaveLength(0) + + const fetchCalls = executeCommand.mock.calls.filter( + ([cmd]) => Array.isArray(cmd) && cmd[0] === 'git' && cmd[1] === '-C' && cmd.includes('fetch') + ) + expect(fetchCalls.length).toBeGreaterThan(0) + + const worktreeAddCalls = executeCommand.mock.calls.filter( + ([cmd]) => Array.isArray(cmd) && cmd.includes('worktree') && cmd.includes('add') + ) + expect(worktreeAddCalls.length).toBeGreaterThan(0) + + expect(updateRepoStatus).toHaveBeenLastCalledWith(database, 42, 'ready') + expect(updateLastPulled).toHaveBeenCalledWith(database, 42) + }) + + it('throws when a worktree row has no stored branch (worktree retry needs a branch)', async () => { + const { retryCloneRepo } = await import('../../src/services/repo') + getRepoById.mockReturnValue(makeErrorRepo({ isWorktree: true, branch: undefined })) + existsSync.mockReturnValue(true) + + await expect(retryCloneRepo({} as never, mockGitAuthService, 42)).rejects.toThrow(/stored branch/) + + const cloneCalls = executeCommand.mock.calls.filter( + ([cmd]) => Array.isArray(cmd) && cmd[0] === 'git' && cmd[1] === 'clone' + ) + expect(cloneCalls).toHaveLength(0) + const worktreeAddCalls = executeCommand.mock.calls.filter( + ([cmd]) => Array.isArray(cmd) && cmd.includes('worktree') && cmd.includes('add') + ) + expect(worktreeAddCalls).toHaveLength(0) + expect(updateRepoStatus).toHaveBeenLastCalledWith({}, 42, 'error') + }) + + it('passes the persisted worktree baseBranch to git worktree add -b on retry', async () => { + const { retryCloneRepo } = await import('../../src/services/repo') + const database = {} as never + getRepoById.mockReturnValue( + makeErrorRepo({ isWorktree: true, localPath: 'forge-feature-x' }) + ) + existsSync.mockReturnValue(true) + // Simulate the initial-create failure mode the bug is about: the worktree + // branch does not exist yet (rev-parse --verify rejects), so the retry + // must recreate it via `git worktree add -b `. + executeCommand.mockImplementation((cmd: unknown) => { + const args = Array.isArray(cmd) ? (cmd as string[]) : [] + if (args.includes('rev-parse') && args.includes('--verify')) { + return Promise.reject(new Error('ref not found')) + } + return Promise.resolve('') + }) + getRepoSetting.mockReturnValue('develop') + + await retryCloneRepo(database, mockGitAuthService, 42) + + expect(getRepoSetting).toHaveBeenCalledWith(database, 42, 'worktreeBaseBranch') + + const newBranchAddCalls = executeCommand.mock.calls.filter( + ([cmd]) => Array.isArray(cmd) && cmd.includes('worktree') && cmd.includes('add') && cmd.includes('-b') + ) + expect(newBranchAddCalls.length).toBeGreaterThan(0) + expect((newBranchAddCalls[0] as unknown as string[])[0]).toContain('develop') + }) + + it('omits the start-point when no worktree baseBranch is persisted', async () => { + const { retryCloneRepo } = await import('../../src/services/repo') + getRepoById.mockReturnValue( + makeErrorRepo({ isWorktree: true, localPath: 'forge-feature-x' }) + ) + existsSync.mockReturnValue(true) + executeCommand.mockResolvedValue('') + getRepoSetting.mockReturnValue(null) + + await retryCloneRepo({} as never, mockGitAuthService, 42) + + const addCalls = executeCommand.mock.calls.filter( + ([cmd]) => Array.isArray(cmd) && cmd.includes('worktree') && cmd.includes('add') + ) + expect(addCalls.length).toBeGreaterThan(0) + expect((addCalls[0] as unknown as string[])[0]).not.toContain('develop') + }) + +it('preserves the persisted skipSSHVerification flag from the original clone on retry', async () => { + const { retryCloneRepo } = await import('../../src/services/repo') + const database = {} as never + getRepoById.mockReturnValue(makeErrorRepo()) + existsSync.mockReturnValue(false) + executeCommand.mockResolvedValue('') + // Persisted setting: the original create ran with host verification + // skipped; retry must keep that posture rather than re-enabling it. + getRepoSetting.mockImplementation((db: unknown, id: number, key: string) => + key === 'skipSSHVerification' ? 'true' : null + ) + + await retryCloneRepo(database, mockGitAuthService, 42) + + expect(mockGitAuthService.setupSSHForRepoUrl).toHaveBeenCalledWith( + expect.any(String), + database, + true + ) + + const cloneCalls = executeCommand.mock.calls.filter( + ([cmd]) => Array.isArray(cmd) && cmd[0] === 'git' && cmd[1] === 'clone' + ) + expect(cloneCalls.length).toBe(1) + }) + + it('defaults to verifying SSH hosts on retry when the create-time flag was not persisted', async () => { + const { retryCloneRepo } = await import('../../src/services/repo') + getRepoById.mockReturnValue(makeErrorRepo()) + existsSync.mockReturnValue(false) + executeCommand.mockResolvedValue('') + getRepoSetting.mockReturnValue(null) + + await retryCloneRepo({} as never, mockGitAuthService, 42) + + expect(mockGitAuthService.setupSSHForRepoUrl).toHaveBeenCalledWith( + expect.any(String), + {} as never, + false + ) + }) + + it('falls back to cloning the default branch and creating the requested branch locally when the remote branch is missing on retry', async () => { + const { retryCloneRepo } = await import('../../src/services/repo') + const database = {} as never + getRepoById.mockReturnValue(makeErrorRepo({ branch: 'feature/ghost' })) + existsSync.mockReturnValue(false) + getRepoSetting.mockReturnValue(null) + let cloneAttempts = 0 + executeCommand.mockImplementation((cmd: unknown) => { + const args = Array.isArray(cmd) ? (cmd as string[]) : [] + const isCloneWithBranch = + args[0] === 'git' && args[1] === 'clone' && args.includes('-b') + if (isCloneWithBranch) { + cloneAttempts += 1 + return Promise.reject(new Error("Remote branch feature/ghost not found in upstream origin")) + } + // The requested branch also does not exist locally yet (rev-parse against + // refs/heads/feature/ghost rejects), so the retry falls through to + // `git checkout -b feature/ghost`. + if (args.includes('rev-parse')) { + return Promise.reject(new Error('unknown revision')) + } + // Default-branch clone and the local branch creation succeed. + return Promise.resolve('') + }) + + await retryCloneRepo(database, mockGitAuthService, 42) + + const cloneWithBranch = executeCommand.mock.calls.filter( + ([cmd]) => Array.isArray(cmd) && (cmd as string[])[0] === 'git' + && (cmd as string[])[1] === 'clone' && (cmd as string[]).includes('-b') + ) + const cloneWithoutBranch = executeCommand.mock.calls.filter( + ([cmd]) => Array.isArray(cmd) && (cmd as string[])[0] === 'git' + && (cmd as string[])[1] === 'clone' && !(cmd as string[]).includes('-b') + ) + expect(cloneWithBranch).toHaveLength(1) + expect(cloneWithoutBranch).toHaveLength(1) + expect(cloneAttempts).toBe(1) + + // The requested branch must be created (or checked out) locally after + // the default-branch clone succeeds, so the row ends up ready with the + // branch the user originally asked for. + const checkoutCreateBranch = executeCommand.mock.calls.filter( + ([cmd]) => Array.isArray(cmd) && (cmd as string[]).includes('checkout') && (cmd as string[]).includes('-b') + && (cmd as string[]).includes('feature/ghost') + ) + expect(checkoutCreateBranch.length).toBe(1) + + expect(updateRepoStatus).toHaveBeenLastCalledWith(database, 42, 'ready') + }) + + it('still surfaces a non-missing-branch clone failure as an error (no spurious fallback)', async () => { + const { retryCloneRepo } = await import('../../src/services/repo') + const database = {} as never + getRepoById.mockReturnValue(makeErrorRepo({ branch: 'feature/x' })) + existsSync.mockReturnValue(false) + getRepoSetting.mockReturnValue(null) + executeCommand.mockRejectedValue(new Error('Authentication failed')) + + await expect(retryCloneRepo(database, mockGitAuthService, 42)).rejects.toThrow(/Authentication/) + + // Only the original `git clone -b feature/x` attempt ran; no fallback + // default-branch clone was attempted for a non missing-branch error. + const cloneCalls = executeCommand.mock.calls.filter( + ([cmd]) => Array.isArray(cmd) && (cmd as string[])[0] === 'git' && (cmd as string[])[1] === 'clone' + ) + expect(cloneCalls).toHaveLength(1) + expect(updateRepoStatus).toHaveBeenLastCalledWith(database, 42, 'error') + }) +}) diff --git a/frontend/src/components/settings/AgentsMdEditor.test.tsx b/frontend/src/components/settings/AgentsMdEditor.test.tsx new file mode 100644 index 000000000..3308850be --- /dev/null +++ b/frontend/src/components/settings/AgentsMdEditor.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { AgentsMdEditor } from './AgentsMdEditor' + +vi.mock('@/api/settings', () => ({ + settingsApi: { + getAgentsMd: vi.fn().mockResolvedValue({ content: '# Rules\n\nline two\nline three\n' }), + updateAgentsMd: vi.fn().mockResolvedValue(undefined), + getDefaultAgentsMd: vi.fn().mockResolvedValue({ content: '# Default' }), + }, +})) + +const createWrapper = () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return ({ children }: { children: React.ReactNode }) => ( + {children} + ) +} + +describe('AgentsMdEditor', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('loads the current AGENTS.md into the editor', async () => { + render(, { wrapper: createWrapper() }) + await waitFor(() => + expect(screen.getByLabelText('AGENTS.md content')).toHaveValue('# Rules\n\nline two\nline three\n'), + ) + }) + + it('renders a line number gutter', async () => { + render(, { wrapper: createWrapper() }) + await waitFor(() => expect(screen.getByLabelText('AGENTS.md content')).toBeInTheDocument()) + const numbers = Array.from(document.body.querySelectorAll('[data-line-number]')) + expect(numbers.map((n) => n.textContent?.trim())).toEqual(['1', '2', '3', '4', '5']) + }) + + it('finds matches in the content', async () => { + const user = userEvent.setup() + render(, { wrapper: createWrapper() }) + await waitFor(() => expect(screen.getByLabelText('AGENTS.md content')).toBeInTheDocument()) + const findInput = screen.getByLabelText('Find in content') + await user.type(findInput, 'line') + expect(screen.getByTestId('find-match-count')).toHaveTextContent('1 of 2') + const marks = document.body.querySelectorAll('mark') + expect(marks.length).toBe(2) + }) + + it('keeps Save disabled until the content changes', async () => { + const user = userEvent.setup() + render(, { wrapper: createWrapper() }) + const saveButton = await screen.findByRole('button', { name: 'Save' }) + expect(saveButton).toBeDisabled() + const textarea = screen.getByLabelText('AGENTS.md content') as HTMLTextAreaElement + await user.type(textarea, '!') + expect(saveButton).toBeEnabled() + }) + + it('saves the edited content', async () => { + const user = userEvent.setup() + const { settingsApi } = await import('@/api/settings') + render(, { wrapper: createWrapper() }) + const textarea = (await screen.findByLabelText('AGENTS.md content')) as HTMLTextAreaElement + const edited = '# Rules\n\nline two\nline three\n# new section\n' + fireEvent.change(textarea, { target: { value: edited } }) + const saveButton = screen.getByRole('button', { name: 'Save' }) + await user.click(saveButton) + await waitFor(() => expect(settingsApi.updateAgentsMd).toHaveBeenCalled()) + expect(settingsApi.updateAgentsMd).toHaveBeenCalledWith(edited) + }) + + it('uses a 16px editor font on mobile', async () => { + render(, { wrapper: createWrapper() }) + const textarea = await screen.findByLabelText('AGENTS.md content') + expect(textarea.className).toContain('text-[16px]') + expect(textarea.className).toContain('md:text-sm') + expect(textarea.className).not.toContain('text-xs') + }) + + it('locks the editor while a save is pending and preserves submitted content', async () => { + const user = userEvent.setup() + const { settingsApi } = await import('@/api/settings') + let resolveSave: () => void = () => {} + ;(settingsApi.updateAgentsMd as ReturnType).mockReturnValueOnce( + new Promise((resolve) => { + resolveSave = resolve + }), + ) + render(, { wrapper: createWrapper() }) + const textarea = (await screen.findByLabelText('AGENTS.md content')) as HTMLTextAreaElement + const edited = '# Rules\n\nline two\nline three\nedit\n' + fireEvent.change(textarea, { target: { value: edited } }) + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(textarea).toBeDisabled()) + try { + await user.type(textarea, 'lost edit') + } catch { + // user-event refuses to type into a disabled element; expected + } + expect(textarea).toHaveValue(edited) + resolveSave() + await waitFor(() => expect(textarea).not.toBeDisabled()) + expect(textarea).toHaveValue(edited) + }) + + it('preserves a post-save edit when a deferred refetch arrives with the saved value', async () => { + const user = userEvent.setup() + const { settingsApi } = await import('@/api/settings') + ;(settingsApi.getAgentsMd as ReturnType) + .mockResolvedValueOnce({ content: 'A' }) + .mockResolvedValueOnce({ content: 'B' }) + render(, { wrapper: createWrapper() }) + const textarea = (await screen.findByLabelText('AGENTS.md content')) as HTMLTextAreaElement + await waitFor(() => expect(textarea).toHaveValue('A')) + fireEvent.change(textarea, { target: { value: 'B' } }) + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(settingsApi.updateAgentsMd).toHaveBeenCalledWith('B')) + fireEvent.change(textarea, { target: { value: 'A' } }) + await waitFor(() => expect(settingsApi.getAgentsMd).toHaveBeenCalledTimes(2)) + expect(textarea).toHaveValue('A') + }) +}) diff --git a/frontend/src/components/settings/AgentsMdEditor.tsx b/frontend/src/components/settings/AgentsMdEditor.tsx index 3c7f9c386..8a318f501 100644 --- a/frontend/src/components/settings/AgentsMdEditor.tsx +++ b/frontend/src/components/settings/AgentsMdEditor.tsx @@ -1,15 +1,22 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Loader2, Save, RotateCcw } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Textarea } from '@/components/ui/textarea' +import { CodeEditor } from '@/components/ui/code-editor' +import { EditorFindBar } from '@/components/ui/editor-find-bar' +import { useFindInText } from '@/lib/useFindInText' import { settingsApi } from '@/api/settings' import { showToast } from '@/lib/toast' export function AgentsMdEditor() { const queryClient = useQueryClient() const [content, setContent] = useState('') - const [hasChanges, setHasChanges] = useState(false) + const [savedContent, setSavedContent] = useState('') + const hasChanges = content !== savedContent + const hasChangesRef = useRef(false) + hasChangesRef.current = hasChanges + const savedContentRef = useRef('') + savedContentRef.current = savedContent const { data, isLoading, error } = useQuery({ queryKey: ['agents-md'], @@ -17,18 +24,19 @@ export function AgentsMdEditor() { }) useEffect(() => { - if (data?.content !== undefined) { - setContent(data.content) - setHasChanges(false) - } + if (data?.content === undefined) return + if (hasChangesRef.current) return + if (data.content === savedContentRef.current) return + setContent(data.content) + setSavedContent(data.content) }, [data?.content]) const updateMutation = useMutation({ mutationFn: (newContent: string) => settingsApi.updateAgentsMd(newContent), - onSuccess: () => { + onSuccess: (_data, newContent) => { + setSavedContent(newContent) queryClient.invalidateQueries({ queryKey: ['agents-md'] }) queryClient.invalidateQueries({ queryKey: ['opencode', 'agents'] }) - setHasChanges(false) showToast.success('AGENTS.md saved and server restarted') }, onError: () => { @@ -45,7 +53,7 @@ export function AgentsMdEditor() { onSuccess: (defaultContent) => { queryClient.invalidateQueries({ queryKey: ['agents-md'] }) setContent(defaultContent) - setHasChanges(false) + setSavedContent(defaultContent) showToast.success('AGENTS.md reset to default and server restarted') }, onError: () => { @@ -53,9 +61,10 @@ export function AgentsMdEditor() { }, }) + const isSaving = updateMutation.isPending || resetToDefaultMutation.isPending + const handleContentChange = (value: string) => { setContent(value) - setHasChanges(value !== data?.content) } const handleSave = () => { @@ -66,6 +75,8 @@ export function AgentsMdEditor() { resetToDefaultMutation.mutate() } + const { query, setQuery, matches, currentMatchIndex, hasMatches, next, prev } = useFindInText(content) + if (isLoading) { return (
@@ -84,18 +95,19 @@ export function AgentsMdEditor() { return (
-
+

Global instructions for AI agents. This file is merged with repository-specific AGENTS.md files.

-
+
- -