fix: surface worktree identity in session badge

The footer project chip previously kept showing only the source project and selected branch even after a session launched inside an isolated CLI worktree. That made an active worktree session look identical to a normal checkout, especially while agent tools were already operating under .claude/worktrees. The server now returns explicit worktree metadata from the session launch intent, and the desktop chip preserves the user-facing project and branch while appending the isolated worktree slug.

Constraint: CLI worktree branches are internal implementation details and should not replace the user-selected branch label.

Rejected: Show only the actual worktree git branch | it would expose worktree-desktop-* internals and make the business branch harder to read.

Confidence: high

Scope-risk: narrow

Directive: Keep normal checkout footer rendering unchanged; only add worktree identity when session metadata says isolation is enabled.

Tested: bun test src/server/__tests__/sessions.test.ts --test-name-pattern git-info

Tested: cd desktop && bun run test -- src/components/shared/ProjectContextChip.test.tsx

Tested: cd desktop && bun run lint

Tested: git diff --check
This commit is contained in:
程序员阿江(Relakkes) 2026-05-08 18:08:30 +08:00
parent 678ba18a6f
commit 3794a9d9ef
6 changed files with 158 additions and 6 deletions

View File

@ -8,6 +8,21 @@ type MessagesResponse = {
taskNotifications?: AgentTaskNotification[]
}
type CreateSessionResponse = { sessionId: string; workDir?: string }
export type SessionGitWorktreeInfo = {
enabled: boolean
path: string | null
plannedPath: string | null
sourceWorkDir: string | null
slug: string | null
branch: string | null
}
export type SessionGitInfo = {
branch: string | null
repoName: string | null
workDir: string
changedFiles: number
worktree: SessionGitWorktreeInfo | null
}
export type CreateSessionRepositoryOptions = {
branch?: string | null
worktree?: boolean
@ -309,7 +324,7 @@ export const sessionsApi = {
},
getGitInfo(sessionId: string) {
return api.get<{ branch: string | null; repoName: string | null; workDir: string; changedFiles: number }>(`/api/sessions/${sessionId}/git-info`)
return api.get<SessionGitInfo>(`/api/sessions/${sessionId}/git-info`)
},
getSlashCommands(sessionId: string) {

View File

@ -12,7 +12,7 @@ import {
useWorkspaceChatContextStore,
type WorkspaceChatReference,
} from '../../stores/workspaceChatContextStore'
import { sessionsApi } from '../../api/sessions'
import { sessionsApi, type SessionGitInfo } from '../../api/sessions'
import { PermissionModeSelector } from '../controls/PermissionModeSelector'
import { ModelSelector } from '../controls/ModelSelector'
import type { AttachmentRef } from '../../types/chat'
@ -30,7 +30,7 @@ import {
resolveSlashUiAction,
} from './composerUtils'
type GitInfo = { branch: string | null; repoName: string | null; workDir: string; changedFiles: number }
type GitInfo = SessionGitInfo
type Attachment = {
id: string
@ -981,6 +981,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
workDir={resolvedWorkDir}
repoName={gitInfo?.repoName || null}
branch={gitInfo?.branch || null}
sourceWorkDir={gitInfo?.worktree?.sourceWorkDir || null}
isWorktree={!!gitInfo?.worktree?.enabled}
worktreeSlug={gitInfo?.worktree?.slug || null}
worktreePath={gitInfo?.worktree?.path || gitInfo?.worktree?.plannedPath || null}
compact={compact}
/>
) : (

View File

@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import '@testing-library/jest-dom'
import { ProjectContextChip } from './ProjectContextChip'
describe('ProjectContextChip', () => {
it('keeps the source project label and adds worktree identity', () => {
render(
<ProjectContextChip
workDir="/workspace/OpenCutSkill/.claude/worktrees/desktop-main-54a09f85"
sourceWorkDir="/workspace/OpenCutSkill"
repoName={null}
branch="main"
isWorktree
worktreeSlug="desktop-main-54a09f85"
/>,
)
expect(screen.getByText('OpenCutSkill')).toBeInTheDocument()
expect(screen.getByText('main')).toBeInTheDocument()
expect(screen.getByText('worktree')).toBeInTheDocument()
expect(screen.getByText('desktop-main-54a09f85')).toBeInTheDocument()
})
it('does not show worktree details for a normal checkout', () => {
render(
<ProjectContextChip
workDir="/workspace/OpenCutSkill"
repoName={null}
branch="main"
/>,
)
expect(screen.getByText('OpenCutSkill')).toBeInTheDocument()
expect(screen.queryByText('worktree')).not.toBeInTheDocument()
})
})

View File

@ -2,16 +2,43 @@ type Props = {
workDir?: string | null
repoName?: string | null
branch?: string | null
sourceWorkDir?: string | null
isWorktree?: boolean
worktreeSlug?: string | null
worktreePath?: string | null
compact?: boolean
}
export function ProjectContextChip({ workDir, repoName, branch, compact = false }: Props) {
const label = branch ? (repoName || workDir?.split('/').pop() || '') : (workDir?.split('/').pop() || repoName || '')
function basename(path: string | null | undefined): string {
return path?.split('/').filter(Boolean).pop() || ''
}
export function ProjectContextChip({
workDir,
repoName,
branch,
sourceWorkDir,
isWorktree = false,
worktreeSlug,
worktreePath,
compact = false,
}: Props) {
const labelRoot = isWorktree ? (sourceWorkDir || workDir) : workDir
const label = branch ? (repoName || basename(labelRoot)) : (basename(labelRoot) || repoName || '')
const worktreeName = worktreeSlug || basename(worktreePath) || 'isolated'
const title = [
label,
branch ? `branch: ${branch}` : null,
isWorktree ? `worktree: ${worktreeName}` : null,
worktreePath ? `worktree cwd: ${worktreePath}` : null,
workDir ? `cwd: ${workDir}` : null,
].filter(Boolean).join('\n')
if (!label) return null
return (
<div
title={title}
className={`inline-flex max-w-full items-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)] ${
compact ? 'gap-1.5 px-3 py-1.5 text-xs' : 'gap-2 px-4 py-2 text-sm'
}`}
@ -30,6 +57,15 @@ export function ProjectContextChip({ workDir, repoName, branch, compact = false
<span className="truncate">{branch}</span>
</>
) : null}
{isWorktree ? (
<>
<span className="text-[var(--color-text-tertiary)]">|</span>
<span className="shrink-0 rounded-full border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase leading-none text-[var(--color-text-tertiary)]">
worktree
</span>
<span className="max-w-[12rem] truncate">{worktreeName}</span>
</>
) : null}
</div>
)
}

View File

@ -1508,6 +1508,53 @@ describe('Sessions API', () => {
}
})
it('GET /api/sessions/:id/git-info should include isolated worktree identity', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const { sessionId } = await sessionService.createSession(
workDir,
{ branch: 'main', worktree: true },
)
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId)
const repository = launchInfo?.repository
expect(repository?.worktreePath).toBeTruthy()
expect(repository?.worktreeBranch).toBeTruthy()
const activeWorktree = repository!.worktreePath!
git(workDir, 'worktree', 'add', '-b', repository!.worktreeBranch!, activeWorktree, 'main')
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
worktree: {
enabled: boolean
path: string | null
plannedPath: string | null
sourceWorkDir: string | null
slug: string | null
branch: string | null
} | null
}
expect(body.branch).toBe('main')
expect(body.workDir).toBe(activeWorktree)
expect(body.worktree).toEqual({
enabled: true,
path: activeWorktree,
plannedPath: activeWorktree,
sourceWorkDir: repository!.requestedWorkDir,
slug: repository!.worktreeSlug,
branch: repository!.worktreeBranch,
})
} 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

@ -539,7 +539,18 @@ async function getGitInfo(sessionId: string): Promise<Response> {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null)
const sessionBranch = launchInfo?.repository?.branch || null
const repository = launchInfo?.repository
const sessionBranch = repository?.branch || null
const worktree = repository?.worktree
? {
enabled: true,
path: workDir,
plannedPath: repository.worktreePath || null,
sourceWorkDir: repository.requestedWorkDir || repository.repoRoot || null,
slug: repository.worktreeSlug || null,
branch: repository.worktreeBranch || null,
}
: null
try {
// Get branch name
@ -584,6 +595,7 @@ async function getGitInfo(sessionId: string): Promise<Response> {
repoName,
workDir,
changedFiles,
worktree,
})
} catch {
// Not a git repo or git not available
@ -592,6 +604,7 @@ async function getGitInfo(sessionId: string): Promise<Response> {
repoName: null,
workDir,
changedFiles: 0,
worktree,
})
}
}