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, }) } }