From 3794a9d9ef7a3c990b29a9f480f10691d4d88d8d 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 18:08:30 +0800 Subject: [PATCH] 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 --- desktop/src/api/sessions.ts | 17 ++++++- desktop/src/components/chat/ChatInput.tsx | 8 +++- .../shared/ProjectContextChip.test.tsx | 37 +++++++++++++++ .../components/shared/ProjectContextChip.tsx | 40 +++++++++++++++- src/server/__tests__/sessions.test.ts | 47 +++++++++++++++++++ src/server/api/sessions.ts | 15 +++++- 6 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 desktop/src/components/shared/ProjectContextChip.test.tsx diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index 06ff0e06..bb7ad9f9 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -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(`/api/sessions/${sessionId}/git-info`) }, getSlashCommands(sessionId: string) { diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 2a261af1..087efe07 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -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} /> ) : ( diff --git a/desktop/src/components/shared/ProjectContextChip.test.tsx b/desktop/src/components/shared/ProjectContextChip.test.tsx new file mode 100644 index 00000000..d5ff758c --- /dev/null +++ b/desktop/src/components/shared/ProjectContextChip.test.tsx @@ -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( + , + ) + + 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( + , + ) + + expect(screen.getByText('OpenCutSkill')).toBeInTheDocument() + expect(screen.queryByText('worktree')).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/shared/ProjectContextChip.tsx b/desktop/src/components/shared/ProjectContextChip.tsx index 2e798d81..1cc58a5a 100644 --- a/desktop/src/components/shared/ProjectContextChip.tsx +++ b/desktop/src/components/shared/ProjectContextChip.tsx @@ -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 (
{branch} ) : null} + {isWorktree ? ( + <> + | + + worktree + + {worktreeName} + + ) : null}
) } diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 934ac879..faf89d71 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -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 + + 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()]) diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index 1bc26ee9..b0ee0add 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -539,7 +539,18 @@ async function getGitInfo(sessionId: string): Promise { 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 { repoName, workDir, changedFiles, + worktree, }) } catch { // Not a git repo or git not available @@ -592,6 +604,7 @@ async function getGitInfo(sessionId: string): Promise { repoName: null, workDir, changedFiles: 0, + worktree, }) } }