diff --git a/CHANGELOG.md b/CHANGELOG.md index 07df169..ae57821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ### Added - Custom terminal configuration: `claudine.customTerminals` config property can be used to specify a custom terminal emulator and arguments for use in Standalone mode. +- Worktree support — conversations from Claude Code worktrees are now shown on the board alongside regular conversations + - worktree name is surfaced as a `worktreeName` field and WT badge on each conversation + - `claudine.monitorWorktrees` configuration property (default `true`) to toggle worktree scanning + - Worktree name is detected from `worktree-state` JSONL entries emitted by Claude Code ### Fixed - Summarization on Windows: `resolveExecutable` now uses `which` or `where` depending on platform, enabling Summarization on Windows +- Workspace path reconstruction (`ConversationParser`) — replaced the greedy hyphen-split approach with a filesystem walk; paths containing dots (`user.name`), underscores (`my_project`), or Windows drive-letter colons (`C:`) are now resolved correctly on all platforms +- Windows workspace encoding (`ClaudeCodeWatcher`) — `encodeWorkspacePath` normalizes backslashes on all platforms and applies case folding on case-insensitive platforms (Windows/macOS); fixes some conversations from Windows projects not appearing on the board - Workspace path reconstruction (`ConversationParser`) — replaced the greedy hyphen-split + `fsp.access` existence-check approach with a filesystem walk; paths containing spaces, dots (`user.name`), underscores (`my_project`), or Windows drive-letter colons (`C:`) are now resolved correctly on all platforms - Windows workspace encoding (`ClaudeCodeWatcher`) — `encodeWorkspacePath` normalizes backslashes before encoding and applies case folding on case-insensitive platforms (Windows/macOS), matching Claude Code's encoding exactly; fixes conversations from Windows projects not appearing on the board - Standalone terminal resumption on Windows — tries Windows Terminal (`wt`) before falling back to `cmd /c start` - Standalone conversation "open in terminal" dropdown immediately closing — `stopPropagation` on the button click prevents the window-level handler from dismissing the menu before it renders +- Conversation content parsing: `content` field in Claude Code messages will sometimes be a single element or a string rather than an array of expected schema; `parseMessageContent` now handles either case +- WT badge UI fix: the WT badge on Conversation cards would sometimes overflow the edge of the card ## [1.1.5] diff --git a/FEATURES.md b/FEATURES.md index a68c63d..18cbfbb 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -24,7 +24,7 @@ - [x] Last tool activity chip (e.g. `Read "path/to/file"`, `Bash "npm test"`) - [x] Activity timer counting seconds/minutes while agent is actively working - [x] Status badges: error, interruption, question/awaiting-input, rate-limit -- [x] Claude worktree badge (`wt`) on cards when a conversation belongs to `.claude/worktrees/` +- [x] Claude worktree badge (`wt`) on cards when a conversation has an active worktree session - [x] Inline "Respond" prompt input on cards needing input - [x] "Open" menu to open conversation in terminal or VS Code editor - [x] Highlight when the card's conversation is the focused editor tab @@ -112,7 +112,7 @@ - [x] Incremental parsing: only newly appended bytes read on file change - [x] LRU parse cache (200 entries max) - [x] Workspace-scoped filtering (only shows conversations for current workspace) -- [x] Claude worktree discovery under monitored workspaces (`.claude/worktrees/*`) with a `monitorWorktrees` toggle +- [x] Claude worktree discovery for monitored workspaces (project folders ending in `--claude-worktrees-*`) with a `monitorWorktrees` toggle - [x] Subagent JSONL files excluded ## State Persistence diff --git a/README.md b/README.md index 6ce491c..b8da932 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ Open VS Code Settings (`Ctrl+,` / `Cmd+,`) and search for **Claudine**. | `claudine.showTaskDescription` | `boolean` | `true` | Show description on cards | | `claudine.showTaskLatest` | `boolean` | `true` | Show last message preview on cards | | `claudine.showTaskGitBranch` | `boolean` | `true` | Show git branch badge on cards | -| `claudine.monitorWorktrees` | `boolean` | `true` | Also scan Claude Code worktrees found under each monitored workspace at `.claude/worktrees/*` | +| `claudine.monitorWorktrees` | `boolean` | `true` | Also scan Claude project folders matching `--claude-worktrees-` for monitored workspaces | | `claudine.monitoredWorkspace` | `object` | `{ mode: 'auto' }` | Monitor the current VS Code workspace, one manual path, or multiple manual paths | API keys are stored securely via VS Code's `SecretStorage` and configured through the in-app settings panel. diff --git a/src/providers/ClaudeCodeWatcher.ts b/src/providers/ClaudeCodeWatcher.ts index 6afd200..6b6aac2 100644 --- a/src/providers/ClaudeCodeWatcher.ts +++ b/src/providers/ClaudeCodeWatcher.ts @@ -171,13 +171,12 @@ export class ClaudeCodeWatcher implements IConversationProvider { try { const conversation = await this._parser.parseFile(filePath); if (conversation) { - const conversationWithWorkspace = this.applyWorkspaceMetadata(conversation, filePath); - this._summaryService.applyCached(conversationWithWorkspace); - this._stateManager.updateConversation(conversationWithWorkspace); + this._summaryService.applyCached(conversation); + this._stateManager.updateConversation(conversation); // Kick off async summarization if not cached if (!this._summaryService.hasCached(conversation.id)) { - this._summaryService.summarizeUncached([conversationWithWorkspace], (id, summary) => { + this._summaryService.summarizeUncached([conversation], (id, summary) => { const existing = this._stateManager.getConversation(id); if (existing) { this._stateManager.updateConversation({ @@ -214,21 +213,24 @@ export class ClaudeCodeWatcher implements IConversationProvider { const lowercase = process.platform === 'win32' || process.platform === 'darwin'; const encodedExcluded = this.encodeWorkspacePath(this._excludedWorkspacePath, lowercase); const normalizedFilePath = this.normalizePath(filePath, lowercase); - return normalizedFilePath.includes(`/${encodedExcluded}/`); + return normalizedFilePath.includes(`/${encodedExcluded}`); } /** BUG2: Check whether a JSONL file belongs to one of the effective workspace's * project directories. When no workspace is open, allow all files (fallback). */ private isFromCurrentWorkspace(filePath: string): boolean { - const effectiveRoots = this.getEffectiveWorkspaceRoots(); - if (!effectiveRoots || effectiveRoots.length === 0) return true; // fallback: no workspace → allow all + const effectiveFolders = this.getEffectiveWorkspaceFolders(); + if (!effectiveFolders || effectiveFolders.length === 0) return true; // fallback: no workspace → allow all - for (const folder of effectiveRoots) { + for (const folder of effectiveFolders) { if (this._excludedWorkspacePath && folder === this._excludedWorkspacePath) continue; const lowercase = process.platform === 'win32' || process.platform === 'darwin'; const encodedPath = this.encodeWorkspacePath(folder, lowercase); const normalizedFilePath = this.normalizePath(filePath, lowercase); - if (normalizedFilePath.includes(`/${encodedPath}/`)) return true; + + const includesStr = this.shouldMonitorWorktrees() ? `/${encodedPath}` : `/${encodedPath}/`; + + if (normalizedFilePath.includes(includesStr)) return true; } return false; } @@ -239,11 +241,11 @@ export class ClaudeCodeWatcher implements IConversationProvider { // Determine which project directories to scan const projectDirs = this.getProjectDirsToScan(projectsPath); - console.log(`Claudine: Scanning ${projectDirs.length} project directories`); - + for (const projectDir of projectDirs) { try { const entries = fs.readdirSync(projectDir, { withFileTypes: true }); + console.log(`Claudine: Scanning project dir: ${projectDir} (${entries.length} entries)`); for (const entry of entries) { // Only process top-level .jsonl files (session conversations) @@ -254,7 +256,10 @@ export class ClaudeCodeWatcher implements IConversationProvider { try { const conversation = await this._parser.parseFile(filePath); if (conversation) { - conversations.push(this.applyWorkspaceMetadata(conversation, filePath)); + conversations.push(conversation); + } + else { + console.log(`Claudine: Unable to parse file: ${filePath}`); } } catch (error) { console.error(`Claudine: Error parsing ${filePath}`, error); @@ -279,6 +284,9 @@ export class ClaudeCodeWatcher implements IConversationProvider { /** * Resolve the effective workspace folders based on the monitoredWorkspace setting. * Returns null when auto mode has no workspace open (scan-all fallback). + * + * When monitorWorktrees is enabled, also appends any worktree directories found under + * `/.claude/worktrees/` for each workspace folder. */ private getEffectiveWorkspaceFolders(): string[] | null { const monitored = this._platform.getWorkspaceLocalConfig<{ mode: string; path?: string; paths?: string[] }>( @@ -287,114 +295,65 @@ export class ClaudeCodeWatcher implements IConversationProvider { if (monitored.mode === 'single' && monitored.path) { return [monitored.path]; - } - if (monitored.mode === 'multi' && monitored.paths && monitored.paths.length > 0) { + } else if (monitored.mode === 'multi' && monitored.paths && monitored.paths.length > 0) { return monitored.paths; } + // Auto mode: delegate to platform return this._platform.getWorkspaceFolders(); } - /** - * Expand the monitored workspace list with Claude Code worktree roots when enabled. - * Worktree sessions live under their own encoded roots in `~/.claude/projects`. - */ - private getEffectiveWorkspaceRoots(): string[] | null { - const effectiveFolders = this.getEffectiveWorkspaceFolders(); - if (!effectiveFolders || effectiveFolders.length === 0) return effectiveFolders; - - const roots: string[] = []; - const seen = new Set(); - - const addRoot = (workspacePath: string) => { - const normalized = this.normalizeWorkspacePath(workspacePath); - if (seen.has(normalized)) return; - seen.add(normalized); - roots.push(normalized); - }; - - for (const folder of effectiveFolders) { - addRoot(folder); - if (!this.shouldMonitorWorktrees()) continue; - for (const worktreePath of this.getClaudeWorktreePaths(folder)) { - addRoot(worktreePath); - } - } - - return roots; - } - private shouldMonitorWorktrees(): boolean { return this._platform.getConfig('monitorWorktrees', true); } - private normalizeWorkspacePath(workspacePath: string): string { - const normalized = path.normalize(workspacePath); - if (normalized === path.sep) return normalized; - return normalized.replace(/[\\/]+$/, ''); - } - - private getClaudeWorktreePaths(workspacePath: string): string[] { - const worktreesDir = path.join(workspacePath, '.claude', 'worktrees'); - if (!fs.existsSync(worktreesDir)) return []; - - try { - const entries = fs.readdirSync(worktreesDir, { withFileTypes: true }); - return entries - .filter(entry => entry.isDirectory()) - .map(entry => path.join(worktreesDir, entry.name)); - } catch (error) { - console.warn(`Claudine: Could not read Claude worktrees in ${worktreesDir}`, error); - return []; + private getProjectDirsToScan(projectsPath: string, ignoreCase?: boolean): string[] { + function _projectIncluded(projectName: string): boolean { + const exclusion = ClaudeCodeWatcher.isExcludedProjectDir(projectName); + if (exclusion.excluded) { + console.log(`Claudine: Auto-excluding project dir "${projectName}" — ${exclusion.reason}`); + return false; + } + return true; + } + + function _getMatchingProjects(encodedPath: string, projectNames: string[], monitorWorktrees: boolean): string[] { + return projectNames.filter(name => { + return (monitorWorktrees && name.startsWith(encodedPath)) || + (!monitorWorktrees && name.trim() === encodedPath.trim()); + }); } - } - private getProjectDirsToScan(projectsPath: string, ignoreCase?: boolean): string[] { - const dirs: string[] = []; ignoreCase = ignoreCase ?? (process.platform === 'win32' || process.platform === 'darwin'); + let dirs: string[] = []; try { if (!fs.existsSync(projectsPath)) { console.warn(`Claudine: Projects path does not exist: ${projectsPath}`); return dirs; } - const effectiveRoots = this.getEffectiveWorkspaceRoots(); - - if (effectiveRoots && effectiveRoots.length > 0) { - // Only scan project directories that match the effective workspace roots - for (const folder of effectiveRoots) { - // Skip the extension's own workspace when running in EDH - if (this._excludedWorkspacePath && folder === this._excludedWorkspacePath) { - console.log(`Claudine: Skipping extension dev workspace: ${folder}`); - continue; - } + const projectEntries = fs.readdirSync(projectsPath, { withFileTypes: true }); + const projectNames = projectEntries + .filter(e => e.isDirectory()) + .map(e => ignoreCase ? e.name.toLowerCase() : e.name) + .filter(_projectIncluded); - const encodedPath = this.encodeWorkspacePath(folder, ignoreCase); - const projectDir = path.join(projectsPath, encodedPath); - - console.log(`Claudine: Workspace "${folder}" → encoded "${encodedPath}"`); - - if (fs.existsSync(projectDir)) { - dirs.push(projectDir); - console.log(`Claudine: Matched project dir: ${projectDir}`); - } else { - console.warn(`Claudine: No project dir found for workspace: ${projectDir}`); - } - } - } else { - // No workspace open & auto mode — scan all projects as fallback + const effectiveFolders = this.getEffectiveWorkspaceFolders(); + if (!effectiveFolders) { console.log('Claudine: No workspace folders, scanning all projects'); - const entries = fs.readdirSync(projectsPath, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const exclusion = ClaudeCodeWatcher.isExcludedProjectDir(entry.name); - if (exclusion.excluded) { - console.log(`Claudine: Auto-excluding project dir "${entry.name}" — ${exclusion.reason}`); - continue; - } - dirs.push(path.join(projectsPath, entry.name)); - } + return projectNames.map(name => path.join(projectsPath, name)); } + + const monitorWorktrees = this.shouldMonitorWorktrees(); + dirs = effectiveFolders + .map(folder => this.encodeWorkspacePath(folder, ignoreCase)) + .flatMap(encoded => _getMatchingProjects(encoded, projectNames, monitorWorktrees)) + .map(name => path.join(projectsPath, name)); + dirs = [...new Set(dirs)]; // dedup + for (const dir of dirs) { + console.log(`Claudine: matched project dir "${dir}"`); + } + } catch (error) { console.error('Claudine: Error listing project directories', error); } @@ -426,44 +385,6 @@ export class ClaudeCodeWatcher implements IConversationProvider { return normalizedWorkspacePath.replace(/[/.:_ ]/g, '-'); } - private resolveWorkspacePathForFile(filePath: string): string | undefined { - const effectiveRoots = this.getEffectiveWorkspaceRoots(); - if (!effectiveRoots || effectiveRoots.length === 0) return undefined; - - const lowercase = process.platform === 'win32' || process.platform === 'darwin'; - const normalizedFilePath = this.normalizePath(filePath, lowercase); - for (const root of effectiveRoots) { - const encodedPath = this.encodeWorkspacePath(root, lowercase); - if (normalizedFilePath.includes(`/${encodedPath}/`)) { - return root; - } - } - - return undefined; - } - - private extractWorktreeName(workspacePath: string | undefined): string | undefined { - if (!workspacePath) return undefined; - const normalized = workspacePath.replace(/\\/g, '/'); - const match = normalized.match(/\/\.claude\/worktrees\/([^/]+)(?:\/|$)/); - return match?.[1]; - } - - private applyWorkspaceMetadata(conversation: Conversation, filePath: string): Conversation { - const resolvedWorkspacePath = this.resolveWorkspacePathForFile(filePath) ?? conversation.workspacePath; - const worktreeName = this.extractWorktreeName(resolvedWorkspacePath); - - if (resolvedWorkspacePath === conversation.workspacePath && worktreeName === conversation.worktreeName) { - return conversation; - } - - return { - ...conversation, - workspacePath: resolvedWorkspacePath, - worktreeName, - }; - } - // ── Project discovery & progressive scanning (standalone) ───────── /** @@ -571,7 +492,7 @@ export class ClaudeCodeWatcher implements IConversationProvider { try { const conversation = await this._parser.parseFile(filePath); if (conversation) { - projectConvs.push(this.applyWorkspaceMetadata(conversation, filePath)); + projectConvs.push(conversation); } } catch (error) { console.error(`Claudine: Error parsing ${filePath}`, error); diff --git a/src/providers/ConversationParser.ts b/src/providers/ConversationParser.ts index 9afa1c3..2b2fbb2 100644 --- a/src/providers/ConversationParser.ts +++ b/src/providers/ConversationParser.ts @@ -37,6 +37,7 @@ interface ParseCache { firstTimestamp: string | undefined; lastTimestamp: string | undefined; gitBranch: string | undefined; + worktreeName: string | undefined; } export class ConversationParser { @@ -94,7 +95,7 @@ export class ConversationParser { // No new data — promote in LRU and rebuild from cached messages this.touchCache(filePath, cached); if (cached.messages.length === 0) return null; - return await this.buildConversation(filePath, cached.messages, cached.firstTimestamp, cached.lastTimestamp, cached.gitBranch, cached.sidechainSteps); + return await this.buildConversation(filePath, cached.messages, cached.firstTimestamp, cached.lastTimestamp, cached.gitBranch, cached.worktreeName, cached.sidechainSteps); } if (cached && cached.byteOffset < fileSize) { @@ -124,13 +125,14 @@ export class ConversationParser { firstTimestamp: undefined, lastTimestamp: undefined, gitBranch: undefined, + worktreeName: undefined, }; this.parseLines(content, cache); this.touchCache(filePath, cache); if (cache.messages.length === 0) return null; - return await this.buildConversation(filePath, cache.messages, cache.firstTimestamp, cache.lastTimestamp, cache.gitBranch, cache.sidechainSteps); + return await this.buildConversation(filePath, cache.messages, cache.firstTimestamp, cache.lastTimestamp, cache.gitBranch, cache.worktreeName, cache.sidechainSteps); } private async parseIncremental(filePath: string, cached: ParseCache, fileSize: number): Promise { @@ -149,7 +151,7 @@ export class ConversationParser { } if (cached.messages.length === 0) return null; - return await this.buildConversation(filePath, cached.messages, cached.firstTimestamp, cached.lastTimestamp, cached.gitBranch, cached.sidechainSteps); + return await this.buildConversation(filePath, cached.messages, cached.firstTimestamp, cached.lastTimestamp, cached.gitBranch, cached.worktreeName, cached.sidechainSteps); } /** Parse raw JSONL lines and accumulate results into the cache. */ @@ -172,6 +174,14 @@ export class ConversationParser { cache.gitBranch = entry.gitBranch; } + if (entry.type === 'worktree-state' && entry.worktreeSession) { + cache.worktreeName = entry.worktreeSession.worktreeName; + } + else if (entry.type === 'worktree-state' && !entry.worktreeSession) { + // Clear worktree name on worktree exit + cache.worktreeName = undefined; + } + if ((entry.type !== 'user' && entry.type !== 'assistant') || !entry.message) { continue; } @@ -256,13 +266,35 @@ export class ConversationParser { cache.sidechainSteps = Array.from(cache.sidechainAgentStatus.values()); } + /** Claude Code will sometimes collapse 'text' type content into a single string, + * or return a single content block instead of an array -- normalize these into + * array of ClaudeCodeContent + */ + private parseMessageContent(content: any): ClaudeCodeContent[] { + function _parse(c: any): ClaudeCodeContent | null { + if (typeof c === 'string') { + return { type: 'text', text: c } as ClaudeCodeContent; + } + else if (c && "type" in c && typeof c.type === 'string') { + return c as ClaudeCodeContent; + } + console.warn('Claudine: Unrecognized content block format:', c); + return null; + } + + if (!Array.isArray(content)) content = [content]; + return content + .map(_parse) + .filter((c: ClaudeCodeContent | null): c is ClaudeCodeContent => c !== null); + } + private parseMessage(entry: ClaudeCodeJsonlEntry): ParsedMessage | null { if (!entry.message) return null; const role = entry.message.role; if (role !== 'user' && role !== 'assistant') return null; - const contentBlocks: ClaudeCodeContent[] = entry.message.content || []; + const contentBlocks = this.parseMessageContent(entry.message.content); const textParts: string[] = []; const toolUses: Array<{ name: string; input: Record }> = []; @@ -417,6 +449,7 @@ export class ConversationParser { firstTimestamp: string | undefined, lastTimestamp: string | undefined, gitBranch: string | undefined, + worktreeName: string | undefined, sidechainSteps: SidechainStep[] = [] ): Promise { const id = this.extractSessionId(filePath); @@ -444,7 +477,7 @@ export class ConversationParser { const createdAt = firstTimestamp ? new Date(firstTimestamp) : new Date(); const updatedAt = lastTimestamp ? new Date(lastTimestamp) : new Date(); - + return { id, title, @@ -469,6 +502,7 @@ export class ConversationParser { updatedAt, filePath, workspacePath: await this.extractWorkspacePath(filePath), + worktreeName, provider: 'claude-code' }; } @@ -965,6 +999,13 @@ export class ConversationParser { return undefined; } + private extractProjectFolderFromFilePath(filePath: string): string | undefined { + const parts = filePath.split(path.sep); + const projectsIndex = parts.indexOf('projects'); + if (projectsIndex === -1 || !parts[projectsIndex + 1]) return undefined; + return parts[projectsIndex + 1]; + } + /** * Extract the workspace path from a conversation file path. * The encoded directory name (e.g. `-Users-matthias-Development-ai-stick`) is lossy @@ -972,20 +1013,21 @@ export class ConversationParser { * Instead of guessing, check the actual filesystem for matching paths. */ private async extractWorkspacePath(filePath: string): Promise { - const parts = filePath.split(path.sep); - const projectsIndex = parts.indexOf('projects'); - if (projectsIndex === -1 || !parts[projectsIndex + 1]) return undefined; + let projectFolder = this.extractProjectFolderFromFilePath(filePath); + if (!projectFolder) return undefined; + // Don't include worktree directory (if any) in workspace path; + // worktree conversations are still resumed from base workspace path + projectFolder = projectFolder.replace(/--claude-worktrees-.*$/, ''); + // macOS and Windows use case-insensitive filesystems; Claude Code may lowercase parts of // the encoded project directory name on these platforms. const ignoreCase = process.platform === 'win32' || process.platform === 'darwin'; - const encoded = ignoreCase - ? parts[projectsIndex + 1].toLowerCase() - : parts[projectsIndex + 1]; + projectFolder = ignoreCase ? projectFolder.toLowerCase() : projectFolder; + const roots = await this.getFilesystemRoots(); - for (const root of roots) { - const result = await this.resolveEncodedPath(encoded, root, ignoreCase); + const result = await this.resolveEncodedPath(projectFolder, root, ignoreCase); if (result !== undefined) return result; } return undefined; diff --git a/src/providers/KanbanViewProvider.ts b/src/providers/KanbanViewProvider.ts index a431593..2adc425 100644 --- a/src/providers/KanbanViewProvider.ts +++ b/src/providers/KanbanViewProvider.ts @@ -629,7 +629,6 @@ export class KanbanViewProvider implements vscode.WebviewViewProvider { showTaskDescription: config.get('showTaskDescription', true), showTaskLatest: config.get('showTaskLatest', true), showTaskGitBranch: config.get('showTaskGitBranch', true), - monitorWorktrees: config.get('monitorWorktrees', true), monitoredWorkspace: (() => { const raw = this._provider.getWorkspaceLocalConfig?.('monitoredWorkspace', { mode: 'auto' }) ?? { mode: 'auto' as const }; @@ -637,6 +636,7 @@ export class KanbanViewProvider implements vscode.WebviewViewProvider { ? raw as MonitoredWorkspace : { mode: 'auto' as const }; })(), + monitorWorktrees: config.get('monitorWorktrees', true), detectedWorkspacePaths: this._provider.getWorkspacePaths?.() ?? [], customTerminals: config.get('customTerminals', []), }; diff --git a/src/test/ClaudeCodeWatcher.perf.test.ts b/src/test/ClaudeCodeWatcher.perf.test.ts index e9a4da0..01fe9b4 100644 --- a/src/test/ClaudeCodeWatcher.perf.test.ts +++ b/src/test/ClaudeCodeWatcher.perf.test.ts @@ -298,6 +298,9 @@ describe('ClaudeCodeWatcher — regression tests', () => { ) { return [{ name: 'conv-1.jsonl', isDirectory: () => false, isFile: () => true }]; } + if (dirPath === path.join(claudePath, 'projects')) { + return [{ name: encodedDir, isDirectory: () => true, isFile: () => false }]; + } return []; }) as unknown as typeof fs.readdirSync); @@ -344,6 +347,9 @@ describe('ClaudeCodeWatcher — regression tests', () => { ) { return [{ name: 'conv-win.jsonl', isDirectory: () => false, isFile: () => true }]; } + if (dirPath === path.join(claudePath, 'projects')) { + return [{ name: encodedDir, isDirectory: () => true, isFile: () => false }]; + } return []; }) as unknown as typeof fs.readdirSync); @@ -389,6 +395,9 @@ describe('ClaudeCodeWatcher — regression tests', () => { ) { return [{ name: 'conv-mix.jsonl', isDirectory: () => false, isFile: () => true }]; } + if (dirPath === path.join(claudePath, 'projects')) { + return [{ name: encodedDir, isDirectory: () => true, isFile: () => false }]; + } return []; }) as unknown as typeof fs.readdirSync); @@ -428,6 +437,9 @@ describe('ClaudeCodeWatcher — regression tests', () => { if (typeof dirPath === 'string' && dirPath.includes(encodedDir)) { return [{ name: 'conv-linux.jsonl', isDirectory: () => false, isFile: () => true }]; } + if (dirPath === path.join(claudePath, 'projects')) { + return [{ name: encodedDir, isDirectory: () => true, isFile: () => false }]; + } return []; }) as unknown as typeof fs.readdirSync); @@ -448,17 +460,19 @@ describe('ClaudeCodeWatcher — regression tests', () => { expect(convs.length).toBe(1); }); - it('assigns worktree metadata to conversations from monitored Claude worktrees', async () => { - const workspace = '/Users/alice/projectA'; - const worktreesDir = path.join(workspace, '.claude', 'worktrees'); - const worktreePath = path.join(worktreesDir, 'feature-login'); + it('scans worktree project directories matching workspace pattern', async () => { + const workspacePath = '/Users/alice/projectA'; + const encodedDir = '-Users-alice-projectA--claude-worktrees-feature-login'; + // Should ignore directories that don't match workspace pattern + const otherEncodedDir = '-Users-alice-projectB--claude-worktrees-feature-logout'; + const ignoreCase = process.platform === 'win32' || process.platform === 'darwin'; - const encodedDir = ignoreCase - ? '-users-alice-projecta--claude-worktrees-feature-login' - : '-Users-alice-projectA--claude-worktrees-feature-login'; + const escapedSep = path.sep.replace(/\\/g, '\\\\'); + const encodedDirMatchStr = new RegExp(`${encodedDir}${escapedSep}?$`, ignoreCase ? 'i' : ''); + const otherEncodedDirMatchStr = new RegExp(`${otherEncodedDir}${escapedSep}?$`, ignoreCase ? 'i' : ''); const platform = createMockPlatform(); - platform.getWorkspaceFolders = () => [workspace]; + platform.getWorkspaceFolders = () => [workspacePath]; platform.getConfig = ((key: string, defaultValue: unknown) => { if (key === 'monitorWorktrees') return true as never; return defaultValue as never; @@ -469,18 +483,21 @@ describe('ClaudeCodeWatcher — regression tests', () => { mockExistsSync.mockImplementation(((p: string) => { if (p === projectsPath) return true; - if (p === worktreesDir) return true; - if (typeof p === 'string' && p.includes(encodedDir)) return true; + if (encodedDirMatchStr.test(p)) return true; + if (otherEncodedDirMatchStr.test(p)) return true; return false; }) as typeof fs.existsSync); mockReaddirSync.mockImplementation(((dirPath: string) => { - if (dirPath === worktreesDir) { - return [{ name: 'feature-login', isDirectory: () => true, isFile: () => false }]; + if (dirPath === projectsPath) { + return [{ name: encodedDir, isDirectory: () => true, isFile: () => false }]; } - if (typeof dirPath === 'string' && dirPath.includes(encodedDir)) { + if (encodedDirMatchStr.test(dirPath)) { return [{ name: 'conv-worktree.jsonl', isDirectory: () => false, isFile: () => true }]; } + if (otherEncodedDirMatchStr.test(dirPath)) { + return [{ name: 'conv-other.jsonl', isDirectory: () => false, isFile: () => true }]; + } return []; }) as unknown as typeof fs.readdirSync); @@ -499,8 +516,59 @@ describe('ClaudeCodeWatcher — regression tests', () => { expect(sm.setConversations).toHaveBeenCalledTimes(1); const convs = sm.setConversations.mock.calls[0][0] as Conversation[]; expect(convs).toHaveLength(1); - expect(convs[0].workspacePath).toBe(worktreePath); - expect((convs[0] as any).worktreeName).toBe('feature-login'); + expect(convs[0].id).toBe('conv-worktree'); + + let expectedFilePath = path.join(projectsPath, encodedDir, 'conv-worktree.jsonl'); + expectedFilePath = ignoreCase ? expectedFilePath.toLowerCase() : expectedFilePath; + expect(mockReadFile).toHaveBeenCalledExactlyOnceWith( + expect.toSatisfy((p: string) => { + p = ignoreCase ? p.toLowerCase() : p; + return p.includes(expectedFilePath); + }), + 'utf-8' + ); + }); + + it('does not scan worktree project directories when monitorWorktrees is disabled', async () => { + const workspacePath = '/Users/alice/projectA'; + const encodedDir = '-Users-alice-projectA--claude-worktrees-feature-login'; + + const ignoreCase = process.platform === 'win32' || process.platform === 'darwin'; + const escapedSep = path.sep.replace(/\\/g, '\\\\'); + const encodedDirMatchStr = new RegExp(`${encodedDir}${escapedSep}?$`, ignoreCase ? 'i' : ''); + + const platform = createMockPlatform(); + platform.getWorkspaceFolders = () => [workspacePath]; + platform.getConfig = ((key: string, defaultValue: unknown) => { + if (key === 'monitorWorktrees') return false as never; + return defaultValue as never; + }) as typeof platform.getConfig; + + const sm = createMockStateManager(); + const w = new ClaudeCodeWatcher(sm as never, platform); + + mockExistsSync.mockImplementation(((p: string) => { + if (p === projectsPath) return true; + if (encodedDirMatchStr.test(p)) return true; + return false; + }) as typeof fs.existsSync); + + mockReaddirSync.mockImplementation(((dirPath: string) => { + if (dirPath === projectsPath) { + return [{ name: encodedDir, isDirectory: () => true, isFile: () => false }]; + } + if (encodedDirMatchStr.test(dirPath)) { + return [{ name: 'conv-worktree.jsonl', isDirectory: () => false, isFile: () => true }]; + } + return []; + }) as unknown as typeof fs.readdirSync); + + await w.refresh(); + + expect(sm.setConversations).toHaveBeenCalledTimes(1); + const convs = sm.setConversations.mock.calls[0][0] as Conversation[]; + expect(convs).toHaveLength(0); + expect(mockReadFile).toHaveBeenCalledTimes(0); }); }); diff --git a/src/test/ConversationParser.test.ts b/src/test/ConversationParser.test.ts index a9a68ef..6c8aa6c 100644 --- a/src/test/ConversationParser.test.ts +++ b/src/test/ConversationParser.test.ts @@ -553,4 +553,112 @@ describe('ConversationParser', () => { expect(result!.lastMessage).toContain('Dark mode is now available'); }); }); + + describe('worktree detection', () => { + it('extracts worktree name from worktree-state entry', async () => { + const content = [ + fixtures.userMessage('Do some work', 10), + JSON.stringify({ + type: 'worktree-state', + uuid: crypto.randomUUID(), + timestamp: new Date().toISOString(), + sessionId: 'test-session', + parentUuid: null, + isSidechain: false, + worktreeSession: { worktreeName: 'my-feature-branch' }, + }), + fixtures.assistantMessage('Done', 9), + ].join('\n'); + const result = await parseContent(content); + expect(result).not.toBeNull(); + expect(result!.worktreeName).toBe('my-feature-branch'); + }); + + it('returns undefined worktree when no worktree-state entry', async () => { + const result = await parseContent(fixtures.completedConversation); + expect(result).not.toBeNull(); + expect(result!.worktreeName).toBeUndefined(); + }); + + it('returns undefined worktree after null worktreeSession', async () => { + const content = [ + fixtures.userMessage('Do some work', 10), + JSON.stringify({ + type: 'worktree-state', + uuid: crypto.randomUUID(), + timestamp: new Date().toISOString(), + sessionId: 'test-session', + parentUuid: null, + isSidechain: false, + worktreeSession: null, + }), + fixtures.assistantMessage('Done', 9), + ].join('\n'); + const result = await parseContent(content); + expect(result).not.toBeNull(); + expect(result!.worktreeName).toBeUndefined(); + }); + + it('parses worktree based on latest worktree-state entry', async () => { + const content = [ + fixtures.userMessage('Do some work', 10), + JSON.stringify({ + type: 'worktree-state', + uuid: crypto.randomUUID(), + timestamp: new Date().toISOString(), + sessionId: 'test-session', + parentUuid: null, + isSidechain: false, + worktreeSession: { worktreeName: 'first-branch' }, + }), + fixtures.assistantMessage('Done', 9), + fixtures.userMessage('Do some more work', 8), + JSON.stringify({ + type: 'worktree-state', + uuid: crypto.randomUUID(), + timestamp: new Date().toISOString(), + sessionId: 'test-session', + parentUuid: null, + isSidechain: false, + worktreeSession: null + }), + fixtures.assistantMessage('Done', 7) + ].join('\n'); + const result = await parseContent(content); + expect(result).not.toBeNull(); + expect(result!.worktreeName).toBeUndefined(); + }); + }); + + describe('message content normalization', () => { + it('parses string content (collapsed text format)', async () => { + const content = JSON.stringify({ + type: 'user', + uuid: crypto.randomUUID(), + timestamp: new Date().toISOString(), + sessionId: 'test-session', + parentUuid: null, + isSidechain: false, + message: { role: 'user', content: 'Plain string content' }, + }); + const result = await parseContent(content); + expect(result).not.toBeNull(); + expect(result!.title).toBe('Plain string content'); + }); + + it('parses single content block (non-array format)', async () => { + const content = JSON.stringify({ + type: 'user', + uuid: crypto.randomUUID(), + timestamp: new Date().toISOString(), + sessionId: 'test-session', + parentUuid: null, + isSidechain: false, + message: { role: 'user', content: { type: 'text', text: 'Single block content' } }, + }); + const result = await parseContent(content); + expect(result).not.toBeNull(); + expect(result!.title).toBe('Single block content'); + }); + }); }); diff --git a/src/test/StandaloneToggleSummarization.test.ts b/src/test/StandaloneToggleSummarization.test.ts index fd99a7f..52a5bcb 100644 --- a/src/test/StandaloneToggleSummarization.test.ts +++ b/src/test/StandaloneToggleSummarization.test.ts @@ -98,8 +98,8 @@ describe('BUG8 – toggleSummarization in standalone mode', () => { showTaskDescription: true, showTaskLatest: true, showTaskGitBranch: true, - monitorWorktrees: true, monitoredWorkspace: { mode: 'auto' }, + monitorWorktrees: true, detectedWorkspacePaths: [], customTerminals: [], }; diff --git a/src/types/index.ts b/src/types/index.ts index ae75d3c..b8649fc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -164,8 +164,8 @@ export interface ClaudineSettings { showTaskDescription: boolean; showTaskLatest: boolean; showTaskGitBranch: boolean; - monitorWorktrees: boolean; monitoredWorkspace: MonitoredWorkspace; + monitorWorktrees: boolean; detectedWorkspacePaths: string[]; customTerminals: CustomTerminalConfig[]; } @@ -173,7 +173,7 @@ export interface ClaudineSettings { // Claude Code data structures (based on actual file format) // Each line in a JSONL conversation file is one of these: export interface ClaudeCodeJsonlEntry { - type: 'user' | 'assistant' | 'file-history-snapshot' | 'queue-operation'; + type: 'user' | 'assistant' | 'file-history-snapshot' | 'queue-operation' | 'worktree-state'; uuid: string; timestamp: string; // ISO 8601 sessionId: string; @@ -193,6 +193,8 @@ export interface ClaudeCodeJsonlEntry { snapshot?: unknown; // queue-operation fields operation?: string; + // worktree-state fields + worktreeSession?: { worktreeName: string; [key: string]: unknown } | null; } export interface ClaudeCodeApiMessage { diff --git a/standalone/StandaloneMessageHandler.ts b/standalone/StandaloneMessageHandler.ts index 82692fd..ef56b01 100644 --- a/standalone/StandaloneMessageHandler.ts +++ b/standalone/StandaloneMessageHandler.ts @@ -318,6 +318,7 @@ export class StandaloneMessageHandler { showTaskGitBranch: this._platform.getConfig('showTaskGitBranch', true), customTerminals: this._platform.getConfig('customTerminals', []), monitoredWorkspace: this._platform.getConfig('monitoredWorkspace', { mode: 'auto' }), + monitorWorktrees: this._platform.getConfig('monitorWorktrees', true), detectedWorkspacePaths: [], }; this._send({ type: 'updateSettings', settings }); diff --git a/webview/src/components/TaskCard.svelte b/webview/src/components/TaskCard.svelte index c20410d..87c22d9 100644 --- a/webview/src/components/TaskCard.svelte +++ b/webview/src/components/TaskCard.svelte @@ -820,7 +820,7 @@ } /* Git branch + agents on same row (#10) */ - .meta-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; min-height: 24px; } + .meta-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 6px; min-height: 24px; } .git-branch { display: inline-flex; align-items: center; gap: 3px; font-size: 10px; color: var(--vscode-textLink-foreground, #3794ff); @@ -839,6 +839,8 @@ font-size: 9px; line-height: 1; white-space: nowrap; + max-width: 100%; + overflow: hidden; } .worktree-label { font-size: 8px;