Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,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.
Expand Down Expand Up @@ -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

Expand Down
113 changes: 106 additions & 7 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -520,19 +520,25 @@
:head-date="currentThreadHeadDate"
:detached="isThreadDetachedHead"
:dirty="isThreadWorktreeDirty"
:worktree-change-summary="threadWorktreeChangeSummary"
:branches="threadBranchOptions"
:commits-by-branch="threadBranchCommitsByBranch"
:commits-loading-for="threadBranchCommitsLoadingFor"
:commits-error="threadBranchCommitsError"
:commit-files-by-sha="threadCommitFilesBySha"
:commit-files-loading-for="threadCommitFilesLoadingFor"
:commit-files-error="threadCommitFilesError"
:loading="isLoadingThreadBranches"
:busy="isSwitchingThreadBranch"
:error="threadBranchError"
:review-open="isReviewPaneOpen"
:show-review="route.name === 'thread' && selectedThreadId.length > 0"
@toggle-review="isReviewPaneOpen = !isReviewPaneOpen"
@toggle-review="onToggleContentHeaderReview"
@checkout-branch="onCheckoutContentHeaderBranch"
@reset-branch-to-commit="onResetContentHeaderBranchToCommit"
@load-commits="loadThreadBranchCommits"
@load-commit-files="loadThreadCommitFiles"
@open-commit-file="onOpenContentHeaderCommitFile"
/>
</template>
</ContentHeader>
Expand Down Expand Up @@ -803,6 +809,8 @@
:thread-id="selectedThreadId"
:cwd="composerCwd"
:is-thread-in-progress="isSelectedThreadInProgress"
:initial-file-path="reviewInitialFilePath"
:commit-sha="reviewInitialCommitSha"
@close="isReviewPaneOpen = false"
/>

