diff --git a/AGENTS.md b/AGENTS.md index 26c34274..5f639690 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,6 +124,7 @@ 5. For responsive/mobile changes, run checks at 375x812 and 768x1024. 6. Wait 2-3 seconds before capturing final screenshot(s). 7. Save screenshots under `output/playwright/` with task-specific names. + 8. Leave the dev server running after verification unless the user explicitly asks to stop it. - Capture screenshots only when Playwright verification is requested. - If the dev server fails to start due to pre-existing errors, fix them first or work around them before testing. - If requested Playwright assertions fail, do not report completion; fix and re-run until passing. @@ -152,6 +153,7 @@ - For dev-server fixes, verify the exact user-requested command afterwards (for example `npm run dev`), not only a fallback Vite invocation. - Never kill or stop the tmux-managed dev server bound to port `5173`. - Treat the `5173` tmux dev process as persistent infrastructure; restart it only when the user explicitly requests a restart. +- Treat the `4173` verification dev server as reusable test infrastructure during active UI work; after tests or screenshots, leave it running unless the user explicitly asks to stop it. ## Dark Theme CSS Rule diff --git a/src/App.vue b/src/App.vue index 77a0b263..1087dc36 100644 --- a/src/App.vue +++ b/src/App.vue @@ -541,19 +541,25 @@ :head-date="currentThreadHeadDate" :detached="isThreadDetachedHead" :dirty="isThreadWorktreeDirty" + :worktree-change-summary="threadWorktreeChangeSummary" :branches="threadBranchOptions" :commits-by-branch="threadBranchCommitsByBranch" :commits-loading-for="threadBranchCommitsLoadingFor" :commits-error="threadBranchCommitsError" + :commit-files-by-sha="threadCommitFilesBySha" + :commit-files-loading-for="threadCommitFilesLoadingFor" + :commit-files-error="threadCommitFilesError" :loading="isLoadingThreadBranches" :busy="isSwitchingThreadBranch" :error="threadBranchError" :review-open="isReviewPaneOpen" :show-review="route.name === 'thread' && selectedThreadId.length > 0" - @toggle-review="isReviewPaneOpen = !isReviewPaneOpen" + @toggle-review="onToggleContentHeaderReview" @checkout-branch="onCheckoutContentHeaderBranch" @reset-branch-to-commit="onResetContentHeaderBranchToCommit" @load-commits="loadThreadBranchCommits" + @load-commit-files="loadThreadCommitFiles" + @open-commit-file="onOpenContentHeaderCommitFile" /> @@ -920,6 +926,8 @@ :thread-id="selectedThreadId" :cwd="composerCwd" :is-thread-in-progress="isSelectedThreadInProgress" + :initial-file-path="reviewInitialFilePath" + :commit-sha="reviewInitialCommitSha" @close="isReviewPaneOpen = false" /> @@ -1097,7 +1105,9 @@ import { createProjectlessThreadDirectory, getGitBranchState, getGitBranchCommits, + getGitCommitFiles, getGitRepositoryStatus, + getReviewSummary, getWorktreeBranchOptions, getAccounts, completeCodexLogin, @@ -1122,7 +1132,7 @@ import { } from './api/codexGateway' import type { ReasoningEffort, SpeedMode, UiAccountEntry, UiRateLimitWindow, UiServerRequest, UiServerRequestReply, UiThreadAutomation, UiThreadTokenUsage } from './types/codex' import type { ComposerDraftPayload, ThreadComposerExposed } from './components/content/ThreadComposer.vue' -import type { GitCommitOption, LocalDirectoryEntry, TelegramStatus, ThreadTerminalQuickCommand, WorktreeBranchOption } from './api/codexGateway' +import type { GitCommitFileChange, GitCommitOption, LocalDirectoryEntry, TelegramStatus, ThreadTerminalQuickCommand, WorktreeBranchOption } from './api/codexGateway' import { getFreeModeStatus, setFreeMode, setFreeModeCustomKey, setCustomProvider } from './api/codexGateway' import { getPathLeafName, getPathParent, isProjectlessChatPath, normalizePathForUi } from './pathUtils.js' @@ -1426,11 +1436,15 @@ let threadSearchTimer: ReturnType | null = null let terminalKeyboardFocusFallbackTimer: ReturnType | null = null let threadBranchesRequestId = 0 let threadBranchCommitsRequestId = 0 +let threadCommitFilesRequestId = 0 +let threadWorktreeSummaryRequestId = 0 const defaultNewProjectName = ref('New Project (1)') const homeDirectory = ref('') const isSettingsOpen = ref(false) const isAccountsSectionCollapsed = ref(loadAccountsSectionCollapsed()) const isReviewPaneOpen = ref(false) +const reviewInitialFilePath = ref('') +const reviewInitialCommitSha = ref('') const threadBranchOptions = ref([]) const currentThreadBranch = ref(null) const currentThreadHeadSha = ref(null) @@ -1438,12 +1452,21 @@ const currentThreadHeadSubject = ref(null) const currentThreadHeadDate = ref(null) const isThreadDetachedHead = ref(false) const isThreadWorktreeDirty = ref(false) +const threadWorktreeChangeSummary = ref({ addedLineCount: 0, removedLineCount: 0 }) const threadBranchError = ref('') const threadBranchCommitsByBranch = ref>({}) const threadBranchCommitsLoadingFor = ref('') const threadBranchCommitsError = ref('') +const threadCommitFilesBySha = ref>({}) +const threadCommitFilesLoadingFor = ref('') +const threadCommitFilesError = ref('') const isLoadingThreadBranches = ref(false) const isSwitchingThreadBranch = ref(false) + +function toThreadBranchCommitsKey(branch: string, includeResetHistory: boolean): string { + return `${branch}\u0000${includeResetHistory ? 'with-reset-history' : 'without-reset-history'}` +} + const createFolderInputRef = ref(null) const accounts = ref([]) const isRefreshingAccounts = ref(false) @@ -3085,6 +3108,8 @@ function canLoadBranchStateForCwd(cwd: string): boolean { function resetThreadBranchState(): void { threadBranchesRequestId += 1 threadBranchCommitsRequestId += 1 + threadCommitFilesRequestId += 1 + threadWorktreeSummaryRequestId += 1 threadBranchOptions.value = [] currentThreadBranch.value = null currentThreadHeadSha.value = null @@ -3092,13 +3117,38 @@ function resetThreadBranchState(): void { currentThreadHeadDate.value = null isThreadDetachedHead.value = false isThreadWorktreeDirty.value = false + threadWorktreeChangeSummary.value = { addedLineCount: 0, removedLineCount: 0 } threadBranchCommitsByBranch.value = {} threadBranchCommitsLoadingFor.value = '' threadBranchCommitsError.value = '' + threadCommitFilesBySha.value = {} + threadCommitFilesLoadingFor.value = '' + threadCommitFilesError.value = '' threadBranchError.value = '' isLoadingThreadBranches.value = false } +function loadThreadWorktreeChangeSummary(cwd: string): void { + const targetCwd = cwd.trim() + if (!targetCwd) { + threadWorktreeChangeSummary.value = { addedLineCount: 0, removedLineCount: 0 } + return + } + const requestId = ++threadWorktreeSummaryRequestId + void getReviewSummary(targetCwd, 'unstaged') + .then((summary) => { + if (requestId !== threadWorktreeSummaryRequestId || !canLoadBranchStateForCwd(targetCwd)) return + threadWorktreeChangeSummary.value = { + addedLineCount: summary.addedLineCount, + removedLineCount: summary.removedLineCount, + } + }) + .catch(() => { + if (requestId !== threadWorktreeSummaryRequestId || !canLoadBranchStateForCwd(targetCwd)) return + threadWorktreeChangeSummary.value = { addedLineCount: 0, removedLineCount: 0 } + }) +} + async function loadThreadBranches(cwd: string): Promise { const targetCwd = cwd.trim() if (!targetCwd) { @@ -3118,6 +3168,9 @@ async function loadThreadBranches(cwd: string): Promise { currentThreadHeadDate.value = state.headDate isThreadDetachedHead.value = state.detached isThreadWorktreeDirty.value = state.dirty + loadThreadWorktreeChangeSummary(targetCwd) + const defaultBranchForCommits = state.currentBranch?.trim() || state.options[0]?.value?.trim() || '' + if (defaultBranchForCommits) loadThreadBranchCommits({ branch: defaultBranchForCommits, includeResetHistory: true }) } catch { if (requestId !== threadBranchesRequestId || !canLoadBranchStateForCwd(targetCwd)) return threadBranchOptions.value = [] @@ -3127,6 +3180,7 @@ async function loadThreadBranches(cwd: string): Promise { currentThreadHeadDate.value = null isThreadDetachedHead.value = false isThreadWorktreeDirty.value = false + threadWorktreeChangeSummary.value = { addedLineCount: 0, removedLineCount: 0 } } finally { if (requestId === threadBranchesRequestId) { isLoadingThreadBranches.value = false @@ -3141,6 +3195,7 @@ function applyThreadGitState(state: { currentBranch: string | null; headSha: str currentThreadHeadDate.value = state.headDate isThreadDetachedHead.value = state.detached isThreadWorktreeDirty.value = state.dirty + loadThreadWorktreeChangeSummary(composerCwd.value) } function onCheckoutContentHeaderBranch(value: string): void { @@ -3198,20 +3253,22 @@ function onResetContentHeaderBranchToCommit(payload: { branch: string; sha: stri }) } -function loadThreadBranchCommits(branch: string): void { - const targetBranch = branch.trim() +function loadThreadBranchCommits(payload: string | { branch: string; includeResetHistory?: boolean }): void { + const targetBranch = (typeof payload === 'string' ? payload : payload.branch).trim() + const includeResetHistory = typeof payload === 'string' ? true : payload.includeResetHistory !== false const cwd = composerCwd.value.trim() - if (!targetBranch || !cwd || threadBranchCommitsLoadingFor.value === targetBranch) return - if (threadBranchCommitsByBranch.value[targetBranch]) return + const cacheKey = toThreadBranchCommitsKey(targetBranch, includeResetHistory) + if (!targetBranch || !cwd || threadBranchCommitsLoadingFor.value === cacheKey) return + if (threadBranchCommitsByBranch.value[cacheKey]) return const requestId = ++threadBranchCommitsRequestId - threadBranchCommitsLoadingFor.value = targetBranch + threadBranchCommitsLoadingFor.value = cacheKey threadBranchCommitsError.value = '' - void getGitBranchCommits(cwd, targetBranch) + void getGitBranchCommits(cwd, targetBranch, { includeResetHistory }) .then((commits) => { if (requestId !== threadBranchCommitsRequestId || !canLoadBranchStateForCwd(cwd)) return threadBranchCommitsByBranch.value = { ...threadBranchCommitsByBranch.value, - [targetBranch]: commits, + [cacheKey]: commits, } }) .catch((error: unknown) => { @@ -3219,12 +3276,54 @@ function loadThreadBranchCommits(branch: string): void { threadBranchCommitsError.value = error instanceof Error ? error.message : 'Failed to load branch commits' }) .finally(() => { - if (requestId === threadBranchCommitsRequestId && threadBranchCommitsLoadingFor.value === targetBranch) { + if (requestId === threadBranchCommitsRequestId && threadBranchCommitsLoadingFor.value === cacheKey) { threadBranchCommitsLoadingFor.value = '' } }) } +function loadThreadCommitFiles(sha: string): void { + const targetSha = sha.trim() + const cwd = composerCwd.value.trim() + if (!targetSha || !cwd || threadCommitFilesLoadingFor.value === targetSha) return + if (threadCommitFilesBySha.value[targetSha]) return + const requestId = ++threadCommitFilesRequestId + threadCommitFilesLoadingFor.value = targetSha + threadCommitFilesError.value = '' + void getGitCommitFiles(cwd, targetSha) + .then((files) => { + if (requestId !== threadCommitFilesRequestId || !canLoadBranchStateForCwd(cwd)) return + threadCommitFilesBySha.value = { + ...threadCommitFilesBySha.value, + [targetSha]: files, + } + }) + .catch((error: unknown) => { + if (requestId !== threadCommitFilesRequestId || !canLoadBranchStateForCwd(cwd)) return + threadCommitFilesError.value = error instanceof Error ? error.message : 'Failed to load commit files' + }) + .finally(() => { + if (requestId === threadCommitFilesRequestId && threadCommitFilesLoadingFor.value === targetSha) { + threadCommitFilesLoadingFor.value = '' + } + }) +} + +function onOpenContentHeaderCommitFile(payload: { sha: string; path: string }): void { + const targetPath = payload.path.trim() + const targetSha = payload.sha.trim() + if (!targetPath || !targetSha) return + reviewInitialFilePath.value = targetPath + reviewInitialCommitSha.value = targetSha + isReviewPaneOpen.value = true +} + +function onToggleContentHeaderReview(): void { + reviewInitialFilePath.value = '' + reviewInitialCommitSha.value = '' + isReviewPaneOpen.value = !isReviewPaneOpen.value +} + async function onOpenProjectSetupModal(): Promise { const baseDir = await resolveProjectBaseDirectory() if (!baseDir) return diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts index 25a762d1..b54596da 100644 --- a/src/api/codexGateway.ts +++ b/src/api/codexGateway.ts @@ -49,6 +49,7 @@ import type { UiReviewResult, UiReviewScope, UiReviewSnapshot, + UiReviewSummary, UiReviewWorkspaceView, UiRateLimitSnapshot, UiRateLimitWindow, @@ -332,6 +333,15 @@ export type GitCommitOption = { date: string } +export type GitCommitFileChange = { + path: string + previousPath: string | null + status: string + label: string + addedLineCount: number | null + removedLineCount: number | null +} + export type GitRepositoryStatus = { isGitRepo: boolean gitRoot: string @@ -937,7 +947,8 @@ function normalizeReviewSnapshot(payload: unknown): UiReviewSnapshot { const envelope = asRecord(payload) const data = asRecord(envelope?.data) const summaryRecord = asRecord(data?.summary) - const scope = readString(data?.scope) === 'baseBranch' ? 'baseBranch' : 'workspace' + const rawScope = readString(data?.scope) + const scope = rawScope === 'baseBranch' || rawScope === 'commit' ? rawScope : 'workspace' const workspaceView = readString(data?.workspaceView) === 'staged' ? 'staged' : 'unstaged' return { @@ -952,6 +963,7 @@ function normalizeReviewSnapshot(payload: unknown): UiReviewSnapshot { .map((entry) => readString(entry)) .filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) : [], + commitSha: readString(data?.commitSha), headBranch: readString(data?.headBranch), mergeBaseSha: readString(data?.mergeBaseSha), generatedAtIso: readString(data?.generatedAtIso) ?? '', @@ -968,6 +980,16 @@ function normalizeReviewSnapshot(payload: unknown): UiReviewSnapshot { } } +function normalizeReviewSummary(payload: unknown): UiReviewSummary { + const envelope = asRecord(payload) + const data = asRecord(envelope?.data) + return { + fileCount: readNumber(data?.fileCount) ?? 0, + addedLineCount: readNumber(data?.addedLineCount) ?? 0, + removedLineCount: readNumber(data?.removedLineCount) ?? 0, + } +} + function parseReviewLocation(value: string): { absolutePath: string | null startLine: number | null @@ -2673,11 +2695,15 @@ export async function checkoutGitBranch(cwd: string, branch: string): Promise { +export async function getGitBranchCommits(cwd: string, branch: string, options: { includeResetHistory?: boolean } = {}): Promise { const normalizedCwd = cwd.trim() const normalizedBranch = branch.trim() if (!normalizedCwd || !normalizedBranch) return [] - const query = new URLSearchParams({ cwd: normalizedCwd, branch: normalizedBranch }) + const query = new URLSearchParams({ + cwd: normalizedCwd, + branch: normalizedBranch, + includeResetHistory: options.includeResetHistory === false ? 'false' : 'true', + }) const response = await fetch(`/codex-api/git/branch-commits?${query.toString()}`) const payload = (await response.json()) as { data?: unknown; error?: string } if (!response.ok) { @@ -2696,6 +2722,34 @@ export async function getGitBranchCommits(cwd: string, branch: string): Promise< }) } +export async function getGitCommitFiles(cwd: string, sha: string): Promise { + const normalizedCwd = cwd.trim() + const normalizedSha = sha.trim() + if (!normalizedCwd || !normalizedSha) return [] + const query = new URLSearchParams({ + cwd: normalizedCwd, + sha: normalizedSha, + }) + const response = await fetch(`/codex-api/git/commit-files?${query.toString()}`) + const payload = (await response.json()) as { data?: unknown; error?: string } + if (!response.ok) { + throw new Error(payload.error || 'Failed to load commit files') + } + const rawList = Array.isArray(payload.data) ? payload.data : [] + return rawList.flatMap((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return [] + const record = item as Record + const path = typeof record.path === 'string' ? record.path : '' + const previousPath = typeof record.previousPath === 'string' && record.previousPath.length > 0 ? record.previousPath : null + const status = typeof record.status === 'string' ? record.status.trim() : '' + const label = typeof record.label === 'string' ? record.label.trim() : '' + const addedLineCount = typeof record.addedLineCount === 'number' && Number.isFinite(record.addedLineCount) ? record.addedLineCount : null + const removedLineCount = typeof record.removedLineCount === 'number' && Number.isFinite(record.removedLineCount) ? record.removedLineCount : null + if (!path || !status) return [] + return [{ path, previousPath, status, label: label || status, addedLineCount, removedLineCount }] + }) +} + export async function resetGitBranchToCommit(cwd: string, branch: string, sha: string): Promise { const response = await fetch('/codex-api/git/reset-to-commit', { method: 'POST', @@ -2730,11 +2784,15 @@ export async function getReviewSnapshot( scope: UiReviewScope, workspaceView: UiReviewWorkspaceView, baseBranch?: string | null, + commitSha?: string | null, ): Promise { const query = new URLSearchParams({ cwd, scope, workspaceView }) if (baseBranch && baseBranch.trim()) { query.set('baseBranch', baseBranch.trim()) } + if (commitSha && commitSha.trim()) { + query.set('commitSha', commitSha.trim()) + } const response = await fetch(`/codex-api/review/snapshot?${query.toString()}`) const payload = (await response.json()) as unknown if (!response.ok) { @@ -2743,6 +2801,19 @@ export async function getReviewSnapshot( return normalizeReviewSnapshot(payload) } +export async function getReviewSummary( + cwd: string, + workspaceView: UiReviewWorkspaceView, +): Promise { + const query = new URLSearchParams({ cwd, workspaceView }) + const response = await fetch(`/codex-api/review/summary?${query.toString()}`) + const payload = (await response.json()) as unknown + if (!response.ok) { + throw new Error(getErrorMessageFromPayload(payload, 'Failed to load review summary')) + } + return normalizeReviewSummary(payload) +} + export async function applyReviewAction(payload: { cwd: string scope: UiReviewScope diff --git a/src/components/content/ContentHeader.vue b/src/components/content/ContentHeader.vue index 848ce5ba..68390ed2 100644 --- a/src/components/content/ContentHeader.vue +++ b/src/components/content/ContentHeader.vue @@ -21,7 +21,7 @@ defineProps<{ @reference "tailwindcss"; .content-header { - @apply relative z-10 w-full min-w-0 min-h-12 sm:min-h-14 flex items-center gap-2 sm:gap-3 px-2 sm:px-3 pt-3 sm:pt-4 pb-2 bg-white; + @apply relative z-[250] w-full min-w-0 min-h-12 sm:min-h-14 flex items-center gap-2 sm:gap-3 px-2 sm:px-3 pt-3 sm:pt-4 pb-2 bg-white; } .content-title { diff --git a/src/components/content/HeaderGitBranchDropdown.vue b/src/components/content/HeaderGitBranchDropdown.vue index b605f87b..1d7cac3a 100644 --- a/src/components/content/HeaderGitBranchDropdown.vue +++ b/src/components/content/HeaderGitBranchDropdown.vue @@ -15,89 +15,172 @@
-
-
{{ detached ? 'Detached HEAD' : 'Current branch' }} {{ displayLabel }} - {{ detachedCommitMeta }} + {{ currentCommitSummary }}
{{ statusMessage }}
-
- -
- -
    -
  • -
    - +
    +
    +
    +
    + + {{ selectedCommit.date }} +
    +

    {{ selectedCommit.subject }}

    -
    -
    Loading commits...
    +
    +
    Loading files...
    +
    {{ commitFilesError }}
    + +
    +
    + +
    +
    + +
    + +
    +
    Select a branch.
    +
    Loading commits...
    {{ commitsError }}
    -
    +
    + +
    +
    +
    -
  • -
  • No branches found.
  • -
+ +
    +
  • +
    + + +
    +
  • +
  • No branches found.
  • +
+ +
@@ -105,9 +188,8 @@