Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/treebeard/src/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -254,6 +254,9 @@ const mainviewRPC = BrowserView.defineRPC<TreebeardRPC>({
'launch:opencode': ({ worktreePath }) => {
launchOpencode(worktreePath)
},
'launch:claudeDesktop': ({ worktreePath }) => {
launchClaudeDesktop(worktreePath)
},
'launch:url': async ({ url }) => {
if (Utils.openExternal(url)) {
return { success: true }
Expand Down
101 changes: 99 additions & 2 deletions packages/treebeard/src/bun/services/launcher.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down Expand Up @@ -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' })
)
})
})
})
67 changes: 67 additions & 0 deletions packages/treebeard/src/bun/services/launcher.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const ide = IDE_REGISTRY[ideId]
Expand Down Expand Up @@ -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<string | null> {
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<void> {
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<void> {
const proc = Bun.spawn(['/usr/bin/open', url], {
stdout: 'pipe',
Expand Down
12 changes: 12 additions & 0 deletions packages/treebeard/src/components/ClaudeIcon.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z" />
</svg>
)
}
17 changes: 15 additions & 2 deletions packages/treebeard/src/components/LaunchButtons.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
})
}))
Expand All @@ -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) }
})
Expand All @@ -30,20 +34,29 @@ describe('LaunchButtons', () => {
renderWithMantine(<LaunchButtons worktreePath={'/repo/worktrees/feat'} defaultIde="vscode" />)

const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(3)
expect(buttons).toHaveLength(4)
fireEvent.click(buttons[0])
fireEvent.click(buttons[1])

expect(launchIdeRequest).toHaveBeenCalledWith({ ideId: 'vscode', worktreePath: '/repo/worktrees/feat' })
expect(launchGhosttyRequest).toHaveBeenCalledWith({ worktreePath: '/repo/worktrees/feat' })
})

it('copies the worktree path to clipboard', () => {
it('opens the worktree in Claude Code Desktop', () => {
renderWithMantine(<LaunchButtons worktreePath={'/repo/worktrees/feat'} defaultIde="vscode" />)

const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[2])

expect(launchClaudeDesktopRequest).toHaveBeenCalledWith({ worktreePath: '/repo/worktrees/feat' })
})

it('copies the worktree path to clipboard', () => {
renderWithMantine(<LaunchButtons worktreePath={'/repo/worktrees/feat'} defaultIde="vscode" />)

const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[3])

expect(navigator.clipboard.writeText).toHaveBeenCalledWith('/repo/worktrees/feat')
})

Expand Down
10 changes: 10 additions & 0 deletions packages/treebeard/src/components/LaunchButtons.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)
}
Expand All @@ -37,6 +42,11 @@ export function LaunchButtons({ worktreePath, defaultIde }: LaunchButtonsProps)
<IconGhost size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Open in Claude Code Desktop">
<ActionIcon variant="subtle" color="orange" size="sm" onClick={handleClaudeDesktop}>
<ClaudeIcon size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Copy path">
<ActionIcon variant="subtle" color="gray" size="sm" onClick={handleCopyPath}>
<IconCopy size={16} />
Expand Down
4 changes: 4 additions & 0 deletions packages/treebeard/src/shared/rpc-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading