From c20b2b85798276894e59da881c902495296bef55 Mon Sep 17 00:00:00 2001 From: Martin Laporte Date: Fri, 2 Oct 2026 08:55:31 +0200 Subject: [PATCH] feat: add Claude Code Desktop launch button Opens the worktree in Claude Desktop, resuming its latest Claude Code session or creating one named after the worktree. Co-Authored-By: Claude Opus 5.5 --- packages/treebeard/src/bun/index.ts | 5 +- .../src/bun/services/launcher.test.ts | 101 +++++++++++++++++- .../treebeard/src/bun/services/launcher.ts | 67 ++++++++++++ .../treebeard/src/components/ClaudeIcon.tsx | 12 +++ .../src/components/LaunchButtons.test.tsx | 17 ++- .../src/components/LaunchButtons.tsx | 10 ++ packages/treebeard/src/shared/rpc-types.ts | 4 + 7 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 packages/treebeard/src/components/ClaudeIcon.tsx diff --git a/packages/treebeard/src/bun/index.ts b/packages/treebeard/src/bun/index.ts index fb9cdd1..25686f0 100644 --- a/packages/treebeard/src/bun/index.ts +++ b/packages/treebeard/src/bun/index.ts @@ -20,7 +20,7 @@ import { } from './services/git' import { getPRForBranch, getPRStackDetails, getPRStackSummary } from './services/github' import { getJiraIssue, getMyJiraIssues } from './services/jira' -import { launchGhostty, launchIde, launchOpencode, launchPippinShell, launchURL } from './services/launcher' +import { launchClaudeDesktop, launchGhostty, launchIde, launchOpencode, launchPippinShell, launchURL } from './services/launcher' import { getShellEnv } from './services/shell-env' import { attachWorkspaceWorktree, @@ -254,6 +254,9 @@ const mainviewRPC = BrowserView.defineRPC({ 'launch:opencode': ({ worktreePath }) => { launchOpencode(worktreePath) }, + 'launch:claudeDesktop': ({ worktreePath }) => { + launchClaudeDesktop(worktreePath) + }, 'launch:url': async ({ url }) => { if (Utils.openExternal(url)) { return { success: true } diff --git a/packages/treebeard/src/bun/services/launcher.test.ts b/packages/treebeard/src/bun/services/launcher.test.ts index f3e22eb..cc92069 100644 --- a/packages/treebeard/src/bun/services/launcher.test.ts +++ b/packages/treebeard/src/bun/services/launcher.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it, vi } from 'vitest' -import { launchGhostty, launchIde, launchPippinShell } from './launcher' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ensureClaudeSessionTitle, findLatestClaudeSession, launchClaudeDesktop, launchGhostty, launchIde, launchPippinShell } from './launcher' import { setBunSpawnQueue } from '../../test/bun' vi.mock('./shell-env', () => ({ @@ -62,4 +65,98 @@ describe('launcher service', () => { expect.objectContaining({ stdout: 'ignore', stderr: 'ignore' }) ) }) + + describe('Claude Desktop', () => { + const SESSION_A = '11111111-1111-1111-1111-111111111111' + const SESSION_B = '22222222-2222-2222-2222-222222222222' + let configDir: string + + const writeSession = (worktreePath: string, name: string, mtime: number, content = '{}\n') => { + const projectDir = path.join(configDir, 'projects', worktreePath.replace(/[^a-zA-Z0-9]/g, '-')) + fs.mkdirSync(projectDir, { recursive: true }) + const file = path.join(projectDir, name) + fs.writeFileSync(file, content) + fs.utimesSync(file, mtime, mtime) + return file + } + + beforeEach(() => { + configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'treebeard-claude-')) + vi.stubEnv('CLAUDE_CONFIG_DIR', configDir) + }) + + afterEach(() => { + vi.unstubAllEnvs() + fs.rmSync(configDir, { recursive: true, force: true }) + }) + + it('finds the most recently modified session for the folder', () => { + writeSession('/repo/my_worktree.v2', `${SESSION_A}.jsonl`, 1000) + writeSession('/repo/my_worktree.v2', `${SESSION_B}.jsonl`, 2000) + writeSession('/repo/my_worktree.v2', 'not-a-session.jsonl', 3000) + + expect(findLatestClaudeSession('/repo/my_worktree.v2')?.id).toBe(SESSION_B) + }) + + it('returns null when the folder has no sessions', () => { + expect(findLatestClaudeSession('/repo/unknown')).toBeNull() + }) + + it('names an untitled session after the worktree', () => { + const file = writeSession('/repo/feat-x', `${SESSION_A}.jsonl`, 1000) + + ensureClaudeSessionTitle({ id: SESSION_A, file }, 'feat-x') + + const lines = fs.readFileSync(file, 'utf-8').trim().split('\n') + expect(JSON.parse(lines[lines.length - 1])).toEqual({ type: 'custom-title', customTitle: 'feat-x', sessionId: SESSION_A }) + }) + + it('keeps a title the user already set', () => { + const content = `{"type":"custom-title","customTitle":"my name","sessionId":"${SESSION_A}"}\n` + const file = writeSession('/repo/feat-x', `${SESSION_A}.jsonl`, 1000, content) + + ensureClaudeSessionTitle({ id: SESSION_A, file }, 'feat-x') + + expect(fs.readFileSync(file, 'utf-8')).toBe(content) + }) + + it('resumes the latest session via deep link', async () => { + writeSession('/repo/worktree', `${SESSION_A}.jsonl`, 1000) + const spawn = setBunSpawnQueue([{ stdout: '' }]) + + await launchClaudeDesktop('/repo/worktree') + + expect(spawn).toHaveBeenCalledWith( + ['/usr/bin/open', `claude://resume?session=${SESSION_A}`], + expect.objectContaining({ stdout: 'ignore', stderr: 'ignore' }) + ) + }) + + it('creates a session named after the worktree when none exists', async () => { + const spawn = setBunSpawnQueue([{ stdout: '/usr/local/bin/claude\n' }, { stdout: '' }, { stdout: '' }]) + + await launchClaudeDesktop('/repo/feat-x') + + const createCall = spawn.mock.calls[1] as unknown as [string[], { cwd: string }] + const [command, options] = createCall + expect(command.slice(0, 3)).toEqual(['/usr/local/bin/claude', '-p', '--session-id']) + expect(command[4]).toBe('/rename feat-x') + expect(options.cwd).toBe('/repo/feat-x') + expect(spawn).toHaveBeenLastCalledWith( + ['/usr/bin/open', `claude://resume?session=${command[3]}`], + expect.objectContaining({ stdout: 'ignore', stderr: 'ignore' }) + ) + }) + + it('opens the folder for an untitled session when the claude CLI is missing', async () => { + const spawn = setBunSpawnQueue([{ stdout: '' }, { stdout: '' }]) + + await launchClaudeDesktop('/repo/worktree') + + expect(spawn).toHaveBeenLastCalledWith( + ['/usr/bin/open', '-a', 'Claude', '/repo/worktree'], + expect.objectContaining({ stdout: 'ignore', stderr: 'ignore' }) + ) + }) + }) }) diff --git a/packages/treebeard/src/bun/services/launcher.ts b/packages/treebeard/src/bun/services/launcher.ts index 2243bc3..2a44e5b 100644 --- a/packages/treebeard/src/bun/services/launcher.ts +++ b/packages/treebeard/src/bun/services/launcher.ts @@ -1,8 +1,17 @@ +import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' import { getShellEnv } from './shell-env' import { IDE_REGISTRY } from '../../shared/ide-registry' import type { IdeId } from '../../shared/types' +export interface ClaudeSession { + id: string + file: string +} + +const SESSION_FILE_REGEX = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i + /** Launch the configured IDE for a given worktree path */ export async function launchIde(ideId: IdeId, worktreePath: string): Promise { const ide = IDE_REGISTRY[ideId] @@ -109,6 +118,64 @@ end tell Bun.spawn(['/usr/bin/osascript', '-e', script], { stdout: 'ignore', stderr: 'ignore' }) } +/** Find the most recently active Claude Code session transcript for a folder */ +export function findLatestClaudeSession(worktreePath: string): ClaudeSession | null { + const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') + // Claude Code stores transcripts per folder, naming the folder with every non-alphanumeric char replaced by '-' + const projectDir = path.join(configDir, 'projects', worktreePath.replace(/[^a-zA-Z0-9]/g, '-')) + try { + let latest: (ClaudeSession & { mtimeMs: number }) | null = null + for (const entry of fs.readdirSync(projectDir)) { + const match = SESSION_FILE_REGEX.exec(entry) + if (!match) continue + const file = path.join(projectDir, entry) + const { mtimeMs } = fs.statSync(file) + if (!latest || mtimeMs > latest.mtimeMs) latest = { id: match[1], file, mtimeMs } + } + return latest ? { id: latest.id, file: latest.file } : null + } catch { + return null + } +} + +/** Name an untitled Claude Code session, leaving titles set via /rename untouched */ +export function ensureClaudeSessionTitle(session: ClaudeSession, title: string): void { + try { + if (fs.readFileSync(session.file, 'utf-8').includes('"type":"custom-title"')) return + // Same entry the CLI appends for /rename; Claude Desktop reads it when importing the session + const entry = JSON.stringify({ type: 'custom-title', customTitle: title, sessionId: session.id }) + fs.appendFileSync(session.file, `${entry}\n`) + } catch {} +} + +async function createNamedClaudeSession(worktreePath: string, title: string): Promise { + const env = await getShellEnv() + const whichProc = Bun.spawn(['which', 'claude'], { stdout: 'pipe', stderr: 'ignore', env }) + const claudePath = (await new Response(whichProc.stdout).text()).trim() + if (!claudePath) return null + const sessionId = crypto.randomUUID() + // /rename runs locally without a model turn, leaving a titled transcript Desktop can import + const proc = Bun.spawn([claudePath, '-p', '--session-id', sessionId, `/rename ${title}`], { + cwd: worktreePath, + stdout: 'ignore', + stderr: 'ignore', + env + }) + return (await proc.exited) === 0 ? sessionId : null +} + +/** Open the worktree in Claude Desktop, resuming its latest Claude Code session or starting one named after the worktree */ +export async function launchClaudeDesktop(worktreePath: string): Promise { + const title = path.basename(worktreePath) + const session = findLatestClaudeSession(worktreePath) + if (session) ensureClaudeSessionTitle(session, title) + const sessionId = session?.id ?? (await createNamedClaudeSession(worktreePath, title)) + // claude://resume imports the CLI session (or reuses Desktop's copy of it); opening a + // bare folder starts an untitled Code session there instead + const args = sessionId ? [`claude://resume?session=${sessionId}`] : ['-a', 'Claude', worktreePath] + Bun.spawn(['/usr/bin/open', ...args], { stdout: 'ignore', stderr: 'ignore' }) +} + export async function launchURL(url: string): Promise { const proc = Bun.spawn(['/usr/bin/open', url], { stdout: 'pipe', diff --git a/packages/treebeard/src/components/ClaudeIcon.tsx b/packages/treebeard/src/components/ClaudeIcon.tsx new file mode 100644 index 0000000..8e85ceb --- /dev/null +++ b/packages/treebeard/src/components/ClaudeIcon.tsx @@ -0,0 +1,12 @@ +interface ClaudeIconProps { + size?: number +} + +/** Claude logo, from Simple Icons (CC0) since Tabler has no Claude brand icon */ +export function ClaudeIcon({ size = 16 }: ClaudeIconProps) { + return ( + + + + ) +} diff --git a/packages/treebeard/src/components/LaunchButtons.test.tsx b/packages/treebeard/src/components/LaunchButtons.test.tsx index 8e3c0b4..95015d0 100644 --- a/packages/treebeard/src/components/LaunchButtons.test.tsx +++ b/packages/treebeard/src/components/LaunchButtons.test.tsx @@ -5,12 +5,14 @@ import { renderWithMantine } from '../test/render' const launchIdeRequest = vi.fn() const launchGhosttyRequest = vi.fn() +const launchClaudeDesktopRequest = vi.fn() vi.mock('../rpc', () => ({ rpc: () => ({ request: { 'launch:ide': launchIdeRequest, 'launch:ghostty': launchGhosttyRequest, + 'launch:claudeDesktop': launchClaudeDesktopRequest, } }) })) @@ -19,8 +21,10 @@ describe('LaunchButtons', () => { beforeEach(() => { launchIdeRequest.mockReset() launchGhosttyRequest.mockReset() + launchClaudeDesktopRequest.mockReset() launchIdeRequest.mockResolvedValue(undefined) launchGhosttyRequest.mockResolvedValue(undefined) + launchClaudeDesktopRequest.mockResolvedValue(undefined) Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } }) @@ -30,7 +34,7 @@ describe('LaunchButtons', () => { renderWithMantine() const buttons = screen.getAllByRole('button') - expect(buttons).toHaveLength(3) + expect(buttons).toHaveLength(4) fireEvent.click(buttons[0]) fireEvent.click(buttons[1]) @@ -38,12 +42,21 @@ describe('LaunchButtons', () => { expect(launchGhosttyRequest).toHaveBeenCalledWith({ worktreePath: '/repo/worktrees/feat' }) }) - it('copies the worktree path to clipboard', () => { + it('opens the worktree in Claude Code Desktop', () => { renderWithMantine() const buttons = screen.getAllByRole('button') fireEvent.click(buttons[2]) + expect(launchClaudeDesktopRequest).toHaveBeenCalledWith({ worktreePath: '/repo/worktrees/feat' }) + }) + + it('copies the worktree path to clipboard', () => { + renderWithMantine() + + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[3]) + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('/repo/worktrees/feat') }) diff --git a/packages/treebeard/src/components/LaunchButtons.tsx b/packages/treebeard/src/components/LaunchButtons.tsx index 64d4a9a..15153e3 100644 --- a/packages/treebeard/src/components/LaunchButtons.tsx +++ b/packages/treebeard/src/components/LaunchButtons.tsx @@ -1,5 +1,6 @@ import { ActionIcon, Group, Tooltip } from '@mantine/core' import { IconCopy, IconGhost } from '@tabler/icons-react' +import { ClaudeIcon } from './ClaudeIcon' import { IdeIcon } from './IdeIcon' import { IDE_REGISTRY } from '../shared/ide-registry' import { rpc } from '../rpc' @@ -21,6 +22,10 @@ export function LaunchButtons({ worktreePath, defaultIde }: LaunchButtonsProps) await rpc().request['launch:ghostty']({ worktreePath }) } + const handleClaudeDesktop = async () => { + await rpc().request['launch:claudeDesktop']({ worktreePath }) + } + const handleCopyPath = () => { void navigator.clipboard.writeText(worktreePath) } @@ -37,6 +42,11 @@ export function LaunchButtons({ worktreePath, defaultIde }: LaunchButtonsProps) + + + + + diff --git a/packages/treebeard/src/shared/rpc-types.ts b/packages/treebeard/src/shared/rpc-types.ts index 81c754c..92027a0 100644 --- a/packages/treebeard/src/shared/rpc-types.ts +++ b/packages/treebeard/src/shared/rpc-types.ts @@ -129,6 +129,10 @@ export type TreebeardRPC = { params: { worktreePath: string } response: void } + 'launch:claudeDesktop': { + params: { worktreePath: string } + response: void + } 'launch:url': { params: { url: string } response: { success: boolean; error?: string }