feat: add support for Claude Code CLI worktrees

- Introduced CLAUDE_CODE_DIR constant for the .claude/worktrees/ directory.
- Updated GitIdentityResolver to recognize and handle worktrees from Claude Code CLI.
- Enhanced WorktreeBadge and DashboardView to display appropriate labels for Claude Code worktrees.
- Updated type definitions and source labels to include 'claude-code' for better integration.
- Improved session handling in DateGroupedSessions and FileSectionDiff components to accommodate new worktree type.
This commit is contained in:
iliya 2026-03-01 13:47:33 +02:00
parent d7c9e87c40
commit 6bdacd26af
8 changed files with 125 additions and 35 deletions

View file

@ -42,3 +42,6 @@ export const CLAUDE_WORKTREES_DIR = '.claude-worktrees';
/** ccswitch worktrees directory */
export const CCSWITCH_DIR = '.ccswitch';
/** Claude Code CLI worktrees directory (.claude/worktrees/) */
export const CLAUDE_CODE_DIR = '.claude';

View file

@ -18,6 +18,7 @@
import {
AUTO_CLAUDE_DIR,
CCSWITCH_DIR,
CLAUDE_CODE_DIR,
CLAUDE_WORKTREES_DIR,
CONDUCTOR_DIR,
CURSOR_DIR,
@ -242,6 +243,15 @@ class GitIdentityResolver {
return parts[claudeWorktreesIdx + 1];
}
// Pattern 6b: /.claude/worktrees/{name} (Claude Code CLI)
const claudeCodeDirIdx = parts.indexOf(CLAUDE_CODE_DIR);
if (claudeCodeDirIdx >= 0 && parts[claudeCodeDirIdx + 1] === WORKTREES_DIR) {
// Repo is the directory containing .claude
if (claudeCodeDirIdx > 0) {
return parts[claudeCodeDirIdx - 1];
}
}
// Pattern 7: /.ccswitch/worktrees/{repo}/{name}
const ccswitchIdx = parts.indexOf(CCSWITCH_DIR);
if (ccswitchIdx >= 0 && parts[ccswitchIdx + 1] === WORKTREES_DIR) {
@ -282,6 +292,11 @@ class GitIdentityResolver {
if (parts.includes(CCSWITCH_DIR) && parts.includes(WORKTREES_DIR)) {
return true;
}
// Pattern: .claude/worktrees/{name} (Claude Code CLI worktrees)
const claudeCodeIdx = parts.indexOf(CLAUDE_CODE_DIR);
if (claudeCodeIdx >= 0 && parts[claudeCodeIdx + 1] === WORKTREES_DIR) {
return true;
}
if (parts.includes(CONDUCTOR_DIR) && parts.includes(WORKSPACES_DIR)) {
// Subpaths in conductor/workspaces are worktrees
const conductorIdx = parts.indexOf(CONDUCTOR_DIR);
@ -569,6 +584,15 @@ class GitIdentityResolver {
return 'ccswitch';
}
// Pattern: claude-code (Claude Code CLI)
// /Users/.../.claude/worktrees/{name}
{
const claudeCodeIdx = parts.indexOf(CLAUDE_CODE_DIR);
if (claudeCodeIdx >= 0 && parts[claudeCodeIdx + 1] === WORKTREES_DIR) {
return 'claude-code';
}
}
// Check if it's a standard git repo (only if filesystem exists)
// For deleted repos, we'll return 'git' as fallback since we can't verify
if (await fileExists(path.join(projectPath, '.git'))) {
@ -668,6 +692,19 @@ class GitIdentityResolver {
break;
}
case 'claude-code': {
// Pattern: .claude/worktrees/{name}
// Display: {name} (e.g., "editor-feature")
const claudeCodeDirIdx = parts.indexOf(CLAUDE_CODE_DIR);
if (claudeCodeDirIdx >= 0) {
const worktreesIdx = parts.indexOf(WORKTREES_DIR, claudeCodeDirIdx);
if (worktreesIdx >= 0 && parts[worktreesIdx + 1]) {
return parts[worktreesIdx + 1];
}
}
break;
}
case 'git':
// Standard git worktree - use branch or path-based name
if (isMainWorktree) {

View file

@ -147,6 +147,7 @@ export type WorktreeSource =
| 'auto-claude' // /Users/.../.auto-claude/worktrees/tasks/{task-id}
| '21st' // /Users/.../.21st/worktrees/{id}/{name [bracket-id]}
| 'claude-desktop' // /Users/.../.claude-worktrees/{repo}/{name}
| 'claude-code' // /Users/.../.claude/worktrees/{name}
| 'ccswitch' // /Users/.../.ccswitch/worktrees/{repo}/{name}
| 'git' // Standard git worktree (main repo or detached)
| 'unknown'; // Non-git project or undetectable

View file

@ -52,6 +52,11 @@ const SOURCE_CONFIG: Record<WorktreeSource, SourceConfig> = {
bgColor: WORKTREE_BADGE_BG,
textColor: WORKTREE_BADGE_TEXT,
},
'claude-code': {
label: 'Worktree',
bgColor: WORKTREE_BADGE_BG,
textColor: WORKTREE_BADGE_TEXT,
},
ccswitch: {
label: 'ccswitch',
bgColor: WORKTREE_BADGE_BG,

View file

@ -24,8 +24,9 @@ import { createLogger } from '@shared/utils/logger';
import { useShallow } from 'zustand/react/shallow';
const logger = createLogger('Component:DashboardView');
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { formatDistanceToNow } from 'date-fns';
import { Command, FolderGit2, FolderOpen, GitBranch, Search, Users } from 'lucide-react';
import { Command, FolderGit2, FolderOpen, GitBranch, GitFork, Search, Users } from 'lucide-react';
import { CliStatusBanner } from './CliStatusBanner';
@ -138,6 +139,41 @@ const RepositoryCard = ({
const mainWorktree = repo.worktrees.find((w) => w.isMainWorktree) ?? repo.worktrees[0];
const mainBranch = mainWorktree?.gitBranch;
// Detect if this is a worktree project:
// 1. No main worktree in the group (isMainWorktree flag)
// 2. OR the shown worktree has a tool-created source
// 3. OR path-based fallback for .claude/worktrees/ directories
const WORKTREE_PATH_MARKERS = [
'/.claude/worktrees/',
'/.claude-worktrees/',
'/.auto-claude/worktrees/',
'/.21st/worktrees/',
'/.ccswitch/worktrees/',
'/.cursor/worktrees/',
'/vibe-kanban/worktrees/',
'/conductor/workspaces/',
];
const shownWorktree = repo.worktrees[0];
const isWorktreeBySource =
shownWorktree?.source && !['git', 'unknown'].includes(shownWorktree.source);
const isWorktreeByPath =
shownWorktree && WORKTREE_PATH_MARKERS.some((m) => shownWorktree.path.includes(m));
const isWorktreeProject =
!repo.worktrees.some((w) => w.isMainWorktree) || isWorktreeBySource || isWorktreeByPath;
// Get the source label for worktree badge
const SOURCE_LABELS: Record<string, string> = {
'vibe-kanban': 'Vibe',
conductor: 'Conductor',
'auto-claude': 'Auto',
'21st': '21st',
'claude-desktop': 'Desktop',
'claude-code': 'Worktree',
ccswitch: 'ccswitch',
};
const worktreeSourceLabel = shownWorktree?.source && SOURCE_LABELS[shownWorktree.source];
const color = useMemo(() => projectColor(repo.name), [repo.name]);
const cardRef = useRef<HTMLButtonElement>(null);
const [isHovered, setIsHovered] = useState(false);
@ -171,30 +207,49 @@ const RepositoryCard = ({
{/* Icon + Project name */}
<div className="mb-1 flex items-center gap-2.5">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors duration-300 group-hover:border-border-emphasis">
<FolderGit2
className="size-4 transition-colors group-hover:text-text"
style={{ color: color.icon }}
/>
{isWorktreeProject ? (
<GitFork
className="size-4 transition-colors group-hover:text-text"
style={{ color: color.icon }}
/>
) : (
<FolderGit2
className="size-4 transition-colors group-hover:text-text"
style={{ color: color.icon }}
/>
)}
</div>
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
{repo.name}
</h3>
{isWorktreeProject && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-purple-500/15 px-1.5 py-0.5 text-[9px] font-medium text-purple-400">
{worktreeSourceLabel ?? 'Worktree'}
</span>
)}
</div>
{/* Project path - monospace, muted, clickable to open in file manager */}
<div
role="button"
tabIndex={0}
onClick={handleOpenPath}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleOpenPath(e as unknown as React.MouseEvent);
}}
className="flex w-full min-w-0 cursor-pointer items-center gap-1 truncate text-left font-mono text-[10px] text-text-muted transition-colors hover:text-text-secondary"
title={`Open in file manager: ${projectPath}`}
>
<FolderOpen className="size-3 shrink-0" />
<span className="truncate">{formattedPath}</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div
role="button"
tabIndex={0}
onClick={handleOpenPath}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ')
handleOpenPath(e as unknown as React.MouseEvent);
}}
className="flex w-full min-w-0 cursor-pointer items-center gap-1 truncate text-left font-mono text-[10px] text-text-muted transition-colors hover:text-text-secondary"
>
<FolderOpen className="size-3 shrink-0" />
<span className="truncate">{formattedPath}</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<p className="font-mono text-[11px]">{projectPath}</p>
</TooltipContent>
</Tooltip>
{/* Git branch / worktree info */}
{mainBranch ? (

View file

@ -57,6 +57,7 @@ const SOURCE_LABELS: Record<WorktreeSource, string> = {
'auto-claude': 'Auto Claude',
'21st': '21st',
'claude-desktop': 'Claude Desktop',
'claude-code': 'Claude Code',
ccswitch: 'ccswitch',
git: 'Git',
unknown: 'Other',

View file

@ -89,8 +89,9 @@ export const FileSectionDiff = ({
return writeSnippets[writeSnippets.length - 1].newString;
})();
const hasCodeMirrorContent =
fileContent && fileContent.contentSource !== 'unavailable' && resolvedModified !== null;
// Show CodeMirror whenever we have resolved modified content (including new files
// where contentSource may be 'unavailable' but write-new snippet provides the content)
const hasCodeMirrorContent = resolvedModified !== null;
if (!hasCodeMirrorContent) {
return (
@ -105,12 +106,12 @@ export const FileSectionDiff = ({
<div className="overflow-auto">
<DiffErrorBoundary
filePath={file.filePath}
oldString={fileContent.originalFullContent ?? ''}
oldString={fileContent?.originalFullContent ?? ''}
newString={resolvedModified}
>
<CodeMirrorDiffView
key={`${file.filePath}:${discardCounter}`}
original={fileContent.originalFullContent ?? ''}
original={fileContent?.originalFullContent ?? ''}
modified={resolvedModified}
fileName={file.relativePath}
readOnly={false}

View file

@ -81,19 +81,6 @@ export const ReviewDiffContent = ({ file }: ReviewDiffContentProps) => {
return (
<div className="space-y-4 p-4">
{/* Заголовок файла */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text">{file.relativePath}</span>
{file.isNewFile && (
<span className="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400">
NEW
</span>
)}
<span className="ml-auto text-xs text-text-muted">
{nonErrorSnippets.length} change{nonErrorSnippets.length !== 1 ? 's' : ''}
</span>
</div>
{/* Snippets */}
{nonErrorSnippets.map((snippet, index) => (
<SnippetDiffView key={snippet.toolUseId} snippet={snippet} index={index} />