From 676d0831b518f6121001d48055b1ba527eff6368 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 11 May 2026 06:49:19 +0700 Subject: [PATCH 01/27] Refine header git commit browser --- src/App.vue | 19 +- src/api/codexGateway.ts | 8 +- .../content/HeaderGitBranchDropdown.vue | 313 ++++++++++++------ src/server/codexAppServerBridge.ts | 66 +++- src/style.css | 11 +- tests.md | 48 +-- 6 files changed, 331 insertions(+), 134 deletions(-) diff --git a/src/App.vue b/src/App.vue index 77a0b263..7a2c7cc1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1444,6 +1444,11 @@ const threadBranchCommitsLoadingFor = ref('') const threadBranchCommitsError = 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) @@ -3118,6 +3123,8 @@ async function loadThreadBranches(cwd: string): Promise { currentThreadHeadDate.value = state.headDate isThreadDetachedHead.value = state.detached isThreadWorktreeDirty.value = state.dirty + 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 = [] @@ -3198,20 +3205,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 (threadBranchCommitsByBranch.value[cacheKey]) return const requestId = ++threadBranchCommitsRequestId threadBranchCommitsLoadingFor.value = targetBranch 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) => { diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts index 25a762d1..5cabdd2e 100644 --- a/src/api/codexGateway.ts +++ b/src/api/codexGateway.ts @@ -2673,11 +2673,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) { diff --git a/src/components/content/HeaderGitBranchDropdown.vue b/src/components/content/HeaderGitBranchDropdown.vue index b605f87b..92e27258 100644 --- a/src/components/content/HeaderGitBranchDropdown.vue +++ b/src/components/content/HeaderGitBranchDropdown.vue @@ -24,80 +24,98 @@
{{ detached ? 'Detached HEAD' : 'Current branch' }} {{ displayLabel }} - {{ detachedCommitMeta }} + {{ currentCommitSummary }}
{{ statusMessage }}
-
- -
- -
    -
  • -
    - - +
    +
    +
    +
    - -
    -
    Loading commits...
    + +
    +
    Select a branch.
    +
    Loading commits...
    {{ commitsError }}
    -
    -
  • -
  • No branches found.
  • -
+ + +
+
+ +
+ +
    +
  • +
    + + +
    +
  • +
  • No branches found.
  • +
+
+ @@ -107,7 +125,6 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import type { GitCommitOption, WorktreeBranchOption } from '../../api/codexGateway' import IconTablerChevronDown from '../icons/IconTablerChevronDown.vue' -import IconTablerChevronRight from '../icons/IconTablerChevronRight.vue' import IconTablerFilePencil from '../icons/IconTablerFilePencil.vue' import IconTablerGitFork from '../icons/IconTablerGitFork.vue' @@ -133,14 +150,17 @@ const emit = defineEmits<{ toggleReview: [] checkoutBranch: [branch: string] resetBranchToCommit: [payload: { branch: string; sha: string }] - loadCommits: [branch: string] + loadCommits: [payload: { branch: string; includeResetHistory: boolean }] }>() const rootRef = ref(null) const searchInputRef = ref(null) const isOpen = ref(false) const searchQuery = ref('') -const expandedBranch = ref('') +const commitSearchQuery = ref('') +const selectedBranch = ref('') +const lastCurrentBranch = ref('') +const showResetHistoryRefs = ref(true) const showReview = computed(() => props.showReview !== false) const displayLabel = computed(() => { @@ -149,9 +169,11 @@ const displayLabel = computed(() => { if (props.headSha) return `Detached ${props.headSha}` return props.loading ? 'Loading branch...' : 'Detached HEAD' }) -const detachedCommitMeta = computed(() => { - if (!props.detached) return '' - return [props.headSha, props.headDate].filter(Boolean).join(' · ') +const currentCommitSummary = computed(() => { + const details = [props.headSha, props.headDate].filter(Boolean).join(' · ') + const subject = props.headSubject?.trim() ?? '' + if (subject && details) return `${subject} (${details})` + return subject || details }) const triggerLabel = computed(() => `Git branch: ${displayLabel.value}`) const disabled = computed(() => props.loading && props.branches.length === 0) @@ -160,19 +182,44 @@ const statusMessage = computed(() => props.error || (props.dirty ? 'Tracked chan const statusKind = computed(() => props.error ? 'error' : 'info') const filteredBranches = computed(() => { const query = searchQuery.value.trim().toLowerCase() - const branches = props.branches + const branches = props.branches.filter((branch) => branch.isRemote !== true) if (!query) return branches return branches.filter((branch) => branch.label.toLowerCase().includes(query) || branch.value.toLowerCase().includes(query)) }) +const selectedBranchOption = computed(() => props.branches.find((branch) => branch.value === selectedBranch.value) ?? null) +const selectedBranchIsRemote = computed(() => selectedBranchOption.value?.isRemote === true) +const selectedBranchCommitsKey = computed(() => { + if (!selectedBranch.value) return '' + return `${selectedBranch.value}\u0000${showResetHistoryRefs.value ? 'with-reset-history' : 'without-reset-history'}` +}) +const selectedBranchCommits = computed(() => selectedBranchCommitsKey.value ? props.commitsByBranch[selectedBranchCommitsKey.value] || [] : []) +const filteredSelectedBranchCommits = computed(() => { + const query = commitSearchQuery.value.trim().toLowerCase() + const commits = selectedBranchCommits.value + if (!query) return commits + return commits.filter((commit) => { + return ( + commit.sha.toLowerCase().includes(query) || + commit.shortSha.toLowerCase().includes(query) || + commit.subject.toLowerCase().includes(query) || + commit.date.toLowerCase().includes(query) + ) + }) +}) function toggleOpen(): void { if (disabled.value) return isOpen.value = !isOpen.value } -function toggleBranchCommits(branch: string): void { - expandedBranch.value = expandedBranch.value === branch ? '' : branch - if (expandedBranch.value) emit('loadCommits', branch) +function selectBranch(branch: string): void { + selectedBranch.value = branch + emit('loadCommits', { branch, includeResetHistory: showResetHistoryRefs.value }) +} + +function reloadSelectedBranchCommits(): void { + if (!selectedBranch.value) return + emit('loadCommits', { branch: selectedBranch.value, includeResetHistory: showResetHistoryRefs.value }) } function isCurrentCommit(commit: GitCommitOption): boolean { @@ -181,14 +228,14 @@ function isCurrentCommit(commit: GitCommitOption): boolean { return commit.sha === headSha || commit.shortSha === headSha || commit.sha.startsWith(headSha) } -function commitActionTitle(branch: WorktreeBranchOption, commit: GitCommitOption): string { - if (branch.isRemote) return 'Remote branches cannot be reset from this menu' - return `Reset ${branch.value} to ${commit.shortSha}` +function selectedBranchCommitActionTitle(commit: GitCommitOption): string { + if (selectedBranchIsRemote.value) return 'Remote branches cannot be reset from this menu' + return `Reset ${selectedBranch.value} to ${commit.shortSha}` } -function onSelectCommit(branch: WorktreeBranchOption, commit: GitCommitOption): void { - if (branch.isRemote) return - emit('resetBranchToCommit', { branch: branch.value, sha: commit.sha }) +function onSelectCommit(commit: GitCommitOption): void { + if (!selectedBranch.value || selectedBranchIsRemote.value) return + emit('resetBranchToCommit', { branch: selectedBranch.value, sha: commit.sha }) } function onEscapeSearch(): void { @@ -199,6 +246,14 @@ function onEscapeSearch(): void { isOpen.value = false } +function onEscapeCommitSearch(): void { + if (commitSearchQuery.value) { + commitSearchQuery.value = '' + return + } + isOpen.value = false +} + function onDocumentPointerDown(event: PointerEvent): void { if (!isOpen.value) return const root = rootRef.value @@ -206,12 +261,50 @@ function onDocumentPointerDown(event: PointerEvent): void { if (!root || !(target instanceof Node) || root.contains(target)) return isOpen.value = false searchQuery.value = '' + commitSearchQuery.value = '' +} + +function preferredBranch(): string { + const currentBranch = props.currentBranch?.trim() + if (currentBranch) return currentBranch + return props.branches[0]?.value ?? '' +} + +function ensureSelectedBranchCommits(): void { + const targetBranch = selectedBranch.value || preferredBranch() + if (!targetBranch) return + selectedBranch.value = targetBranch + emit('loadCommits', { branch: targetBranch, includeResetHistory: showResetHistoryRefs.value }) } watch(isOpen, (open) => { - if (open) void nextTick(() => searchInputRef.value?.focus()) + if (!open) return + ensureSelectedBranchCommits() + void nextTick(() => searchInputRef.value?.focus()) }) +watch( + () => [props.currentBranch, props.branches.map((branch) => branch.value).join('\n')] as const, + () => { + const targetBranch = preferredBranch() + if (!targetBranch) { + selectedBranch.value = '' + lastCurrentBranch.value = '' + return + } + const currentBranch = props.currentBranch?.trim() ?? '' + const currentBranchChanged = currentBranch !== lastCurrentBranch.value + lastCurrentBranch.value = currentBranch + if (currentBranchChanged || !selectedBranch.value || !props.branches.some((branch) => branch.value === selectedBranch.value)) { + selectedBranch.value = targetBranch + } + if (isOpen.value && selectedBranch.value) { + emit('loadCommits', { branch: selectedBranch.value, includeResetHistory: showResetHistoryRefs.value }) + } + }, + { immediate: true }, +) + onMounted(() => window.addEventListener('pointerdown', onDocumentPointerDown)) onBeforeUnmount(() => window.removeEventListener('pointerdown', onDocumentPointerDown)) @@ -250,11 +343,12 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onDocumentPointe } .header-git-menu { - @apply w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border border-zinc-200 bg-white p-1 shadow-lg; + @apply w-[42rem] max-w-[calc(100vw-1.5rem)] rounded-xl border border-zinc-200 bg-white p-1 shadow-lg; } .header-git-review-row, .header-git-branch-button, +.header-git-branch-checkout, .header-git-commit { @apply flex w-full border-0 bg-transparent text-left transition; } @@ -295,6 +389,27 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onDocumentPointe @apply w-full rounded-md border border-zinc-200 bg-white px-2 py-1.5 text-xs text-zinc-800 outline-none transition focus:border-zinc-400; } +.header-git-toggle-row { + @apply mx-1 mb-1 flex items-center gap-2 rounded-md px-1 py-1 text-xs text-zinc-500; +} + +.header-git-toggle-row input { + @apply h-3.5 w-3.5 shrink-0; +} + +.header-git-columns { + @apply grid min-h-80 grid-cols-[minmax(0,1.15fr)_minmax(13rem,0.85fr)] gap-1; +} + +.header-git-commit-panel, +.header-git-branch-panel { + @apply min-w-0 rounded-lg border border-zinc-100 bg-zinc-50 p-1; +} + +.header-git-commit-list { + @apply max-h-80 overflow-y-auto; +} + .header-git-branches { @apply m-0 max-h-80 list-none overflow-y-auto p-0; } @@ -307,24 +422,21 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onDocumentPointe @apply flex items-stretch gap-1; } -.header-git-branch-expand { - @apply flex w-7 shrink-0 items-center justify-center rounded-lg border-0 bg-transparent text-zinc-500 transition hover:bg-zinc-100; -} - -.header-git-expand-icon { - @apply h-4 w-4 transition-transform; +.header-git-branch-button { + @apply min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-sm text-zinc-700 hover:bg-zinc-100 disabled:cursor-wait; } -.header-git-expand-icon.is-expanded { - @apply rotate-90; +.header-git-branch-button.is-current, +.header-git-branch-button.is-selected { + @apply bg-zinc-100 text-zinc-950; } -.header-git-branch-button { - @apply min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-sm text-zinc-700 hover:bg-zinc-100 disabled:cursor-wait; +.header-git-branch-button.is-selected { + @apply ring-1 ring-zinc-300; } -.header-git-branch-button.is-current { - @apply bg-zinc-100 text-zinc-950; +.header-git-branch-checkout { + @apply w-auto shrink-0 items-center rounded-lg px-2 py-1.5 text-xs text-zinc-500 hover:bg-zinc-100 hover:text-zinc-800 disabled:cursor-wait; } .header-git-branch-name, @@ -337,7 +449,7 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onDocumentPointe } .header-git-commits { - @apply ml-8 mr-1 mb-1 rounded-lg border border-zinc-100 bg-zinc-50 p-1; + @apply rounded-lg border border-zinc-100 bg-zinc-50 p-1; } .header-git-commit { @@ -368,4 +480,15 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onDocumentPointe .header-git-commits-empty.is-error { @apply text-red-700; } + +@media (max-width: 640px) { + .header-git-columns { + @apply grid-cols-1; + } + + .header-git-commit-list, + .header-git-branches { + @apply max-h-56; + } +} diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 0dde66bb..de5d5c90 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -2836,12 +2836,49 @@ function toHeaderGitResetHistoryRef(branchName: string, commitSha: string): stri } const HEADER_GIT_RESET_HISTORY_REF_LIMIT = 25 +const HEADER_GIT_UNTRACKED_BACKUP_DIR = '.codex/untracked-backups' async function assertLocalGitBranch(repoRoot: string, branchName: string): Promise { await runCommandCapture('git', ['show-ref', '--verify', `refs/heads/${branchName}`], { cwd: repoRoot }) } +function splitGitPathList(raw: string): string[] { + return raw + .split('\0') + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function isSafeGitRelativePath(filePath: string): boolean { + return Boolean(filePath) && !isAbsolute(filePath) && !filePath.split('/').includes('..') +} + +function resolveGitRelativePath(repoRoot: string, filePath: string): string { + return join(repoRoot, ...filePath.split('/')) +} + +async function preserveUntrackedFilesForGitTarget(repoRoot: string, targetRef: string): Promise { + const [untrackedRaw, targetTreeRaw] = await Promise.all([ + runCommandCapture('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: repoRoot }), + runCommandCapture('git', ['ls-tree', '-r', '--name-only', '-z', `${targetRef}^{tree}`], { cwd: repoRoot }), + ]) + const targetPaths = new Set(splitGitPathList(targetTreeRaw)) + const conflictingUntrackedPaths = splitGitPathList(untrackedRaw) + .filter((filePath) => targetPaths.has(filePath) && isSafeGitRelativePath(filePath)) + if (conflictingUntrackedPaths.length === 0) return [] + + const backupRoot = join(repoRoot, HEADER_GIT_UNTRACKED_BACKUP_DIR, new Date().toISOString().replace(/[:.]/g, '-')) + for (const filePath of conflictingUntrackedPaths) { + const sourcePath = resolveGitRelativePath(repoRoot, filePath) + const backupPath = join(backupRoot, ...filePath.split('/')) + await mkdir(dirname(backupPath), { recursive: true }) + await rename(sourcePath, backupPath) + } + return conflictingUntrackedPaths +} + async function checkoutGitBranchWithWorktreeRecovery(repoRoot: string, branchName: string): Promise { + await preserveUntrackedFilesForGitTarget(repoRoot, branchName) try { await runCommand('git', ['checkout', branchName], { cwd: repoRoot }) } catch (checkoutError) { @@ -6921,6 +6958,7 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { if (req.method === 'GET' && url.pathname === '/codex-api/git/branch-commits') { const rawCwd = (url.searchParams.get('cwd') ?? '').trim() const branch = (url.searchParams.get('branch') ?? '').trim() + const includeResetHistory = url.searchParams.get('includeResetHistory') !== 'false' if (!rawCwd) { setJson(res, 400, { error: 'Missing cwd' }) return @@ -6933,20 +6971,23 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { try { const gitRoot = await runCommandCapture('git', ['rev-parse', '--show-toplevel'], { cwd }) await runCommandCapture('git', ['rev-parse', '--verify', `${branch}^{commit}`], { cwd: gitRoot }) - const resetHistoryRefPrefix = `refs/codex/header-git-reset-history/${branch}/` - const resetHistoryRefsRaw = await runCommandCapture( - 'git', - ['for-each-ref', '--sort=-creatordate', '--format=%(refname)', resetHistoryRefPrefix], - { cwd: gitRoot }, - ).catch(() => '') - const resetHistoryRefs = resetHistoryRefsRaw - .split('\n') - .map((entry) => entry.trim()) - .filter(Boolean) - .slice(0, HEADER_GIT_RESET_HISTORY_REF_LIMIT) + let resetHistoryRefs: string[] = [] + if (includeResetHistory) { + const resetHistoryRefPrefix = `refs/codex/header-git-reset-history/${branch}/` + const resetHistoryRefsRaw = await runCommandCapture( + 'git', + ['for-each-ref', '--sort=-creatordate', '--format=%(refname)', resetHistoryRefPrefix], + { cwd: gitRoot }, + ).catch(() => '') + resetHistoryRefs = resetHistoryRefsRaw + .split('\n') + .map((entry) => entry.trim()) + .filter(Boolean) + .slice(0, HEADER_GIT_RESET_HISTORY_REF_LIMIT) + } const output = await runCommandCapture( 'git', - ['log', '-n', '12', '--date=short', '--format=%H%x09%h%x09%cd%x09%s', branch, ...resetHistoryRefs], + ['log', '-n', '50', '--date=short', '--format=%H%x09%h%x09%cd%x09%s', branch, ...resetHistoryRefs], { cwd: gitRoot }, ) const commits = output.split('\n').flatMap((line) => { @@ -7000,6 +7041,7 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { const targetSha = await runCommandCapture('git', ['rev-parse', '--verify', `${sha}^{commit}`], { cwd: gitRoot }) await runCommand('git', ['update-ref', toHeaderGitResetHistoryRef(branch, previousTip.trim()), previousTip.trim()], { cwd: gitRoot }) await pruneHeaderGitResetHistoryRefs(gitRoot, branch) + await preserveUntrackedFilesForGitTarget(gitRoot, targetSha.trim()) await runCommand('git', ['reset', '--hard', targetSha.trim()], { cwd: gitRoot }) setJson(res, 200, { data: await readGitHeaderState(gitRoot) }) } catch (error) { diff --git a/src/style.css b/src/style.css index b900cd8d..963eadd3 100644 --- a/src/style.css +++ b/src/style.css @@ -893,24 +893,32 @@ :root.dark .header-git-review-row, :root.dark .header-git-branch-button, +:root.dark .header-git-branch-checkout, :root.dark .header-git-commit { @apply text-zinc-200; } :root.dark .header-git-review-row:hover, -:root.dark .header-git-branch-expand:hover, :root.dark .header-git-branch-button:hover, +:root.dark .header-git-branch-checkout:hover, :root.dark .header-git-commit:hover, :root.dark .header-git-commit.is-current, +:root.dark .header-git-branch-button.is-selected, :root.dark .header-git-branch-button.is-current { @apply bg-zinc-800; } +:root.dark .header-git-branch-button.is-selected { + @apply ring-zinc-600; +} + :root.dark .header-git-commit.is-current { @apply ring-zinc-600; } :root.dark .header-git-state, +:root.dark .header-git-commit-panel, +:root.dark .header-git-branch-panel, :root.dark .header-git-commits { @apply border-zinc-700 bg-zinc-800; } @@ -921,6 +929,7 @@ :root.dark .header-git-state-label, :root.dark .header-git-state-meta, +:root.dark .header-git-toggle-row, :root.dark .header-git-branch-meta, :root.dark .header-git-commit-top, :root.dark .header-git-empty, diff --git a/tests.md b/tests.md index 7c59f535..deedb456 100644 --- a/tests.md +++ b/tests.md @@ -773,7 +773,7 @@ Skills Sync skips unchanged manifest writes and does not fail parent commits whe ### Header Git branch dropdown with commit reset #### Feature/Change Name -Thread header Git dropdown replaces the simple review action with branch search, Review access, safe branch switching, branch reset-to-commit, and reset-history commit preservation. +Thread header Git dropdown replaces the simple review action with a two-column commits/branches picker, Review access, safe branch switching, branch reset-to-commit, and reset-history commit preservation. #### Prerequisites/Setup 1. Dev server running (`pnpm run dev`) @@ -785,27 +785,37 @@ Thread header Git dropdown replaces the simple review action with branch search, #### Steps 1. In light theme, open the Git dropdown in the thread header. 2. Confirm the trigger shows the current branch, or the detached commit subject if the repository is already detached. -3. Click `Review` and confirm the review pane opens; click it again and confirm the pane toggles. -4. Type part of a branch name in search and confirm the branch list filters. -5. Select a different branch with a clean worktree and confirm the header updates to that branch. -6. Expand a branch row and confirm recent commits load with short SHA, subject, and date. -7. Expand a remote branch row and confirm its commit rows are disabled with a tooltip explaining remote branches cannot be reset. -8. Select an older commit on the disposable local branch and confirm the header stays on that branch instead of entering detached HEAD. -9. Confirm `git -C rev-parse --abbrev-ref HEAD` still prints the branch name and `git -C rev-parse --short HEAD` matches the selected commit. -10. Reopen/expand the same branch and confirm commits that were ahead of the reset target still appear, with the selected branch HEAD marked `current`. -11. Repeat reset on the same branch several times and confirm the dropdown still opens quickly and shows recent reset-history commits. -12. Create a tracked uncommitted change, try to switch branch or reset to a commit, and confirm the dropdown shows a dirty-worktree error instead of switching or resetting. -13. Create only an untracked file, try to reset to a commit, and confirm the reset proceeds unless Git reports the untracked file would be overwritten. -14. Switch to dark theme and repeat steps 1, 2, 4, 6, 7, 10, 12, and 13. - -#### Expected Results -- The header dropdown exposes Review, current checkout state, searchable branches, and inline commits. -- Branch switching and branch reset-to-commit are blocked by tracked uncommitted changes, but untracked-only changes are allowed unless Git would overwrite them. +3. Confirm the menu shows commits in the left column and branches in the right column. +4. Confirm the left column defaults to the current branch and shows no more than 50 recent commits with short SHA, subject, and date. +5. Click `Review` and confirm the review pane opens; click it again and confirm the pane toggles. +6. Type part of a commit subject or short SHA in the left commit search and confirm the commit list filters. +7. Turn off `Reset-history refs` and confirm the commit list reloads without saved reset-history refs. +8. Turn `Reset-history refs` back on and confirm saved reset-history commits reappear when available. +9. Type part of a branch name in search and confirm the right branch list filters. +10. Click a different branch row and confirm the left commit list changes to that branch without immediately switching checkout. +11. Use the branch row `Checkout` action with a clean worktree and confirm the header updates to that branch. +12. Confirm remote branches are hidden from the branch list. +13. Select an older commit on the disposable local branch and confirm the header stays on that branch instead of entering detached HEAD. +14. Confirm `git -C rev-parse --abbrev-ref HEAD` still prints the branch name and `git -C rev-parse --short HEAD` matches the selected commit. +15. Reopen/select the same branch and confirm commits that were ahead of the reset target still appear, with the selected branch HEAD marked `current`. +16. Repeat reset on the same branch several times and confirm the dropdown still opens quickly and shows recent reset-history commits. +17. Create a tracked uncommitted change, try to switch branch or reset to a commit, and confirm the dropdown shows a dirty-worktree error instead of switching or resetting. +18. Create only an untracked file whose path does not exist in the target commit, try to reset to a commit, and confirm the reset proceeds while the untracked file remains in place. +19. Create only an untracked file whose path exists in the target commit, try to reset to that target, and confirm the reset proceeds and the untracked file is moved under `.codex/untracked-backups/` instead of being overwritten. +20. Switch to dark theme and repeat steps 1, 2, 3, 4, 6, 7, 8, 9, 12, 15, 17, 18, and 19. + +#### Expected Results +- The header dropdown exposes Review, current checkout state, a left-side commit list, and a right-side searchable branch list. +- The current branch commit list loads by default and is capped at 50 commits. +- The commit list can be searched by SHA, subject, or date without changing the selected branch. +- Reset-history refs can be shown or hidden from the commit list without changing the selected branch. +- Branch switching and branch reset-to-commit are blocked by tracked uncommitted changes, but untracked-only changes are preserved and allowed. - Commit selection resets the local branch to that commit instead of detaching HEAD. -- Remote branch commit rows are inspectable but cannot trigger local branch reset. +- Remote branches are hidden from the branch list. - The branch commit list still shows commits that were ahead of the reset target by reading saved internal reset-history refs. - Reset-history refs are bounded so repeated resets do not grow commit-list inputs without limit. -- The selected branch HEAD commit is marked `current` in expanded commit lists. +- Untracked files that would collide with target tracked files are moved to `.codex/untracked-backups/` before checkout/reset. +- The selected branch HEAD commit is marked `current` in the commit list. - Loading and error messages remain visible in the dropdown without using browser alerts. - Dropdown surfaces, text, badges, and errors are readable in both light theme and dark theme. From c95db85487dabbd88851e7a363df15c24bbf4713 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 11 May 2026 08:02:40 +0700 Subject: [PATCH 02/27] Add commit detail panel to git dropdown --- src/App.vue | 52 +++++- src/api/codexGateway.ts | 33 ++++ .../content/HeaderGitBranchDropdown.vue | 152 ++++++++++++++++-- src/components/content/ReviewPane.vue | 34 +++- src/server/codexAppServerBridge.ts | 49 ++++++ src/style.css | 29 +++- tests.md | 36 +++-- 7 files changed, 349 insertions(+), 36 deletions(-) diff --git a/src/App.vue b/src/App.vue index 7a2c7cc1..9082ba81 100644 --- a/src/App.vue +++ b/src/App.vue @@ -545,6 +545,9 @@ :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" @@ -554,6 +557,8 @@ @checkout-branch="onCheckoutContentHeaderBranch" @reset-branch-to-commit="onResetContentHeaderBranchToCommit" @load-commits="loadThreadBranchCommits" + @load-commit-files="loadThreadCommitFiles" + @open-commit-file="onOpenContentHeaderCommitFile" /> @@ -920,6 +925,7 @@ :thread-id="selectedThreadId" :cwd="composerCwd" :is-thread-in-progress="isSelectedThreadInProgress" + :initial-file-path="reviewInitialFilePath" @close="isReviewPaneOpen = false" /> @@ -1097,6 +1103,7 @@ import { createProjectlessThreadDirectory, getGitBranchState, getGitBranchCommits, + getGitCommitFiles, getGitRepositoryStatus, getWorktreeBranchOptions, getAccounts, @@ -1122,7 +1129,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 +1433,13 @@ let threadSearchTimer: ReturnType | null = null let terminalKeyboardFocusFallbackTimer: ReturnType | null = null let threadBranchesRequestId = 0 let threadBranchCommitsRequestId = 0 +let threadCommitFilesRequestId = 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 threadBranchOptions = ref([]) const currentThreadBranch = ref(null) const currentThreadHeadSha = ref(null) @@ -1442,6 +1451,9 @@ 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) @@ -3090,6 +3102,7 @@ function canLoadBranchStateForCwd(cwd: string): boolean { function resetThreadBranchState(): void { threadBranchesRequestId += 1 threadBranchCommitsRequestId += 1 + threadCommitFilesRequestId += 1 threadBranchOptions.value = [] currentThreadBranch.value = null currentThreadHeadSha.value = null @@ -3100,6 +3113,9 @@ function resetThreadBranchState(): void { threadBranchCommitsByBranch.value = {} threadBranchCommitsLoadingFor.value = '' threadBranchCommitsError.value = '' + threadCommitFilesBySha.value = {} + threadCommitFilesLoadingFor.value = '' + threadCommitFilesError.value = '' threadBranchError.value = '' isLoadingThreadBranches.value = false } @@ -3234,6 +3250,40 @@ function loadThreadBranchCommits(payload: string | { branch: string; includeRese }) } +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(filePath: string): void { + const targetPath = filePath.trim() + if (!targetPath) return + reviewInitialFilePath.value = targetPath + isReviewPaneOpen.value = true +} + async function onOpenProjectSetupModal(): Promise { const baseDir = await resolveProjectBaseDirectory() if (!baseDir) return diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts index 5cabdd2e..87347fca 100644 --- a/src/api/codexGateway.ts +++ b/src/api/codexGateway.ts @@ -332,6 +332,13 @@ export type GitCommitOption = { date: string } +export type GitCommitFileChange = { + path: string + previousPath: string | null + status: string + label: string +} + export type GitRepositoryStatus = { isGitRepo: boolean gitRoot: string @@ -2700,6 +2707,32 @@ export async function getGitBranchCommits(cwd: string, branch: string, options: }) } +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.trim() : '' + const previousPath = typeof record.previousPath === 'string' && record.previousPath.trim() ? record.previousPath.trim() : null + const status = typeof record.status === 'string' ? record.status.trim() : '' + const label = typeof record.label === 'string' ? record.label.trim() : '' + if (!path || !status) return [] + return [{ path, previousPath, status, label: label || status }] + }) +} + export async function resetGitBranchToCommit(cwd: string, branch: string, sha: string): Promise { const response = await fetch('/codex-api/git/reset-to-commit', { method: 'POST', diff --git a/src/components/content/HeaderGitBranchDropdown.vue b/src/components/content/HeaderGitBranchDropdown.vue index 92e27258..8bb689f4 100644 --- a/src/components/content/HeaderGitBranchDropdown.vue +++ b/src/components/content/HeaderGitBranchDropdown.vue @@ -55,9 +55,9 @@ v-for="commit in filteredSelectedBranchCommits" :key="commit.sha" class="header-git-commit" - :class="{ 'is-current': isCurrentCommit(commit) }" + :class="{ 'is-current': isCurrentCommit(commit), 'is-selected': commit.sha === selectedCommitSha }" type="button" - :disabled="busy || selectedBranchIsRemote" + :disabled="busy" :title="selectedBranchCommitActionTitle(commit)" @click="onSelectCommit(commit)" > @@ -115,6 +115,49 @@
  • No branches found.
  • + +
    + +
    Select a commit to view files.
    +
    @@ -123,7 +166,7 @@ @@ -343,13 +418,14 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onDocumentPointe } .header-git-menu { - @apply w-[42rem] max-w-[calc(100vw-1.5rem)] rounded-xl border border-zinc-200 bg-white p-1 shadow-lg; + @apply w-[58rem] max-w-[calc(100vw-1.5rem)] rounded-xl border border-zinc-200 bg-white p-1 shadow-lg; } .header-git-review-row, .header-git-branch-button, .header-git-branch-checkout, -.header-git-commit { +.header-git-commit, +.header-git-file { @apply flex w-full border-0 bg-transparent text-left transition; } @@ -398,11 +474,12 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onDocumentPointe } .header-git-columns { - @apply grid min-h-80 grid-cols-[minmax(0,1.15fr)_minmax(13rem,0.85fr)] gap-1; + @apply grid min-h-80 grid-cols-[minmax(0,1.1fr)_minmax(12rem,0.75fr)_minmax(13rem,0.8fr)] gap-1; } .header-git-commit-panel, -.header-git-branch-panel { +.header-git-branch-panel, +.header-git-commit-detail-panel { @apply min-w-0 rounded-lg border border-zinc-100 bg-zinc-50 p-1; } @@ -410,6 +487,10 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onDocumentPointe @apply max-h-80 overflow-y-auto; } +.header-git-file-list { + @apply max-h-64 overflow-y-auto; +} + .header-git-branches { @apply m-0 max-h-80 list-none overflow-y-auto p-0; } @@ -460,6 +541,10 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onDocumentPointe @apply bg-white ring-1 ring-zinc-300; } +.header-git-commit.is-selected { + @apply bg-white ring-1 ring-zinc-400; +} + .header-git-commit-top { @apply flex items-center justify-between gap-2 text-[0.68rem] text-zinc-500; } @@ -481,13 +566,54 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onDocumentPointe @apply text-red-700; } +.header-git-commit-detail-head { + @apply rounded-lg bg-white p-2; +} + +.header-git-commit-detail-title { + @apply flex items-center justify-between gap-2 text-[0.68rem] text-zinc-500; +} + +.header-git-commit-detail-title code { + @apply rounded bg-zinc-200 px-1 py-0.5 font-mono text-[0.68rem] text-zinc-700; +} + +.header-git-commit-detail-subject { + @apply m-0 mt-1 line-clamp-2 text-xs text-zinc-800; +} + +.header-git-reset-commit { + @apply mt-2 w-full rounded-md border border-zinc-200 bg-zinc-900 px-2 py-1.5 text-xs font-medium text-white transition hover:bg-zinc-800 disabled:cursor-wait disabled:border-zinc-200 disabled:bg-zinc-100 disabled:text-zinc-400; +} + +.header-git-file { + @apply mt-1 flex-col gap-1 rounded-md px-2 py-1.5 text-xs text-zinc-700 hover:bg-white; +} + +.header-git-file-status { + @apply w-fit rounded bg-zinc-200 px-1.5 py-0.5 text-[0.65rem] uppercase text-zinc-600; +} + +.header-git-file-path { + @apply min-w-0 truncate; +} + +.header-git-files-empty { + @apply px-2 py-2 text-xs text-zinc-500; +} + +.header-git-files-empty.is-error { + @apply text-red-700; +} + @media (max-width: 640px) { .header-git-columns { @apply grid-cols-1; } .header-git-commit-list, - .header-git-branches { + .header-git-branches, + .header-git-file-list { @apply max-h-56; } } diff --git a/src/components/content/ReviewPane.vue b/src/components/content/ReviewPane.vue index 3d446699..e1a5f6d6 100644 --- a/src/components/content/ReviewPane.vue +++ b/src/components/content/ReviewPane.vue @@ -423,6 +423,7 @@ const props = defineProps<{ threadId: string cwd: string isThreadInProgress: boolean + initialFilePath?: string }>() defineEmits<{ @@ -498,6 +499,30 @@ const currentReviewResult = computed(() => reviewResultsByKey.value[reviewKey.va const selectedFile = computed(() => snapshot.value?.files.find((file) => file.id === selectedFileId.value) ?? snapshot.value?.files[0] ?? null) const folderExpansionState = ref>({}) +function normalizeReviewPath(filePath: string): string { + return filePath.trim().replace(/\\/g, '/') +} + +function findFileByPath(filePath: string): UiReviewFile | null { + const targetPath = normalizeReviewPath(filePath) + if (!targetPath || !snapshot.value) return null + return snapshot.value.files.find((file) => { + return [ + file.path, + file.absolutePath, + file.previousPath, + file.previousAbsolutePath, + ].some((candidate) => typeof candidate === 'string' && normalizeReviewPath(candidate) === targetPath) + }) ?? null +} + +function selectInitialFilePath(): boolean { + const targetFile = findFileByPath(props.initialFilePath ?? '') + if (!targetFile) return false + selectFile(targetFile.id) + return true +} + const headerTitle = computed(() => { if (!snapshot.value?.isGitRepo) return t('Repository review') if (activeScope.value === 'workspace') { @@ -806,7 +831,7 @@ async function loadSnapshot(): Promise { } snapshot.value = nextSnapshot const hasSelectedFile = nextSnapshot.files.some((file) => file.id === selectedFileId.value) - if (!hasSelectedFile) { + if (!selectInitialFilePath() && !hasSelectedFile) { selectedFileId.value = nextSnapshot.files[0]?.id ?? '' selectedHunkId.value = nextSnapshot.files[0]?.hunks[0]?.id ?? '' } @@ -1025,6 +1050,13 @@ watch( { immediate: true }, ) +watch( + () => props.initialFilePath, + () => { + selectInitialFilePath() + }, +) + watch( () => [activeScope.value, workspaceView.value] as const, () => { diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index de5d5c90..15248e4b 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -7004,6 +7004,55 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { return } + if (req.method === 'GET' && url.pathname === '/codex-api/git/commit-files') { + const rawCwd = (url.searchParams.get('cwd') ?? '').trim() + const sha = (url.searchParams.get('sha') ?? '').trim() + if (!rawCwd) { + setJson(res, 400, { error: 'Missing cwd' }) + return + } + if (!sha) { + setJson(res, 400, { error: 'Missing sha' }) + return + } + const cwd = isAbsolute(rawCwd) ? rawCwd : resolve(rawCwd) + try { + const gitRoot = await runCommandCapture('git', ['rev-parse', '--show-toplevel'], { cwd }) + await runCommandCapture('git', ['rev-parse', '--verify', `${sha}^{commit}`], { cwd: gitRoot }) + const output = await runCommandCapture( + 'git', + ['diff-tree', '--root', '--no-commit-id', '--name-status', '-r', '-M', sha], + { cwd: gitRoot }, + ) + const files = output.split('\n').flatMap((line) => { + const parts = line.split('\t').map((part) => part.trim()).filter(Boolean) + const status = parts[0] ?? '' + if (!status) return [] + const statusKind = status.charAt(0) + const isRenameOrCopy = (statusKind === 'R' || statusKind === 'C') && parts.length >= 3 + const path = isRenameOrCopy ? parts[2] : parts[1] + const previousPath = isRenameOrCopy ? parts[1] : null + if (!path) return [] + const label = statusKind === 'A' + ? 'Added' + : statusKind === 'D' + ? 'Deleted' + : statusKind === 'R' + ? 'Renamed' + : statusKind === 'C' + ? 'Copied' + : statusKind === 'M' + ? 'Modified' + : status + return [{ path, previousPath, status, label }] + }) + setJson(res, 200, { data: files }) + } catch (error) { + setJson(res, 500, { error: getErrorMessage(error, 'Failed to load commit files') }) + } + return + } + if (req.method === 'POST' && url.pathname === '/codex-api/git/reset-to-commit') { const payload = await readJsonBody(req) const record = asRecord(payload) diff --git a/src/style.css b/src/style.css index 963eadd3..4c78d247 100644 --- a/src/style.css +++ b/src/style.css @@ -894,7 +894,8 @@ :root.dark .header-git-review-row, :root.dark .header-git-branch-button, :root.dark .header-git-branch-checkout, -:root.dark .header-git-commit { +:root.dark .header-git-commit, +:root.dark .header-git-file { @apply text-zinc-200; } @@ -902,13 +903,16 @@ :root.dark .header-git-branch-button:hover, :root.dark .header-git-branch-checkout:hover, :root.dark .header-git-commit:hover, +:root.dark .header-git-file:hover, :root.dark .header-git-commit.is-current, +:root.dark .header-git-commit.is-selected, :root.dark .header-git-branch-button.is-selected, :root.dark .header-git-branch-button.is-current { @apply bg-zinc-800; } -:root.dark .header-git-branch-button.is-selected { +:root.dark .header-git-branch-button.is-selected, +:root.dark .header-git-commit.is-selected { @apply ring-zinc-600; } @@ -919,11 +923,13 @@ :root.dark .header-git-state, :root.dark .header-git-commit-panel, :root.dark .header-git-branch-panel, +:root.dark .header-git-commit-detail-panel, :root.dark .header-git-commits { @apply border-zinc-700 bg-zinc-800; } -:root.dark .header-git-state-value { +:root.dark .header-git-state-value, +:root.dark .header-git-commit-detail-subject { @apply text-zinc-100; } @@ -933,7 +939,9 @@ :root.dark .header-git-branch-meta, :root.dark .header-git-commit-top, :root.dark .header-git-empty, -:root.dark .header-git-commits-empty { +:root.dark .header-git-commits-empty, +:root.dark .header-git-commit-detail-title, +:root.dark .header-git-files-empty { @apply text-zinc-400; } @@ -942,16 +950,27 @@ } :root.dark .header-git-commit-top code, +:root.dark .header-git-commit-detail-title code, +:root.dark .header-git-file-status, :root.dark .header-git-branch-meta { @apply bg-zinc-700 text-zinc-200; } +:root.dark .header-git-commit-detail-head { + @apply bg-zinc-900; +} + +:root.dark .header-git-reset-commit { + @apply border-zinc-600 bg-zinc-100 text-zinc-900 hover:bg-white disabled:border-zinc-700 disabled:bg-zinc-800 disabled:text-zinc-500; +} + :root.dark .header-git-status { @apply bg-amber-950/40 text-amber-300; } :root.dark .header-git-status.is-error, -:root.dark .header-git-commits-empty.is-error { +:root.dark .header-git-commits-empty.is-error, +:root.dark .header-git-files-empty.is-error { @apply bg-red-950/40 text-red-300; } diff --git a/tests.md b/tests.md index deedb456..4b090b5f 100644 --- a/tests.md +++ b/tests.md @@ -773,7 +773,7 @@ Skills Sync skips unchanged manifest writes and does not fail parent commits whe ### Header Git branch dropdown with commit reset #### Feature/Change Name -Thread header Git dropdown replaces the simple review action with a two-column commits/branches picker, Review access, safe branch switching, branch reset-to-commit, and reset-history commit preservation. +Thread header Git dropdown replaces the simple review action with a commits/branches picker, Review access, safe branch switching, selected-commit file details, branch reset-to-commit, and reset-history commit preservation. #### Prerequisites/Setup 1. Dev server running (`pnpm run dev`) @@ -785,7 +785,7 @@ Thread header Git dropdown replaces the simple review action with a two-column c #### Steps 1. In light theme, open the Git dropdown in the thread header. 2. Confirm the trigger shows the current branch, or the detached commit subject if the repository is already detached. -3. Confirm the menu shows commits in the left column and branches in the right column. +3. Confirm the menu shows commits in the left column, branches in the middle column, and an empty commit detail panel on the right. 4. Confirm the left column defaults to the current branch and shows no more than 50 recent commits with short SHA, subject, and date. 5. Click `Review` and confirm the review pane opens; click it again and confirm the pane toggles. 6. Type part of a commit subject or short SHA in the left commit search and confirm the commit list filters. @@ -794,24 +794,28 @@ Thread header Git dropdown replaces the simple review action with a two-column c 9. Type part of a branch name in search and confirm the right branch list filters. 10. Click a different branch row and confirm the left commit list changes to that branch without immediately switching checkout. 11. Use the branch row `Checkout` action with a clean worktree and confirm the header updates to that branch. -12. Confirm remote branches are hidden from the branch list. -13. Select an older commit on the disposable local branch and confirm the header stays on that branch instead of entering detached HEAD. -14. Confirm `git -C rev-parse --abbrev-ref HEAD` still prints the branch name and `git -C rev-parse --short HEAD` matches the selected commit. -15. Reopen/select the same branch and confirm commits that were ahead of the reset target still appear, with the selected branch HEAD marked `current`. -16. Repeat reset on the same branch several times and confirm the dropdown still opens quickly and shows recent reset-history commits. -17. Create a tracked uncommitted change, try to switch branch or reset to a commit, and confirm the dropdown shows a dirty-worktree error instead of switching or resetting. -18. Create only an untracked file whose path does not exist in the target commit, try to reset to a commit, and confirm the reset proceeds while the untracked file remains in place. -19. Create only an untracked file whose path exists in the target commit, try to reset to that target, and confirm the reset proceeds and the untracked file is moved under `.codex/untracked-backups/` instead of being overwritten. -20. Switch to dark theme and repeat steps 1, 2, 3, 4, 6, 7, 8, 9, 12, 15, 17, 18, and 19. - -#### Expected Results -- The header dropdown exposes Review, current checkout state, a left-side commit list, and a right-side searchable branch list. +12. Confirm local branches appear first and remote branches appear at the end of the branch list. +13. Select an older commit on the disposable local branch and confirm the right detail panel shows that commit subject, file changes, and a `Reset` button without changing HEAD. +14. Click a file in the selected commit details and confirm the Review pane opens with the matching file selected when that file is present in the review snapshot. +15. Click `Reset` for the selected commit and confirm the header stays on that branch instead of entering detached HEAD. +16. Confirm `git -C rev-parse --abbrev-ref HEAD` still prints the branch name and `git -C rev-parse --short HEAD` matches the selected commit. +17. Reopen/select the same branch and confirm commits that were ahead of the reset target still appear, with the selected branch HEAD marked `current`. +18. Repeat reset on the same branch several times and confirm the dropdown still opens quickly and shows recent reset-history commits. +19. Create a tracked uncommitted change, try to switch branch or reset to a commit, and confirm the dropdown shows a dirty-worktree error instead of switching or resetting. +20. Create only an untracked file whose path does not exist in the target commit, try to reset to a commit, and confirm the reset proceeds while the untracked file remains in place. +21. Create only an untracked file whose path exists in the target commit, try to reset to that target, and confirm the reset proceeds and the untracked file is moved under `.codex/untracked-backups/` instead of being overwritten. +22. Switch to dark theme and repeat steps 1, 2, 3, 4, 6, 7, 8, 9, 12, 13, 14, 17, 19, 20, and 21. + +#### Expected Results +- The header dropdown exposes Review, current checkout state, a left-side commit list, a middle searchable branch list, and a right-side selected-commit file panel. - The current branch commit list loads by default and is capped at 50 commits. - The commit list can be searched by SHA, subject, or date without changing the selected branch. - Reset-history refs can be shown or hidden from the commit list without changing the selected branch. - Branch switching and branch reset-to-commit are blocked by tracked uncommitted changes, but untracked-only changes are preserved and allowed. -- Commit selection resets the local branch to that commit instead of detaching HEAD. -- Remote branches are hidden from the branch list. +- Commit selection opens file details without resetting or detaching HEAD. +- The selected commit `Reset` button resets the local branch to that commit instead of detaching HEAD. +- Clicking a selected commit file opens the Review pane and selects that path when it exists in the current review snapshot. +- Remote branches appear after local branches in the branch list. - The branch commit list still shows commits that were ahead of the reset target by reading saved internal reset-history refs. - Reset-history refs are bounded so repeated resets do not grow commit-list inputs without limit. - Untracked files that would collide with target tracked files are moved to `.codex/untracked-backups/` before checkout/reset. From ccbb37fed917fecff5bdb9c88f46f2fa6b4a2f10 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 11 May 2026 08:08:31 +0700 Subject: [PATCH 03/27] Fix header dropdown stacking --- src/components/content/ContentHeader.vue | 2 +- src/components/layout/DesktopLayout.vue | 6 ++-- tests.md | 38 +++++++++++++----------- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/components/content/ContentHeader.vue b/src/components/content/ContentHeader.vue index 848ce5ba..094de33f 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-[80] 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/layout/DesktopLayout.vue b/src/components/layout/DesktopLayout.vue index abfe3aae..cc6f6a23 100644 --- a/src/components/layout/DesktopLayout.vue +++ b/src/components/layout/DesktopLayout.vue @@ -110,14 +110,14 @@ function onResizeHandleMouseDown(event: MouseEvent): void { @reference "tailwindcss"; .desktop-layout { - @apply grid bg-slate-100 text-slate-900 overflow-hidden; + @apply isolate grid bg-slate-100 text-slate-900 overflow-hidden; height: 100vh; height: 100dvh; grid-template-columns: var(--layout-columns); } .desktop-sidebar { - @apply bg-slate-100 min-h-0 overflow-hidden; + @apply relative z-0 bg-slate-100 min-h-0 overflow-hidden; } .desktop-resize-handle { @@ -130,7 +130,7 @@ function onResizeHandleMouseDown(event: MouseEvent): void { } .desktop-main { - @apply bg-white min-h-0 overflow-y-hidden overflow-x-visible; + @apply relative z-[100] bg-white min-h-0 overflow-y-hidden overflow-x-visible; } .mobile-drawer-backdrop { diff --git a/tests.md b/tests.md index 4b090b5f..2ed20b66 100644 --- a/tests.md +++ b/tests.md @@ -787,27 +787,29 @@ Thread header Git dropdown replaces the simple review action with a commits/bran 2. Confirm the trigger shows the current branch, or the detached commit subject if the repository is already detached. 3. Confirm the menu shows commits in the left column, branches in the middle column, and an empty commit detail panel on the right. 4. Confirm the left column defaults to the current branch and shows no more than 50 recent commits with short SHA, subject, and date. -5. Click `Review` and confirm the review pane opens; click it again and confirm the pane toggles. -6. Type part of a commit subject or short SHA in the left commit search and confirm the commit list filters. -7. Turn off `Reset-history refs` and confirm the commit list reloads without saved reset-history refs. -8. Turn `Reset-history refs` back on and confirm saved reset-history commits reappear when available. -9. Type part of a branch name in search and confirm the right branch list filters. -10. Click a different branch row and confirm the left commit list changes to that branch without immediately switching checkout. -11. Use the branch row `Checkout` action with a clean worktree and confirm the header updates to that branch. -12. Confirm local branches appear first and remote branches appear at the end of the branch list. -13. Select an older commit on the disposable local branch and confirm the right detail panel shows that commit subject, file changes, and a `Reset` button without changing HEAD. -14. Click a file in the selected commit details and confirm the Review pane opens with the matching file selected when that file is present in the review snapshot. -15. Click `Reset` for the selected commit and confirm the header stays on that branch instead of entering detached HEAD. -16. Confirm `git -C rev-parse --abbrev-ref HEAD` still prints the branch name and `git -C rev-parse --short HEAD` matches the selected commit. -17. Reopen/select the same branch and confirm commits that were ahead of the reset target still appear, with the selected branch HEAD marked `current`. -18. Repeat reset on the same branch several times and confirm the dropdown still opens quickly and shows recent reset-history commits. -19. Create a tracked uncommitted change, try to switch branch or reset to a commit, and confirm the dropdown shows a dirty-worktree error instead of switching or resetting. -20. Create only an untracked file whose path does not exist in the target commit, try to reset to a commit, and confirm the reset proceeds while the untracked file remains in place. -21. Create only an untracked file whose path exists in the target commit, try to reset to that target, and confirm the reset proceeds and the untracked file is moved under `.codex/untracked-backups/` instead of being overwritten. -22. Switch to dark theme and repeat steps 1, 2, 3, 4, 6, 7, 8, 9, 12, 13, 14, 17, 19, 20, and 21. +5. Confirm the open dropdown visually layers above the sidebar and above the Review pane if the pane is already open. +6. Click `Review` and confirm the review pane opens; click it again and confirm the pane toggles. +7. Type part of a commit subject or short SHA in the left commit search and confirm the commit list filters. +8. Turn off `Reset-history refs` and confirm the commit list reloads without saved reset-history refs. +9. Turn `Reset-history refs` back on and confirm saved reset-history commits reappear when available. +10. Type part of a branch name in search and confirm the right branch list filters. +11. Click a different branch row and confirm the left commit list changes to that branch without immediately switching checkout. +12. Use the branch row `Checkout` action with a clean worktree and confirm the header updates to that branch. +13. Confirm local branches appear first and remote branches appear at the end of the branch list. +14. Select an older commit on the disposable local branch and confirm the right detail panel shows that commit subject, file changes, and a `Reset` button without changing HEAD. +15. Click a file in the selected commit details and confirm the Review pane opens with the matching file selected when that file is present in the review snapshot. +16. Click `Reset` for the selected commit and confirm the header stays on that branch instead of entering detached HEAD. +17. Confirm `git -C rev-parse --abbrev-ref HEAD` still prints the branch name and `git -C rev-parse --short HEAD` matches the selected commit. +18. Reopen/select the same branch and confirm commits that were ahead of the reset target still appear, with the selected branch HEAD marked `current`. +19. Repeat reset on the same branch several times and confirm the dropdown still opens quickly and shows recent reset-history commits. +20. Create a tracked uncommitted change, try to switch branch or reset to a commit, and confirm the dropdown shows a dirty-worktree error instead of switching or resetting. +21. Create only an untracked file whose path does not exist in the target commit, try to reset to a commit, and confirm the reset proceeds while the untracked file remains in place. +22. Create only an untracked file whose path exists in the target commit, try to reset to that target, and confirm the reset proceeds and the untracked file is moved under `.codex/untracked-backups/` instead of being overwritten. +23. Switch to dark theme and repeat steps 1, 2, 3, 4, 5, 7, 8, 9, 10, 13, 14, 15, 18, 20, 21, and 22. #### Expected Results - The header dropdown exposes Review, current checkout state, a left-side commit list, a middle searchable branch list, and a right-side selected-commit file panel. +- The dropdown layer appears above the sidebar and Review pane while it is open. - The current branch commit list loads by default and is capped at 50 commits. - The commit list can be searched by SHA, subject, or date without changing the selected branch. - Reset-history refs can be shown or hidden from the commit list without changing the selected branch. From cd44267ed62835375142ded804f8905b583c8304 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 11 May 2026 08:11:38 +0700 Subject: [PATCH 04/27] Hide commit files until selection --- .../content/HeaderGitBranchDropdown.vue | 87 ++++++++++--------- tests.md | 9 +- 2 files changed, 51 insertions(+), 45 deletions(-) diff --git a/src/components/content/HeaderGitBranchDropdown.vue b/src/components/content/HeaderGitBranchDropdown.vue index 8bb689f4..39790ef1 100644 --- a/src/components/content/HeaderGitBranchDropdown.vue +++ b/src/components/content/HeaderGitBranchDropdown.vue @@ -15,7 +15,7 @@
    -
    +
    -
    +
    -
    - +
    @@ -418,7 +415,11 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onDocumentPointe } .header-git-menu { - @apply w-[58rem] max-w-[calc(100vw-1.5rem)] rounded-xl border border-zinc-200 bg-white p-1 shadow-lg; + @apply w-[42rem] max-w-[calc(100vw-1.5rem)] rounded-xl border border-zinc-200 bg-white p-1 shadow-lg; +} + +.header-git-menu.has-commit-files { + @apply w-[58rem]; } .header-git-review-row, @@ -474,7 +475,11 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onDocumentPointe } .header-git-columns { - @apply grid min-h-80 grid-cols-[minmax(0,1.1fr)_minmax(12rem,0.75fr)_minmax(13rem,0.8fr)] gap-1; + @apply grid min-h-80 grid-cols-[minmax(0,1.15fr)_minmax(13rem,0.85fr)] gap-1; +} + +.header-git-columns.has-commit-files { + @apply grid-cols-[minmax(0,1.1fr)_minmax(12rem,0.75fr)_minmax(13rem,0.8fr)]; } .header-git-commit-panel, diff --git a/tests.md b/tests.md index 2ed20b66..6b2dd9af 100644 --- a/tests.md +++ b/tests.md @@ -785,18 +785,18 @@ Thread header Git dropdown replaces the simple review action with a commits/bran #### Steps 1. In light theme, open the Git dropdown in the thread header. 2. Confirm the trigger shows the current branch, or the detached commit subject if the repository is already detached. -3. Confirm the menu shows commits in the left column, branches in the middle column, and an empty commit detail panel on the right. +3. Confirm the menu initially shows only commits on the left and branches on the right, with no commit-files panel before a commit is selected. 4. Confirm the left column defaults to the current branch and shows no more than 50 recent commits with short SHA, subject, and date. 5. Confirm the open dropdown visually layers above the sidebar and above the Review pane if the pane is already open. 6. Click `Review` and confirm the review pane opens; click it again and confirm the pane toggles. 7. Type part of a commit subject or short SHA in the left commit search and confirm the commit list filters. 8. Turn off `Reset-history refs` and confirm the commit list reloads without saved reset-history refs. 9. Turn `Reset-history refs` back on and confirm saved reset-history commits reappear when available. -10. Type part of a branch name in search and confirm the right branch list filters. +10. Type part of a branch name in search and confirm the branch list filters. 11. Click a different branch row and confirm the left commit list changes to that branch without immediately switching checkout. 12. Use the branch row `Checkout` action with a clean worktree and confirm the header updates to that branch. 13. Confirm local branches appear first and remote branches appear at the end of the branch list. -14. Select an older commit on the disposable local branch and confirm the right detail panel shows that commit subject, file changes, and a `Reset` button without changing HEAD. +14. Select an older commit on the disposable local branch and confirm the dropdown widens and shows a right-side file panel with that commit subject, file changes, and a `Reset` button without changing HEAD. 15. Click a file in the selected commit details and confirm the Review pane opens with the matching file selected when that file is present in the review snapshot. 16. Click `Reset` for the selected commit and confirm the header stays on that branch instead of entering detached HEAD. 17. Confirm `git -C rev-parse --abbrev-ref HEAD` still prints the branch name and `git -C rev-parse --short HEAD` matches the selected commit. @@ -808,7 +808,8 @@ Thread header Git dropdown replaces the simple review action with a commits/bran 23. Switch to dark theme and repeat steps 1, 2, 3, 4, 5, 7, 8, 9, 10, 13, 14, 15, 18, 20, 21, and 22. #### Expected Results -- The header dropdown exposes Review, current checkout state, a left-side commit list, a middle searchable branch list, and a right-side selected-commit file panel. +- The header dropdown exposes Review, current checkout state, a left-side commit list, and a right-side searchable branch list before a commit is selected. +- The selected-commit file panel is hidden until commit selection, then appears on the right and expands the dropdown width. - The dropdown layer appears above the sidebar and Review pane while it is open. - The current branch commit list loads by default and is capped at 50 commits. - The commit list can be searched by SHA, subject, or date without changing the selected branch. From ff74f65dda2e22693baa4445119ed11c66337888 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 11 May 2026 08:19:22 +0700 Subject: [PATCH 05/27] Move commit files panel left --- src/components/content/ContentHeader.vue | 2 +- .../content/HeaderGitBranchDropdown.vue | 82 +++++++++---------- src/components/content/ReviewPane.vue | 2 +- tests.md | 6 +- 4 files changed, 46 insertions(+), 46 deletions(-) diff --git a/src/components/content/ContentHeader.vue b/src/components/content/ContentHeader.vue index 094de33f..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-[80] 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 39790ef1..3562815a 100644 --- a/src/components/content/HeaderGitBranchDropdown.vue +++ b/src/components/content/HeaderGitBranchDropdown.vue @@ -32,6 +32,46 @@
    +
    +
    +
    + {{ selectedCommit.shortSha }} + {{ selectedCommit.date }} +
    +

    {{ selectedCommit.subject }}

    + +
    + +
    +
    Loading files...
    +
    {{ commitFilesError }}
    + +
    +
    +
    No branches found.
    - -
    -
    -
    - {{ selectedCommit.shortSha }} - {{ selectedCommit.date }} -
    -

    {{ selectedCommit.subject }}

    - -
    - -
    -
    Loading files...
    -
    {{ commitFilesError }}
    - -
    -
    @@ -411,7 +411,7 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onDocumentPointe } .header-git-menu-wrap { - @apply absolute right-0 top-[calc(100%+8px)] z-50; + @apply fixed right-3 top-[4.25rem] z-[1000]; } .header-git-menu { diff --git a/src/components/content/ReviewPane.vue b/src/components/content/ReviewPane.vue index e1a5f6d6..25ae976f 100644 --- a/src/components/content/ReviewPane.vue +++ b/src/components/content/ReviewPane.vue @@ -1108,7 +1108,7 @@ onBeforeUnmount(() => { @reference "tailwindcss"; .review-pane { - @apply flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white; + @apply relative z-0 flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white; } .review-pane.is-mobile { diff --git a/tests.md b/tests.md index 6b2dd9af..775686c4 100644 --- a/tests.md +++ b/tests.md @@ -796,7 +796,7 @@ Thread header Git dropdown replaces the simple review action with a commits/bran 11. Click a different branch row and confirm the left commit list changes to that branch without immediately switching checkout. 12. Use the branch row `Checkout` action with a clean worktree and confirm the header updates to that branch. 13. Confirm local branches appear first and remote branches appear at the end of the branch list. -14. Select an older commit on the disposable local branch and confirm the dropdown widens and shows a right-side file panel with that commit subject, file changes, and a `Reset` button without changing HEAD. +14. Select an older commit on the disposable local branch and confirm the dropdown widens and shows a left-side file panel with that commit subject, file changes, and a `Reset` button without changing HEAD. 15. Click a file in the selected commit details and confirm the Review pane opens with the matching file selected when that file is present in the review snapshot. 16. Click `Reset` for the selected commit and confirm the header stays on that branch instead of entering detached HEAD. 17. Confirm `git -C rev-parse --abbrev-ref HEAD` still prints the branch name and `git -C rev-parse --short HEAD` matches the selected commit. @@ -809,8 +809,8 @@ Thread header Git dropdown replaces the simple review action with a commits/bran #### Expected Results - The header dropdown exposes Review, current checkout state, a left-side commit list, and a right-side searchable branch list before a commit is selected. -- The selected-commit file panel is hidden until commit selection, then appears on the right and expands the dropdown width. -- The dropdown layer appears above the sidebar and Review pane while it is open. +- The selected-commit file panel is hidden until commit selection, then appears on the left and expands the dropdown width. +- The dropdown layer is viewport-positioned and appears above the sidebar and the already-open Review pane while it is open. - The current branch commit list loads by default and is capped at 50 commits. - The commit list can be searched by SHA, subject, or date without changing the selected branch. - Reset-history refs can be shown or hidden from the commit list without changing the selected branch. From 1a51cc5f6760655fc9ad1ce38f756896b53726c2 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 11 May 2026 08:23:06 +0700 Subject: [PATCH 06/27] Show commit file line counts --- src/api/codexGateway.ts | 6 +++- .../content/HeaderGitBranchDropdown.vue | 36 ++++++++++++++++--- src/server/codexAppServerBridge.ts | 19 +++++++++- src/style.css | 11 +++++- tests.md | 3 +- 5 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts index 87347fca..78d9e1cd 100644 --- a/src/api/codexGateway.ts +++ b/src/api/codexGateway.ts @@ -337,6 +337,8 @@ export type GitCommitFileChange = { previousPath: string | null status: string label: string + addedLineCount: number | null + removedLineCount: number | null } export type GitRepositoryStatus = { @@ -2728,8 +2730,10 @@ export async function getGitCommitFiles(cwd: string, sha: string): Promise - {{ file.label }} - - {{ file.path }} - + + {{ file.label }} + + +{{ formatFileLineCount(file.addedLineCount) }} + -{{ formatFileLineCount(file.removedLineCount) }} + + {{ file.path }} + ← {{ file.previousPath }}
    No file changes.
    @@ -295,6 +299,10 @@ function resetSelectedCommit(): void { emit('resetBranchToCommit', { branch: selectedBranch.value, sha: selectedCommit.value.sha }) } +function formatFileLineCount(value: number | null): string { + return typeof value === 'number' && Number.isFinite(value) ? String(value) : '-' +} + function openCommitFile(filePath: string): void { emit('openCommitFile', filePath) isOpen.value = false @@ -595,14 +603,34 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onDocumentPointe @apply mt-1 flex-col gap-1 rounded-md px-2 py-1.5 text-xs text-zinc-700 hover:bg-white; } +.header-git-file-meta-row { + @apply flex min-w-0 items-center justify-between gap-2; +} + .header-git-file-status { @apply w-fit rounded bg-zinc-200 px-1.5 py-0.5 text-[0.65rem] uppercase text-zinc-600; } +.header-git-file-delta { + @apply flex shrink-0 items-center gap-1 font-mono text-[0.68rem]; +} + +.header-git-file-added { + @apply text-emerald-600; +} + +.header-git-file-removed { + @apply text-red-600; +} + .header-git-file-path { @apply min-w-0 truncate; } +.header-git-file-previous-path { + @apply min-w-0 truncate text-[0.68rem] text-zinc-500; +} + .header-git-files-empty { @apply px-2 py-2 text-xs text-zinc-500; } diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 15248e4b..3a3c3b8a 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -7024,6 +7024,22 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { ['diff-tree', '--root', '--no-commit-id', '--name-status', '-r', '-M', sha], { cwd: gitRoot }, ) + const numstatOutput = await runCommandCapture( + 'git', + ['diff-tree', '--root', '--no-commit-id', '--numstat', '-r', '-M', sha], + { cwd: gitRoot }, + ) + const lineCountsByPath = new Map() + for (const line of numstatOutput.split('\n')) { + const parts = line.split('\t') + const addedRaw = parts[0]?.trim() ?? '' + const removedRaw = parts[1]?.trim() ?? '' + const path = (parts.length >= 4 ? parts[3] : parts[2])?.trim() ?? '' + if (!path) continue + const addedLineCount = /^\d+$/.test(addedRaw) ? Number(addedRaw) : null + const removedLineCount = /^\d+$/.test(removedRaw) ? Number(removedRaw) : null + lineCountsByPath.set(path, { addedLineCount, removedLineCount }) + } const files = output.split('\n').flatMap((line) => { const parts = line.split('\t').map((part) => part.trim()).filter(Boolean) const status = parts[0] ?? '' @@ -7044,7 +7060,8 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { : statusKind === 'M' ? 'Modified' : status - return [{ path, previousPath, status, label }] + const lineCounts = lineCountsByPath.get(path) ?? { addedLineCount: null, removedLineCount: null } + return [{ path, previousPath, status, label, ...lineCounts }] }) setJson(res, 200, { data: files }) } catch (error) { diff --git a/src/style.css b/src/style.css index 4c78d247..3b977209 100644 --- a/src/style.css +++ b/src/style.css @@ -941,10 +941,19 @@ :root.dark .header-git-empty, :root.dark .header-git-commits-empty, :root.dark .header-git-commit-detail-title, -:root.dark .header-git-files-empty { +:root.dark .header-git-files-empty, +:root.dark .header-git-file-previous-path { @apply text-zinc-400; } +:root.dark .header-git-file-added { + @apply text-emerald-400; +} + +:root.dark .header-git-file-removed { + @apply text-red-400; +} + :root.dark .header-git-search { @apply border-zinc-700 bg-zinc-800 text-zinc-100 focus:border-zinc-600; } diff --git a/tests.md b/tests.md index 775686c4..becfa03d 100644 --- a/tests.md +++ b/tests.md @@ -796,7 +796,7 @@ Thread header Git dropdown replaces the simple review action with a commits/bran 11. Click a different branch row and confirm the left commit list changes to that branch without immediately switching checkout. 12. Use the branch row `Checkout` action with a clean worktree and confirm the header updates to that branch. 13. Confirm local branches appear first and remote branches appear at the end of the branch list. -14. Select an older commit on the disposable local branch and confirm the dropdown widens and shows a left-side file panel with that commit subject, file changes, and a `Reset` button without changing HEAD. +14. Select an older commit on the disposable local branch and confirm the dropdown widens and shows a left-side file panel with that commit subject, file changes, per-file `+`/`-` line counts, and a `Reset` button without changing HEAD. 15. Click a file in the selected commit details and confirm the Review pane opens with the matching file selected when that file is present in the review snapshot. 16. Click `Reset` for the selected commit and confirm the header stays on that branch instead of entering detached HEAD. 17. Confirm `git -C rev-parse --abbrev-ref HEAD` still prints the branch name and `git -C rev-parse --short HEAD` matches the selected commit. @@ -810,6 +810,7 @@ Thread header Git dropdown replaces the simple review action with a commits/bran #### Expected Results - The header dropdown exposes Review, current checkout state, a left-side commit list, and a right-side searchable branch list before a commit is selected. - The selected-commit file panel is hidden until commit selection, then appears on the left and expands the dropdown width. +- Each selected-commit file row shows added and removed line counts, using `-` for binary or unavailable counts. - The dropdown layer is viewport-positioned and appears above the sidebar and the already-open Review pane while it is open. - The current branch commit list loads by default and is capped at 50 commits. - The commit list can be searched by SHA, subject, or date without changing the selected branch. From ff97332ca6fb59811141d6df3a0d91c1938146c3 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 11 May 2026 08:28:30 +0700 Subject: [PATCH 07/27] Copy commit refs from dropdown --- .../content/HeaderGitBranchDropdown.vue | 61 ++++++++++++++++--- src/style.css | 7 ++- tests.md | 20 +++--- 3 files changed, 69 insertions(+), 19 deletions(-) diff --git a/src/components/content/HeaderGitBranchDropdown.vue b/src/components/content/HeaderGitBranchDropdown.vue index 4979b153..6d2ed8c0 100644 --- a/src/components/content/HeaderGitBranchDropdown.vue +++ b/src/components/content/HeaderGitBranchDropdown.vue @@ -35,7 +35,14 @@
    - {{ selectedCommit.shortSha }} + {{ selectedCommit.date }}

    {{ selectedCommit.subject }}

    @@ -106,7 +113,17 @@ @click="onSelectCommit(commit)" > - {{ commit.shortSha }} + + {{ commit.shortSha }} + current {{ commit.date }} @@ -209,6 +226,7 @@ const searchQuery = ref('') const commitSearchQuery = ref('') const selectedBranch = ref('') const selectedCommitSha = ref('') +const copiedCommitSha = ref('') const lastCurrentBranch = ref('') const showResetHistoryRefs = ref(true) const showReview = computed(() => props.showReview !== false) @@ -294,6 +312,37 @@ function onSelectCommit(commit: GitCommitOption): void { emit('loadCommitFiles', commit.sha) } +function copyCommitRef(commit: GitCommitOption): void { + const value = commit.sha.trim() || commit.shortSha.trim() + if (!value) return + copiedCommitSha.value = commit.sha + void copyTextToClipboard(value).catch(() => { + copiedCommitSha.value = '' + }) +} + +async function copyTextToClipboard(value: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value) + return + } + } catch { + // Fall back for embedded browser contexts without clipboard permission. + } + const textarea = document.createElement('textarea') + textarea.value = value + textarea.setAttribute('readonly', 'true') + textarea.style.position = 'fixed' + textarea.style.top = '-9999px' + textarea.style.left = '-9999px' + document.body.appendChild(textarea) + textarea.select() + const copied = document.execCommand('copy') + textarea.remove() + if (!copied) throw new Error('Copy failed') +} + function resetSelectedCommit(): void { if (!selectedBranch.value || !selectedCommit.value || selectedBranchIsRemote.value) return emit('resetBranchToCommit', { branch: selectedBranch.value, sha: selectedCommit.value.sha }) @@ -566,8 +615,8 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onDocumentPointe @apply flex shrink-0 items-center gap-1.5; } -.header-git-commit-top code { - @apply rounded bg-zinc-200 px-1 py-0.5 font-mono text-[0.68rem] text-zinc-700; +.header-git-ref { + @apply inline-flex w-fit max-w-full items-center rounded border-0 bg-zinc-200 px-1 py-0.5 font-mono text-[0.68rem] text-zinc-700 outline-none transition hover:bg-zinc-300 focus-visible:ring-1 focus-visible:ring-zinc-500; } .header-git-empty, @@ -587,10 +636,6 @@ onBeforeUnmount(() => window.removeEventListener('pointerdown', onDocumentPointe @apply flex items-center justify-between gap-2 text-[0.68rem] text-zinc-500; } -.header-git-commit-detail-title code { - @apply rounded bg-zinc-200 px-1 py-0.5 font-mono text-[0.68rem] text-zinc-700; -} - .header-git-commit-detail-subject { @apply m-0 mt-1 line-clamp-2 text-xs text-zinc-800; } diff --git a/src/style.css b/src/style.css index 3b977209..4964ac3a 100644 --- a/src/style.css +++ b/src/style.css @@ -958,13 +958,16 @@ @apply border-zinc-700 bg-zinc-800 text-zinc-100 focus:border-zinc-600; } -:root.dark .header-git-commit-top code, -:root.dark .header-git-commit-detail-title code, +:root.dark .header-git-ref, :root.dark .header-git-file-status, :root.dark .header-git-branch-meta { @apply bg-zinc-700 text-zinc-200; } +:root.dark .header-git-ref:hover { + @apply bg-zinc-600; +} + :root.dark .header-git-commit-detail-head { @apply bg-zinc-900; } diff --git a/tests.md b/tests.md index becfa03d..82c4c8ee 100644 --- a/tests.md +++ b/tests.md @@ -797,15 +797,16 @@ Thread header Git dropdown replaces the simple review action with a commits/bran 12. Use the branch row `Checkout` action with a clean worktree and confirm the header updates to that branch. 13. Confirm local branches appear first and remote branches appear at the end of the branch list. 14. Select an older commit on the disposable local branch and confirm the dropdown widens and shows a left-side file panel with that commit subject, file changes, per-file `+`/`-` line counts, and a `Reset` button without changing HEAD. -15. Click a file in the selected commit details and confirm the Review pane opens with the matching file selected when that file is present in the review snapshot. -16. Click `Reset` for the selected commit and confirm the header stays on that branch instead of entering detached HEAD. -17. Confirm `git -C rev-parse --abbrev-ref HEAD` still prints the branch name and `git -C rev-parse --short HEAD` matches the selected commit. -18. Reopen/select the same branch and confirm commits that were ahead of the reset target still appear, with the selected branch HEAD marked `current`. -19. Repeat reset on the same branch several times and confirm the dropdown still opens quickly and shows recent reset-history commits. -20. Create a tracked uncommitted change, try to switch branch or reset to a commit, and confirm the dropdown shows a dirty-worktree error instead of switching or resetting. -21. Create only an untracked file whose path does not exist in the target commit, try to reset to a commit, and confirm the reset proceeds while the untracked file remains in place. -22. Create only an untracked file whose path exists in the target commit, try to reset to that target, and confirm the reset proceeds and the untracked file is moved under `.codex/untracked-backups/` instead of being overwritten. -23. Switch to dark theme and repeat steps 1, 2, 3, 4, 5, 7, 8, 9, 10, 13, 14, 15, 18, 20, 21, and 22. +15. Click a commit ref badge and confirm the full commit SHA is copied to the clipboard without changing the selected commit. +16. Click a file in the selected commit details and confirm the Review pane opens with the matching file selected when that file is present in the review snapshot. +17. Click `Reset` for the selected commit and confirm the header stays on that branch instead of entering detached HEAD. +18. Confirm `git -C rev-parse --abbrev-ref HEAD` still prints the branch name and `git -C rev-parse --short HEAD` matches the selected commit. +19. Reopen/select the same branch and confirm commits that were ahead of the reset target still appear, with the selected branch HEAD marked `current`. +20. Repeat reset on the same branch several times and confirm the dropdown still opens quickly and shows recent reset-history commits. +21. Create a tracked uncommitted change, try to switch branch or reset to a commit, and confirm the dropdown shows a dirty-worktree error instead of switching or resetting. +22. Create only an untracked file whose path does not exist in the target commit, try to reset to a commit, and confirm the reset proceeds while the untracked file remains in place. +23. Create only an untracked file whose path exists in the target commit, try to reset to that target, and confirm the reset proceeds and the untracked file is moved under `.codex/untracked-backups/` instead of being overwritten. +24. Switch to dark theme and repeat steps 1, 2, 3, 4, 5, 7, 8, 9, 10, 13, 14, 15, 16, 19, 21, 22, and 23. #### Expected Results - The header dropdown exposes Review, current checkout state, a left-side commit list, and a right-side searchable branch list before a commit is selected. @@ -817,6 +818,7 @@ Thread header Git dropdown replaces the simple review action with a commits/bran - Reset-history refs can be shown or hidden from the commit list without changing the selected branch. - Branch switching and branch reset-to-commit are blocked by tracked uncommitted changes, but untracked-only changes are preserved and allowed. - Commit selection opens file details without resetting or detaching HEAD. +- Commit ref badges copy the full SHA to the clipboard without triggering commit selection. - The selected commit `Reset` button resets the local branch to that commit instead of detaching HEAD. - Clicking a selected commit file opens the Review pane and selects that path when it exists in the current review snapshot. - Remote branches appear after local branches in the branch list. From ab5f5442a8b34a417f07d8e199398f9dfebd52d2 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 11 May 2026 08:32:06 +0700 Subject: [PATCH 08/27] Document persistent verification dev server --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) 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 From 0374a08354e7e0c19efb6881514a476df45a7455 Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 11 May 2026 09:38:39 +0700 Subject: [PATCH 09/27] Keep mobile review close visible --- src/components/content/ReviewPane.vue | 8 ++++---- tests.md | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/content/ReviewPane.vue b/src/components/content/ReviewPane.vue index 25ae976f..a385f5bc 100644 --- a/src/components/content/ReviewPane.vue +++ b/src/components/content/ReviewPane.vue @@ -1112,7 +1112,7 @@ onBeforeUnmount(() => { } .review-pane.is-mobile { - @apply fixed inset-0 z-40 rounded-none border-0; + @apply fixed inset-0 z-[700] rounded-none border-0; } .review-pane-header { @@ -1120,7 +1120,7 @@ onBeforeUnmount(() => { } .review-pane-heading { - @apply min-w-0; + @apply min-w-0 flex-1; } .review-pane-eyebrow { @@ -1132,7 +1132,7 @@ onBeforeUnmount(() => { } .review-pane-header-actions { - @apply flex items-center gap-2; + @apply flex shrink-0 items-center gap-2; } .review-pane-close, @@ -1146,7 +1146,7 @@ onBeforeUnmount(() => { } .review-pane-close { - @apply flex h-7.5 w-7.5 items-center justify-center rounded-full p-0; + @apply flex h-7.5 w-7.5 shrink-0 items-center justify-center rounded-full p-0; } .review-pane-toolbar { diff --git a/tests.md b/tests.md index 82c4c8ee..d543acf9 100644 --- a/tests.md +++ b/tests.md @@ -806,7 +806,8 @@ Thread header Git dropdown replaces the simple review action with a commits/bran 21. Create a tracked uncommitted change, try to switch branch or reset to a commit, and confirm the dropdown shows a dirty-worktree error instead of switching or resetting. 22. Create only an untracked file whose path does not exist in the target commit, try to reset to a commit, and confirm the reset proceeds while the untracked file remains in place. 23. Create only an untracked file whose path exists in the target commit, try to reset to that target, and confirm the reset proceeds and the untracked file is moved under `.codex/untracked-backups/` instead of being overwritten. -24. Switch to dark theme and repeat steps 1, 2, 3, 4, 5, 7, 8, 9, 10, 13, 14, 15, 16, 19, 21, 22, and 23. +24. At a mobile viewport around 375px wide, open the Review pane and confirm the `X` close button remains visible and tappable in the top-right corner. +25. Switch to dark theme and repeat steps 1, 2, 3, 4, 5, 7, 8, 9, 10, 13, 14, 15, 16, 19, 21, 22, 23, and 24. #### Expected Results - The header dropdown exposes Review, current checkout state, a left-side commit list, and a right-side searchable branch list before a commit is selected. @@ -826,6 +827,7 @@ Thread header Git dropdown replaces the simple review action with a commits/bran - Reset-history refs are bounded so repeated resets do not grow commit-list inputs without limit. - Untracked files that would collide with target tracked files are moved to `.codex/untracked-backups/` before checkout/reset. - The selected branch HEAD commit is marked `current` in the commit list. +- The mobile Review pane keeps its close button visible above the app chrome in both light theme and dark theme. - Loading and error messages remain visible in the dropdown without using browser alerts. - Dropdown surfaces, text, badges, and errors are readable in both light theme and dark theme. From c22ebfdfd333300927206ba35b4e0d08033cb78b Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 11 May 2026 09:41:40 +0700 Subject: [PATCH 10/27] Close git dropdown when toggling review --- src/components/content/HeaderGitBranchDropdown.vue | 10 +++++++++- tests.md | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/content/HeaderGitBranchDropdown.vue b/src/components/content/HeaderGitBranchDropdown.vue index 6d2ed8c0..0359dd5c 100644 --- a/src/components/content/HeaderGitBranchDropdown.vue +++ b/src/components/content/HeaderGitBranchDropdown.vue @@ -16,7 +16,7 @@
    - @@ -286,6 +286,14 @@ function toggleOpen(): void { isOpen.value = !isOpen.value } +function toggleReview(): void { + emit('toggleReview') + isOpen.value = false + searchQuery.value = '' + commitSearchQuery.value = '' + selectedCommitSha.value = '' +} + function selectBranch(branch: string): void { selectedBranch.value = branch selectedCommitSha.value = '' diff --git a/tests.md b/tests.md index d543acf9..ea991078 100644 --- a/tests.md +++ b/tests.md @@ -788,7 +788,7 @@ Thread header Git dropdown replaces the simple review action with a commits/bran 3. Confirm the menu initially shows only commits on the left and branches on the right, with no commit-files panel before a commit is selected. 4. Confirm the left column defaults to the current branch and shows no more than 50 recent commits with short SHA, subject, and date. 5. Confirm the open dropdown visually layers above the sidebar and above the Review pane if the pane is already open. -6. Click `Review` and confirm the review pane opens; click it again and confirm the pane toggles. +6. Click `Review` and confirm the dropdown closes and the review pane opens; reopen the dropdown, click `Review` again, and confirm the dropdown closes and the pane toggles closed. 7. Type part of a commit subject or short SHA in the left commit search and confirm the commit list filters. 8. Turn off `Reset-history refs` and confirm the commit list reloads without saved reset-history refs. 9. Turn `Reset-history refs` back on and confirm saved reset-history commits reappear when available. @@ -814,6 +814,7 @@ Thread header Git dropdown replaces the simple review action with a commits/bran - The selected-commit file panel is hidden until commit selection, then appears on the left and expands the dropdown width. - Each selected-commit file row shows added and removed line counts, using `-` for binary or unavailable counts. - The dropdown layer is viewport-positioned and appears above the sidebar and the already-open Review pane while it is open. +- Clicking the dropdown `Review` row always closes the dropdown after toggling the Review pane. - The current branch commit list loads by default and is capped at 50 commits. - The commit list can be searched by SHA, subject, or date without changing the selected branch. - Reset-history refs can be shown or hidden from the commit list without changing the selected branch. From 2f1f105bd0731c33aa1c99f7e690d06a4f8fc3fd Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 11 May 2026 09:45:30 +0700 Subject: [PATCH 11/27] Remove run review button --- src/components/content/ReviewPane.vue | 53 ++------------------------- src/composables/useUiLanguage.ts | 4 +- tests.md | 3 +- 3 files changed, 6 insertions(+), 54 deletions(-) diff --git a/src/components/content/ReviewPane.vue b/src/components/content/ReviewPane.vue index a385f5bc..2a0e4700 100644 --- a/src/components/content/ReviewPane.vue +++ b/src/components/content/ReviewPane.vue @@ -102,14 +102,6 @@
    - @@ -329,7 +321,7 @@

    No structured findings yet

    - {{ currentReviewResult?.summary ? 'The latest review only returned summary text.' : 'Run review to populate this pane.' }} + {{ currentReviewResult?.summary ? 'The latest review only returned summary text.' : 'No review results yet.' }}

    @@ -400,7 +392,6 @@ import { getReviewSnapshot, getThreadReviewResult, initializeReviewGit, - startThreadReview, subscribeCodexNotifications, type RpcNotification, } from '../../api/codexGateway' @@ -531,14 +522,6 @@ const headerTitle = computed(() => { return snapshot.value?.baseBranch ? `${t('Against')} ${snapshot.value.baseBranch}` : t('Base branch') }) -const canRunReview = computed(() => ( - props.threadId.trim().length > 0 - && props.cwd.trim().length > 0 - && snapshot.value?.isGitRepo === true - && !props.isThreadInProgress - && !(activeScope.value === 'baseBranch' && !snapshot.value?.baseBranch) -)) - const showBulkActions = computed(() => ( activeScope.value === 'workspace' && snapshot.value?.isGitRepo === true @@ -927,29 +910,6 @@ async function initializeGit(): Promise { } } -async function runReview(): Promise { - if (!canRunReview.value || isRunningReview.value) return - reviewError.value = '' - reviewStatusLabel.value = activeScope.value === 'workspace' - ? 'Reviewing current changes' - : `Reviewing against ${snapshot.value?.baseBranch ?? 'base branch'}` - isRunningReview.value = true - pendingReviewKey.value = reviewKey.value - - try { - await startThreadReview( - props.threadId, - activeScope.value, - workspaceView.value, - selectedBaseBranch.value || (snapshot.value?.baseBranch ?? null), - ) - } catch (error) { - isRunningReview.value = false - reviewStatusLabel.value = '' - reviewError.value = error instanceof Error ? error.message : 'Failed to start review' - } -} - function formatFindingLocation(finding: UiReviewFinding): string { if (!finding.absolutePath) return '' const lineSuffix = finding.startLine ? `:${finding.startLine}${finding.endLine && finding.endLine !== finding.startLine ? `-${finding.endLine}` : ''}` : '' @@ -1138,7 +1098,6 @@ onBeforeUnmount(() => { .review-pane-close, .review-pane-mobile-files-button, .review-pane-refresh, -.review-pane-run, .review-pane-bulk-button, .review-pane-row-button, .review-pane-primary-cta { @@ -1210,10 +1169,6 @@ onBeforeUnmount(() => { @apply flex shrink-0 items-center gap-1.5; } -.review-pane-run { - @apply border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700; -} - .review-pane-refresh { @apply border-amber-300 bg-amber-50 text-amber-900 hover:bg-amber-100; } @@ -1576,8 +1531,7 @@ onBeforeUnmount(() => { .review-pane-close, .review-pane-mobile-files-button, - .review-pane-refresh, - .review-pane-run { + .review-pane-refresh { @apply px-2.5 py-1 text-[12px]; } @@ -1621,8 +1575,7 @@ onBeforeUnmount(() => { @apply w-auto gap-1; } - .review-pane-refresh, - .review-pane-run { + .review-pane-refresh { @apply px-2.5 py-1 text-[12px]; } diff --git a/src/composables/useUiLanguage.ts b/src/composables/useUiLanguage.ts index 954ebec9..393269a7 100644 --- a/src/composables/useUiLanguage.ts +++ b/src/composables/useUiLanguage.ts @@ -153,8 +153,6 @@ const zhCN: Record = { 'Compare': '比较', 'Branch': '分支', 'Changes': '更改', - 'Reviewing…': '审查中…', - 'Run review': '运行审查', 'Loading review state': '加载审查状态中', 'This folder is not a Git repository': '此文件夹不是 Git 仓库', 'Initialize Git to review local changes and run Codex review.': '初始化 Git 后即可审查本地更改并运行 Codex review。', @@ -169,7 +167,7 @@ const zhCN: Record = { 'Summary': '摘要', 'No structured findings yet': '暂时没有结构化发现', 'The latest review only returned summary text.': '最近一次审查仅返回了摘要文本。', - 'Run review to populate this pane.': '运行审查后会填充此面板。', + 'No review results yet.': '暂时没有审查结果。', 'Findings': '发现', 'Added': '新增', 'Deleted': '删除', diff --git a/tests.md b/tests.md index ea991078..88a81494 100644 --- a/tests.md +++ b/tests.md @@ -788,7 +788,7 @@ Thread header Git dropdown replaces the simple review action with a commits/bran 3. Confirm the menu initially shows only commits on the left and branches on the right, with no commit-files panel before a commit is selected. 4. Confirm the left column defaults to the current branch and shows no more than 50 recent commits with short SHA, subject, and date. 5. Confirm the open dropdown visually layers above the sidebar and above the Review pane if the pane is already open. -6. Click `Review` and confirm the dropdown closes and the review pane opens; reopen the dropdown, click `Review` again, and confirm the dropdown closes and the pane toggles closed. +6. Click `Review` and confirm the dropdown closes and the review pane opens without showing a `Run review` button; reopen the dropdown, click `Review` again, and confirm the dropdown closes and the pane toggles closed. 7. Type part of a commit subject or short SHA in the left commit search and confirm the commit list filters. 8. Turn off `Reset-history refs` and confirm the commit list reloads without saved reset-history refs. 9. Turn `Reset-history refs` back on and confirm saved reset-history commits reappear when available. @@ -815,6 +815,7 @@ Thread header Git dropdown replaces the simple review action with a commits/bran - Each selected-commit file row shows added and removed line counts, using `-` for binary or unavailable counts. - The dropdown layer is viewport-positioned and appears above the sidebar and the already-open Review pane while it is open. - Clicking the dropdown `Review` row always closes the dropdown after toggling the Review pane. +- The Review pane toolbar keeps `Refresh` but does not show a `Run review` button. - The current branch commit list loads by default and is capped at 50 commits. - The commit list can be searched by SHA, subject, or date without changing the selected branch. - Reset-history refs can be shown or hidden from the commit list without changing the selected branch. From 3b9f424fd106c8de660b65849c461a237d80a34b Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 11 May 2026 09:50:48 +0700 Subject: [PATCH 12/27] Remove findings from review pane --- src/components/content/ReviewPane.vue | 194 +------------------------- src/composables/useUiLanguage.ts | 4 - src/types/codex.ts | 1 - tests.md | 4 +- 4 files changed, 9 insertions(+), 194 deletions(-) diff --git a/src/components/content/ReviewPane.vue b/src/components/content/ReviewPane.vue index 2a0e4700..7c043915 100644 --- a/src/components/content/ReviewPane.vue +++ b/src/components/content/ReviewPane.vue @@ -7,7 +7,7 @@
    -
    -
    -
    {{ t('Compare') }} @@ -120,7 +105,7 @@ vs {{ snapshot.baseBranch }}
    -
    +
    -
    -
    -

    Summary

    -
    {{ currentReviewResult.summary }}
    -
    - -
    - -
    - -
    -

    No structured findings yet

    -

    - {{ currentReviewResult?.summary ? 'The latest review only returned summary text.' : 'No review results yet.' }} -

    -
    -
    -
    ('changes') const activeScope = ref('workspace') const workspaceView = ref('unstaged') const snapshot = ref(null) @@ -435,22 +385,14 @@ const selectedHunkId = ref('') const isFileSheetOpen = ref(false) const isLoadingSnapshot = ref(false) const isApplyingAction = ref(false) -const isRunningReview = ref(false) const isInitializingGit = ref(false) const snapshotError = ref('') const reviewError = ref('') const reviewStatusLabel = ref('') -const reviewResultsByKey = ref>({}) -const pendingReviewKey = ref('') const hunkRefs = new Map() let stopNotifications: (() => void) | null = null let stopResizeTracking: (() => void) | null = null -const reviewTabs = [ - { value: 'changes' as const, label: t('Changes') }, - { value: 'findings' as const, label: t('Findings') }, -] - type ReviewTreeFolderNode = { kind: 'folder' treeKey: string @@ -485,8 +427,6 @@ type MutableReviewTreeFolder = { files: MutableReviewTreeFile[] } -const reviewKey = computed(() => `${activeScope.value}:${workspaceView.value}`) -const currentReviewResult = computed(() => reviewResultsByKey.value[reviewKey.value] ?? null) const selectedFile = computed(() => snapshot.value?.files.find((file) => file.id === selectedFileId.value) ?? snapshot.value?.files[0] ?? null) const folderExpansionState = ref>({}) @@ -825,26 +765,8 @@ async function loadSnapshot(): Promise { } } -async function loadLatestReviewResult(): Promise { - if (!props.threadId.trim()) return - try { - const reviewState = await getThreadReviewResult(props.threadId) - if (reviewState.result) { - reviewResultsByKey.value = { - ...reviewResultsByKey.value, - [reviewKey.value]: reviewState.result, - } - } - } catch { - // Keep the pane usable even if thread history refresh fails. - } -} - async function reloadAll(): Promise { - await Promise.all([ - loadSnapshot(), - loadLatestReviewResult(), - ]) + await loadSnapshot() } function selectFile(fileId: string): void { @@ -910,54 +832,12 @@ async function initializeGit(): Promise { } } -function formatFindingLocation(finding: UiReviewFinding): string { - if (!finding.absolutePath) return '' - const lineSuffix = finding.startLine ? `:${finding.startLine}${finding.endLine && finding.endLine !== finding.startLine ? `-${finding.endLine}` : ''}` : '' - return `${finding.absolutePath}${lineSuffix}` -} - -function findMatchingHunk(file: UiReviewFile, finding: UiReviewFinding): UiReviewHunk | null { - if (!finding.startLine) return file.hunks[0] ?? null - for (const hunk of file.hunks) { - if (hunk.newStart !== null) { - const newEnd = hunk.newStart + Math.max(hunk.newLineCount, 1) - 1 - if (finding.startLine >= hunk.newStart && finding.startLine <= newEnd) { - return hunk - } - } - if (hunk.oldStart !== null) { - const oldEnd = hunk.oldStart + Math.max(hunk.oldLineCount, 1) - 1 - if (finding.startLine >= hunk.oldStart && finding.startLine <= oldEnd) { - return hunk - } - } - } - return file.hunks[0] ?? null -} - async function scrollToHunk(hunkId: string): Promise { await nextTick() const element = hunkRefs.get(hunkId) element?.scrollIntoView({ block: 'center', behavior: 'smooth' }) } -async function openFinding(finding: UiReviewFinding): Promise { - activeTab.value = 'changes' - const file = snapshot.value?.files.find((entry) => ( - entry.absolutePath === finding.absolutePath - || entry.previousAbsolutePath === finding.absolutePath - )) ?? null - if (!file) return - - expandFileAncestors(file.id) - selectedFileId.value = file.id - const matchedHunk = findMatchingHunk(file, finding) - selectedHunkId.value = matchedHunk?.id ?? '' - if (matchedHunk?.id) { - await scrollToHunk(matchedHunk.id) - } -} - function handleNotification(notification: RpcNotification): void { if (extractNotificationThreadId(notification) !== props.threadId) return const params = notification.params !== null && typeof notification.params === 'object' && !Array.isArray(notification.params) @@ -969,30 +849,12 @@ function handleNotification(notification: RpcNotification): void { const itemType = typeof item?.type === 'string' ? item.type : '' if (notification.method === 'item/started' && itemType === 'enteredReviewMode') { - isRunningReview.value = true reviewStatusLabel.value = typeof item?.review === 'string' ? item.review : 'Review in progress' return } if (notification.method === 'item/completed' && itemType === 'exitedReviewMode') { - const targetKey = pendingReviewKey.value || reviewKey.value - isRunningReview.value = false reviewStatusLabel.value = '' - void getThreadReviewResult(props.threadId) - .then((reviewState) => { - if (!reviewState.result) return - reviewResultsByKey.value = { - ...reviewResultsByKey.value, - [targetKey]: reviewState.result, - } - activeTab.value = 'findings' - }) - .catch((error) => { - reviewError.value = error instanceof Error ? error.message : 'Failed to load review result' - }) - .finally(() => { - pendingReviewKey.value = '' - }) } } @@ -1001,8 +863,6 @@ watch( () => { selectedFileId.value = '' selectedHunkId.value = '' - reviewResultsByKey.value = {} - pendingReviewKey.value = '' reviewError.value = '' reviewStatusLabel.value = '' void reloadAll() @@ -1112,10 +972,6 @@ onBeforeUnmount(() => { @apply flex flex-col gap-2 border-b border-zinc-100 px-3 py-2.5; } -.review-pane-toolbar-tabs { - @apply min-w-0; -} - .review-pane-toolbar-controls { @apply flex flex-wrap items-center gap-2; } @@ -1140,10 +996,6 @@ onBeforeUnmount(() => { @apply inline-flex min-w-0 items-center gap-1 rounded-full bg-zinc-100 p-1; } -.review-pane-segmented-primary { - @apply flex-1 bg-zinc-100/80; -} - .review-pane-segmented-button { @apply relative min-w-0 rounded-full border border-transparent px-2.5 py-1.25 text-[11px] font-medium text-zinc-500 transition-colors; } @@ -1197,8 +1049,7 @@ onBeforeUnmount(() => { @apply bg-rose-100 text-rose-700; } -.review-pane-content, -.review-pane-findings { +.review-pane-content { @apply min-h-0 flex-1 overflow-hidden; } @@ -1261,8 +1112,7 @@ onBeforeUnmount(() => { @apply bg-sky-500; } -.review-pane-file, -.review-pane-finding { +.review-pane-file { @apply flex w-full flex-col gap-0.75 rounded-xl border border-transparent px-2.5 py-2 text-left transition hover:border-zinc-200 hover:bg-white; } @@ -1340,8 +1190,7 @@ onBeforeUnmount(() => { } .review-pane-file-subtitle, -.review-pane-hunk-meta, -.review-pane-finding-location { +.review-pane-hunk-meta { @apply m-0 text-[11px] text-zinc-500; } @@ -1419,35 +1268,10 @@ onBeforeUnmount(() => { @apply overflow-x-auto rounded-2xl border border-zinc-200 bg-zinc-950 p-3 text-xs text-zinc-100; } -.review-pane-raw-diff pre, -.review-pane-summary-text { +.review-pane-raw-diff pre { @apply m-0 whitespace-pre-wrap break-all font-mono; } -.review-pane-summary-card { - @apply mx-3 mt-3 rounded-2xl border border-zinc-200 bg-zinc-50 px-3 py-2.5; -} - -.review-pane-summary-title { - @apply m-0 mb-2 text-sm font-medium text-zinc-900; -} - -.review-pane-findings-list { - @apply flex h-full flex-col gap-2.5 overflow-y-auto px-3 py-3; -} - -.review-pane-finding { - @apply border-zinc-200 bg-white hover:border-zinc-300; -} - -.review-pane-finding-title { - @apply text-sm font-medium text-zinc-900; -} - -.review-pane-finding-body { - @apply text-sm text-zinc-600 whitespace-pre-wrap; -} - .review-pane-empty { @apply flex h-full min-h-0 flex-col items-center justify-center px-6 text-center; } @@ -1678,10 +1502,6 @@ onBeforeUnmount(() => { @apply flex-row items-center gap-2.5; } - .review-pane-toolbar-tabs { - @apply flex-1; - } - .review-pane-toolbar-controls { @apply min-w-0 flex-nowrap; } diff --git a/src/composables/useUiLanguage.ts b/src/composables/useUiLanguage.ts index 393269a7..8357eb76 100644 --- a/src/composables/useUiLanguage.ts +++ b/src/composables/useUiLanguage.ts @@ -165,10 +165,6 @@ const zhCN: Record = { 'No merge diff found against the base branch.': '未找到相对基础分支的合并 diff。', 'No unified diff available.': '没有可用的统一 diff。', 'Summary': '摘要', - 'No structured findings yet': '暂时没有结构化发现', - 'The latest review only returned summary text.': '最近一次审查仅返回了摘要文本。', - 'No review results yet.': '暂时没有审查结果。', - 'Findings': '发现', 'Added': '新增', 'Deleted': '删除', 'Renamed': '重命名', diff --git a/src/types/codex.ts b/src/types/codex.ts index ec3b629b..97b07d6e 100644 --- a/src/types/codex.ts +++ b/src/types/codex.ts @@ -112,7 +112,6 @@ export type UiFileChange = { removedLineCount: number } -export type UiReviewTab = 'changes' | 'findings' export type UiReviewScope = 'workspace' | 'baseBranch' export type UiReviewWorkspaceView = 'unstaged' | 'staged' export type UiReviewAction = 'stage' | 'unstage' | 'revert' diff --git a/tests.md b/tests.md index 88a81494..97e6ec89 100644 --- a/tests.md +++ b/tests.md @@ -788,7 +788,7 @@ Thread header Git dropdown replaces the simple review action with a commits/bran 3. Confirm the menu initially shows only commits on the left and branches on the right, with no commit-files panel before a commit is selected. 4. Confirm the left column defaults to the current branch and shows no more than 50 recent commits with short SHA, subject, and date. 5. Confirm the open dropdown visually layers above the sidebar and above the Review pane if the pane is already open. -6. Click `Review` and confirm the dropdown closes and the review pane opens without showing a `Run review` button; reopen the dropdown, click `Review` again, and confirm the dropdown closes and the pane toggles closed. +6. Click `Review` and confirm the dropdown closes and the review pane opens directly to changes without showing a `Findings` tab or `Run review` button; reopen the dropdown, click `Review` again, and confirm the dropdown closes and the pane toggles closed. 7. Type part of a commit subject or short SHA in the left commit search and confirm the commit list filters. 8. Turn off `Reset-history refs` and confirm the commit list reloads without saved reset-history refs. 9. Turn `Reset-history refs` back on and confirm saved reset-history commits reappear when available. @@ -815,7 +815,7 @@ Thread header Git dropdown replaces the simple review action with a commits/bran - Each selected-commit file row shows added and removed line counts, using `-` for binary or unavailable counts. - The dropdown layer is viewport-positioned and appears above the sidebar and the already-open Review pane while it is open. - Clicking the dropdown `Review` row always closes the dropdown after toggling the Review pane. -- The Review pane toolbar keeps `Refresh` but does not show a `Run review` button. +- The Review pane toolbar keeps `Refresh` but does not show a `Findings` tab or `Run review` button. - The current branch commit list loads by default and is capped at 50 commits. - The commit list can be searched by SHA, subject, or date without changing the selected branch. - Reset-history refs can be shown or hidden from the commit list without changing the selected branch. From 8686611ca91154816bd3e1e6b0fc60baa1f92e7d Mon Sep 17 00:00:00 2001 From: Igor Date: Mon, 11 May 2026 09:59:33 +0700 Subject: [PATCH 13/27] Open commit files in review pane --- src/App.vue | 18 +++++++-- src/api/codexGateway.ts | 8 +++- .../content/HeaderGitBranchDropdown.vue | 5 ++- src/components/content/ReviewPane.vue | 29 +++++++++++---- src/composables/useUiLanguage.ts | 1 + src/server/reviewGit.ts | 37 ++++++++++++++++--- src/types/codex.ts | 3 +- tests.md | 4 +- 8 files changed, 82 insertions(+), 23 deletions(-) diff --git a/src/App.vue b/src/App.vue index 9082ba81..afa90e0b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -553,7 +553,7 @@ :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" @@ -926,6 +926,7 @@ :cwd="composerCwd" :is-thread-in-progress="isSelectedThreadInProgress" :initial-file-path="reviewInitialFilePath" + :commit-sha="reviewInitialCommitSha" @close="isReviewPaneOpen = false" /> @@ -1440,6 +1441,7 @@ 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) @@ -3277,13 +3279,21 @@ function loadThreadCommitFiles(sha: string): void { }) } -function onOpenContentHeaderCommitFile(filePath: string): void { - const targetPath = filePath.trim() - if (!targetPath) return +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 78d9e1cd..2d06cb9a 100644 --- a/src/api/codexGateway.ts +++ b/src/api/codexGateway.ts @@ -946,7 +946,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 { @@ -961,6 +962,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) ?? '', @@ -2771,11 +2773,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) { diff --git a/src/components/content/HeaderGitBranchDropdown.vue b/src/components/content/HeaderGitBranchDropdown.vue index 0359dd5c..3a3553fe 100644 --- a/src/components/content/HeaderGitBranchDropdown.vue +++ b/src/components/content/HeaderGitBranchDropdown.vue @@ -216,7 +216,7 @@ const emit = defineEmits<{ resetBranchToCommit: [payload: { branch: string; sha: string }] loadCommits: [payload: { branch: string; includeResetHistory: boolean }] loadCommitFiles: [sha: string] - openCommitFile: [path: string] + openCommitFile: [payload: { sha: string; path: string }] }>() const rootRef = ref(null) @@ -361,7 +361,8 @@ function formatFileLineCount(value: number | null): string { } function openCommitFile(filePath: string): void { - emit('openCommitFile', filePath) + if (!selectedCommit.value) return + emit('openCommitFile', { sha: selectedCommit.value.sha, path: filePath }) isOpen.value = false searchQuery.value = '' commitSearchQuery.value = '' diff --git a/src/components/content/ReviewPane.vue b/src/components/content/ReviewPane.vue index 7c043915..7a561036 100644 --- a/src/components/content/ReviewPane.vue +++ b/src/components/content/ReviewPane.vue @@ -22,7 +22,7 @@
    -
    +
    {{ t('Compare') }}
    -
    +
    {{ t('Branch') }}