Expand Down Expand Up @@ -976,7 +984,9 @@ import {
createProjectlessThreadDirectory,
getGitBranchState,
getGitBranchCommits,
getGitCommitFiles,
getGitRepositoryStatus,
getReviewSnapshot,
getWorktreeBranchOptions,
getAccounts,
completeCodexLogin,
Expand All @@ -1001,7 +1011,7 @@ import {
} from './api/codexGateway'
import type { ReasoningEffort, SpeedMode, UiAccountEntry, UiRateLimitWindow, UiServerRequest, UiServerRequestReply, 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'

Expand Down Expand Up @@ -1287,24 +1297,37 @@ let threadSearchTimer: ReturnType<typeof setTimeout> | null = null
let terminalKeyboardFocusFallbackTimer: ReturnType<typeof setTimeout> | null = null
let threadBranchesRequestId = 0
let threadBranchCommitsRequestId = 0
let threadCommitFilesRequestId = 0
let threadWorktreeSummaryRequestId = 0
const defaultNewProjectName = ref('New Project (1)')
const homeDirectory = ref('')
const isSettingsOpen = ref(false)
const isAccountsSectionCollapsed = ref(loadAccountsSectionCollapsed())
const isReviewPaneOpen = ref(false)
const reviewInitialFilePath = ref('')
const reviewInitialCommitSha = ref('')
const threadBranchOptions = ref<WorktreeBranchOption[]>([])
const currentThreadBranch = ref<string | null>(null)
const currentThreadHeadSha = ref<string | null>(null)
const currentThreadHeadSubject = ref<string | null>(null)
const currentThreadHeadDate = ref<string | null>(null)
const isThreadDetachedHead = ref(false)
const isThreadWorktreeDirty = ref(false)
const threadWorktreeChangeSummary = ref({ addedLineCount: 0, removedLineCount: 0 })
const threadBranchError = ref('')
const threadBranchCommitsByBranch = ref<Record<string, GitCommitOption[]>>({})
const threadBranchCommitsLoadingFor = ref('')
const threadBranchCommitsError = ref('')
const threadCommitFilesBySha = ref<Record<string, GitCommitFileChange[]>>({})
const threadCommitFilesLoadingFor = ref('')
const threadCommitFilesError = ref('')
const isLoadingThreadBranches = ref(false)
const isSwitchingThreadBranch = ref(false)

function toThreadBranchCommitsKey(branch: string, includeResetHistory: boolean): string {
return `${branch}\u0000${includeResetHistory ? 'with-reset-history' : 'without-reset-history'}`
}

const createFolderInputRef = ref<HTMLInputElement | null>(null)
const accounts = ref<UiAccountEntry[]>([])
const isRefreshingAccounts = ref(false)
Expand Down Expand Up @@ -2879,20 +2902,47 @@ function canLoadBranchStateForCwd(cwd: string): boolean {
function resetThreadBranchState(): void {
threadBranchesRequestId += 1
threadBranchCommitsRequestId += 1
threadCommitFilesRequestId += 1
threadWorktreeSummaryRequestId += 1
threadBranchOptions.value = []
currentThreadBranch.value = null
currentThreadHeadSha.value = null
currentThreadHeadSubject.value = null
currentThreadHeadDate.value = null
isThreadDetachedHead.value = false
isThreadWorktreeDirty.value = false
threadWorktreeChangeSummary.value = { addedLineCount: 0, removedLineCount: 0 }
threadBranchCommitsByBranch.value = {}
threadBranchCommitsLoadingFor.value = ''
threadBranchCommitsError.value = ''
threadCommitFilesBySha.value = {}
threadCommitFilesLoadingFor.value = ''
threadCommitFilesError.value = ''
threadBranchError.value = ''
isLoadingThreadBranches.value = false
}

function loadThreadWorktreeChangeSummary(cwd: string): void {
const targetCwd = cwd.trim()
if (!targetCwd) {
threadWorktreeChangeSummary.value = { addedLineCount: 0, removedLineCount: 0 }
return
}
const requestId = ++threadWorktreeSummaryRequestId
void getReviewSnapshot(targetCwd, 'workspace', 'unstaged')
.then((snapshot) => {
if (requestId !== threadWorktreeSummaryRequestId || !canLoadBranchStateForCwd(targetCwd)) return
threadWorktreeChangeSummary.value = {
addedLineCount: snapshot.summary.addedLineCount,
removedLineCount: snapshot.summary.removedLineCount,
}
})
.catch(() => {
if (requestId !== threadWorktreeSummaryRequestId || !canLoadBranchStateForCwd(targetCwd)) return
threadWorktreeChangeSummary.value = { addedLineCount: 0, removedLineCount: 0 }
})
}

async function loadThreadBranches(cwd: string): Promise<void> {
const targetCwd = cwd.trim()
if (!targetCwd) {
Expand All @@ -2912,6 +2962,9 @@ async function loadThreadBranches(cwd: string): Promise<void> {
currentThreadHeadDate.value = state.headDate
isThreadDetachedHead.value = state.detached
isThreadWorktreeDirty.value = state.dirty
loadThreadWorktreeChangeSummary(targetCwd)
const defaultBranchForCommits = state.currentBranch?.trim() || state.options[0]?.value?.trim() || ''
if (defaultBranchForCommits) loadThreadBranchCommits({ branch: defaultBranchForCommits, includeResetHistory: true })
} catch {
if (requestId !== threadBranchesRequestId || !canLoadBranchStateForCwd(targetCwd)) return
threadBranchOptions.value = []
Expand All @@ -2921,6 +2974,7 @@ async function loadThreadBranches(cwd: string): Promise<void> {
currentThreadHeadDate.value = null
isThreadDetachedHead.value = false
isThreadWorktreeDirty.value = false
threadWorktreeChangeSummary.value = { addedLineCount: 0, removedLineCount: 0 }
} finally {
if (requestId === threadBranchesRequestId) {
isLoadingThreadBranches.value = false
Expand All @@ -2935,6 +2989,7 @@ function applyThreadGitState(state: { currentBranch: string | null; headSha: str
currentThreadHeadDate.value = state.headDate
isThreadDetachedHead.value = state.detached
isThreadWorktreeDirty.value = state.dirty
loadThreadWorktreeChangeSummary(composerCwd.value)
}

function onCheckoutContentHeaderBranch(value: string): void {
Expand Down Expand Up @@ -2992,20 +3047,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) => {
Expand All @@ -3019,6 +3076,48 @@ function loadThreadBranchCommits(branch: string): void {
})
}

function loadThreadCommitFiles(sha: string): void {
const targetSha = sha.trim()
const cwd = composerCwd.value.trim()
if (!targetSha || !cwd || threadCommitFilesLoadingFor.value === targetSha) return
if (threadCommitFilesBySha.value[targetSha]) return
const requestId = ++threadCommitFilesRequestId
threadCommitFilesLoadingFor.value = targetSha
threadCommitFilesError.value = ''
void getGitCommitFiles(cwd, targetSha)
.then((files) => {
if (requestId !== threadCommitFilesRequestId || !canLoadBranchStateForCwd(cwd)) return
threadCommitFilesBySha.value = {
...threadCommitFilesBySha.value,
[targetSha]: files,
}
})
.catch((error: unknown) => {
if (requestId !== threadCommitFilesRequestId || !canLoadBranchStateForCwd(cwd)) return
threadCommitFilesError.value = error instanceof Error ? error.message : 'Failed to load commit files'
})
.finally(() => {
if (requestId === threadCommitFilesRequestId && threadCommitFilesLoadingFor.value === targetSha) {
threadCommitFilesLoadingFor.value = ''
}
})
}

function onOpenContentHeaderCommitFile(payload: { sha: string; path: string }): void {
const targetPath = payload.path.trim()
const targetSha = payload.sha.trim()
if (!targetPath || !targetSha) return
reviewInitialFilePath.value = targetPath
reviewInitialCommitSha.value = targetSha
isReviewPaneOpen.value = true
}

function onToggleContentHeaderReview(): void {
reviewInitialFilePath.value = ''
reviewInitialCommitSha.value = ''
isReviewPaneOpen.value = !isReviewPaneOpen.value
}

async function onCreateProject(): Promise<void> {
const baseDir = await resolveProjectBaseDirectory()
if (!baseDir) return
Expand Down
53 changes: 50 additions & 3 deletions src/api/codexGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,15 @@ export type GitCommitOption = {
date: string
}

export type GitCommitFileChange = {
path: string
previousPath: string | null
status: string
label: string
addedLineCount: number | null
removedLineCount: number | null
}

export type GitRepositoryStatus = {
isGitRepo: boolean
gitRoot: string
Expand Down Expand Up @@ -879,7 +888,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 {
Expand All @@ -894,6 +904,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) ?? '',
Expand Down Expand Up @@ -2560,11 +2571,15 @@ export async function checkoutGitBranch(cwd: string, branch: string): Promise<st
return typeof branchName === 'string' && branchName.trim() ? branchName.trim() : null
}

export async function getGitBranchCommits(cwd: string, branch: string): Promise<GitCommitOption[]> {
export async function getGitBranchCommits(cwd: string, branch: string, options: { includeResetHistory?: boolean } = {}): Promise<GitCommitOption[]> {
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) {
Expand All @@ -2583,6 +2598,34 @@ export async function getGitBranchCommits(cwd: string, branch: string): Promise<
})
}

export async function getGitCommitFiles(cwd: string, sha: string): Promise<GitCommitFileChange[]> {
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<string, unknown>
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() : ''
const addedLineCount = typeof record.addedLineCount === 'number' && Number.isFinite(record.addedLineCount) ? record.addedLineCount : null
const removedLineCount = typeof record.removedLineCount === 'number' && Number.isFinite(record.removedLineCount) ? record.removedLineCount : null
if (!path || !status) return []
return [{ path, previousPath, status, label: label || status, addedLineCount, removedLineCount }]
})
}

export async function resetGitBranchToCommit(cwd: string, branch: string, sha: string): Promise<GitBranchState> {
const response = await fetch('/codex-api/git/reset-to-commit', {
method: 'POST',
Expand Down Expand Up @@ -2617,11 +2660,15 @@ export async function getReviewSnapshot(
scope: UiReviewScope,
workspaceView: UiReviewWorkspaceView,
baseBranch?: string | null,
commitSha?: string | null,
): Promise<UiReviewSnapshot> {
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) {
Expand Down
2 changes: 1 addition & 1 deletion src/components/content/ContentHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ defineProps<{
@reference "tailwindcss";

.content-header {
@apply relative z-10 w-full min-w-0 min-h-12 sm:min-h-14 flex items-center gap-2 sm:gap-3 px-2 sm:px-3 pt-3 sm:pt-4 pb-2 bg-white;
@apply relative z-[250] w-full min-w-0 min-h-12 sm:min-h-14 flex items-center gap-2 sm:gap-3 px-2 sm:px-3 pt-3 sm:pt-4 pb-2 bg-white;
}

.content-title {
Expand Down
Loading