From 678ba18a6f3be13575b90ac504b5884413e8f2b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 8 May 2026 17:14:20 +0800 Subject: [PATCH] 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 --- desktop/src/components/chat/ChatInput.tsx | 13 +++- src/server/__tests__/sessions.test.ts | 75 ++++++++++++++++++++++- src/server/api/sessions.ts | 8 ++- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 2f7945ac..2a261af1 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -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([]) diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 6683b94a..934ac879 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -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 { 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 { + 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 + + 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 + + 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()]) diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index f9077cea..1bc26ee9 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -534,10 +534,12 @@ function chooseRicherUsage( } async function getGitInfo(sessionId: string): Promise { - 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 { 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 { } catch { // Not a git repo or git not available return Response.json({ - branch: null, + branch: sessionBranch, repoName: null, workDir, changedFiles: 0,