fix: keep session git badge on the active CLI checkout

The chat footer previously read git-info from the persisted session file and refreshed only on tab changes. Repository launches can update the active CLI cwd after the composer has already rendered, so the footer could keep showing an older selected branch even while the running model was operating in the correct checkout.

Constraint: The active CLI process is the source of truth for a live session cwd
Rejected: Derive the footer branch only from persisted session metadata | metadata can lag behind startup and does not prove the current running cwd
Confidence: high
Scope-risk: narrow
Tested: bun test src/server/__tests__/sessions.test.ts --test-name-pattern "git-info should prefer"
Tested: cd desktop && bun run lint
Tested: git diff --check
This commit is contained in:
程序员阿江(Relakkes) 2026-05-08 17:14:20 +08:00
parent 8533bc0bf6
commit 678ba18a6f
3 changed files with 90 additions and 6 deletions

View File

@ -138,6 +138,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
],
[attachments, workspaceReferences],
)
const slashCommandCount = slashCommands.length
useEffect(() => {
textareaRef.current?.focus()
@ -174,7 +175,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
})
}, [composerPrefill])
useEffect(() => {
const refreshGitInfo = useCallback(() => {
if (!activeTabId) {
setGitInfo(null)
return
@ -186,6 +187,16 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
sessionsApi.getGitInfo(activeTabId).then(setGitInfo).catch(() => setGitInfo(null))
}, [activeTabId, isMemberSession])
useEffect(() => {
refreshGitInfo()
}, [refreshGitInfo])
useEffect(() => {
if (!activeTabId || isMemberSession || messageCount === 0) return
const timeout = setTimeout(refreshGitInfo, chatState === 'idle' ? 0 : 500)
return () => clearTimeout(timeout)
}, [activeTabId, chatState, isMemberSession, messageCount, refreshGitInfo, slashCommandCount])
useEffect(() => {
if (!isMemberSession) return
setAttachments([])

View File

@ -7,8 +7,7 @@ import * as fs from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import * as path from 'node:path'
import * as os from 'node:os'
import { SessionService } from '../services/sessionService.js'
import { sessionService } from '../services/sessionService.js'
import { SessionService, sessionService } from '../services/sessionService.js'
import {
getRepositoryContext,
prepareSessionWorkspace,
@ -39,6 +38,36 @@ async function cleanupTmpDir(): Promise<void> {
delete process.env.CLAUDE_CONFIG_DIR
}
function git(cwd: string, ...args: string[]): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
})
}
async function createCleanGitRepo(baseDir: string): Promise<string> {
const workDir = path.join(
baseDir,
`repo-${Date.now()}-${Math.random().toString(36).slice(2)}`,
)
await fs.mkdir(workDir, { recursive: true })
git(workDir, 'init')
git(workDir, 'config', 'user.email', 'sessions-api@example.com')
git(workDir, 'config', 'user.name', 'Sessions API')
git(workDir, 'checkout', '-b', 'main')
await fs.writeFile(path.join(workDir, 'README.md'), 'main\n')
git(workDir, 'add', 'README.md')
git(workDir, 'commit', '-m', 'initial')
git(workDir, 'checkout', '-b', 'feature/rail')
await fs.writeFile(path.join(workDir, 'feature.txt'), 'feature\n')
git(workDir, 'add', 'feature.txt')
git(workDir, 'commit', '-m', 'feature')
git(workDir, 'checkout', 'main')
return workDir
}
/** Write a JSONL session file with given entries. */
async function writeSessionFile(
projectDir: string,
@ -1437,6 +1466,48 @@ describe('Sessions API', () => {
])
})
it('GET /api/sessions/:id/git-info should prefer the active CLI workDir', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const activeWorktree = path.join(tmpDir, `active-feature-rail-${Date.now()}`)
git(workDir, 'worktree', 'add', activeWorktree, 'feature/rail')
const { sessionId } = await sessionService.createSession(workDir)
const sessionsMap = (conversationService as any).sessions as Map<string, { workDir: string }>
sessionsMap.set(sessionId, { workDir: activeWorktree })
try {
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/git-info`)
expect(res.status).toBe(200)
const body = (await res.json()) as { branch: string | null; workDir: string }
expect(body.workDir).toBe(activeWorktree)
expect(body.branch).toBe('feature/rail')
} finally {
sessionsMap.delete(sessionId)
}
})
it('GET /api/sessions/:id/git-info should keep the session launch branch stable', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const { sessionId } = await sessionService.createSession(
workDir,
{ branch: 'feature/rail', worktree: false },
)
const sessionsMap = (conversationService as any).sessions as Map<string, { workDir: string }>
sessionsMap.set(sessionId, { workDir })
git(workDir, 'switch', 'main')
try {
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/git-info`)
expect(res.status).toBe(200)
const body = (await res.json()) as { branch: string | null; workDir: string }
expect(body.workDir).toBe(workDir)
expect(body.branch).toBe('feature/rail')
} finally {
sessionsMap.delete(sessionId)
}
})
it('DELETE /api/sessions/:id should delete the session', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-api-test', sessionId, [makeSnapshotEntry()])

View File

@ -534,10 +534,12 @@ function chooseRicherUsage(
}
async function getGitInfo(sessionId: string): Promise<Response> {
const workDir = await sessionService.getSessionWorkDir(sessionId)
const workDir = conversationService.getSessionWorkDir(sessionId) || await sessionService.getSessionWorkDir(sessionId)
if (!workDir) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null)
const sessionBranch = launchInfo?.repository?.branch || null
try {
// Get branch name
@ -547,7 +549,7 @@ async function getGitInfo(sessionId: string): Promise<Response> {
stderr: 'pipe',
})
const branchText = await new Response(branchProc.stdout).text()
const branch = branchText.trim()
const branch = sessionBranch || branchText.trim()
// Get repo name from remote or directory
let repoName = ''
@ -586,7 +588,7 @@ async function getGitInfo(sessionId: string): Promise<Response> {
} catch {
// Not a git repo or git not available
return Response.json({
branch: null,
branch: sessionBranch,
repoName: null,
workDir,
changedFiles: 0,