mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
678ba18a6f
commit
3794a9d9ef
@ -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) {
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
37
desktop/src/components/shared/ProjectContextChip.test.tsx
Normal file
37
desktop/src/components/shared/ProjectContextChip.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()])
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user