feat: add git worktree session support with improved UI

Implement comprehensive worktree session support allowing users to spawn sessions in temporary git worktrees. Includes backend worktree management, full-stack integration, and refined UI for session type selection.

Backend:
- Add worktree creation/removal utilities with branch management
- Track worktree metadata (basePath, branch, name, path) in session metadata
- Automatic cleanup of worktrees when sessions fail or exit
- Enhanced error handling with stderr tail logging

UI improvements:
- Redesign session type toggle with improved alignment and spacing
- Move worktree description inline with label for cleaner layout
- Add branch name input field that appears when worktree mode selected
- Auto-focus on worktree input when switching modes
- Reduce gap between radio options from gap-3 to gap-1.5
- Update descriptive text and placeholders for clarity

Integration:
- Thread worktree parameters through API client, RPC handlers, and daemon
- Add worktreeEnv utility to read worktree info from environment
- Update session spawning to support both simple and worktree modes
This commit is contained in:
weishu
2025-12-28 13:17:58 +08:00
parent 0eab45c49c
commit c35a5ce827
21 changed files with 605 additions and 69 deletions
+4 -2
View File
@@ -231,11 +231,13 @@ export class ApiClient {
machineId: string,
directory: string,
agent?: 'claude' | 'codex' | 'gemini',
yolo?: boolean
yolo?: boolean,
sessionType?: 'simple' | 'worktree',
worktreeName?: string
): Promise<SpawnResponse> {
return await this.request<SpawnResponse>(`/api/machines/${encodeURIComponent(machineId)}/spawn`, {
method: 'POST',
body: JSON.stringify({ directory, agent, yolo })
body: JSON.stringify({ directory, agent, yolo, sessionType, worktreeName })
})
}
}
+86 -2
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ApiClient } from '@/api/client'
import type { Machine } from '@/types/api'
import { Button } from '@/components/ui/button'
@@ -8,6 +8,7 @@ import { useSpawnSession } from '@/hooks/mutations/useSpawnSession'
import { useRecentPaths } from '@/hooks/useRecentPaths'
type AgentType = 'claude' | 'codex' | 'gemini'
type SessionType = 'simple' | 'worktree'
function getMachineTitle(machine: Machine): string {
if (machine.metadata?.displayName) return machine.metadata.displayName
@@ -31,7 +32,17 @@ export function NewSession(props: {
const [directory, setDirectory] = useState('')
const [agent, setAgent] = useState<AgentType>('claude')
const [yoloMode, setYoloMode] = useState(false)
const [sessionType, setSessionType] = useState<SessionType>('simple')
const [worktreeName, setWorktreeName] = useState('')
const [error, setError] = useState<string | null>(null)
const worktreeInputRef = useRef<HTMLInputElement>(null)
// Focus worktree input when switching to worktree mode
useEffect(() => {
if (sessionType === 'worktree') {
worktreeInputRef.current?.focus()
}
}, [sessionType])
// Initialize with last used machine or first available
useEffect(() => {
@@ -84,7 +95,9 @@ export function NewSession(props: {
machineId,
directory: directory.trim(),
agent,
yolo: yoloMode
yolo: yoloMode,
sessionType,
worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined
})
if (result.type === 'success') {
@@ -170,6 +183,77 @@ export function NewSession(props: {
)}
</div>
{/* Session Type */}
<div className="flex flex-col gap-1.5 px-3 py-3">
<label className="text-xs font-medium text-[var(--app-hint)]">
Session type
</label>
<div className="flex flex-col gap-1.5">
{(['simple', 'worktree'] as const).map((type) => (
<div key={type} className="flex flex-col gap-2">
{type === 'worktree' ? (
<div className="flex items-center gap-2">
<input
id="session-type-worktree"
type="radio"
name="sessionType"
value="worktree"
checked={sessionType === 'worktree'}
onChange={() => setSessionType('worktree')}
disabled={isFormDisabled}
className="accent-[var(--app-link)]"
/>
<div className="flex-1">
<div className="min-h-[34px] flex items-center">
{sessionType === 'worktree' ? (
<input
ref={worktreeInputRef}
type="text"
placeholder="Branch name (optional)"
value={worktreeName}
onChange={(e) => setWorktreeName(e.target.value)}
disabled={isFormDisabled}
className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-60"
/>
) : (
<>
<label
htmlFor="session-type-worktree"
className="text-sm capitalize cursor-pointer"
>
Worktree
</label>
<span className="ml-2 text-xs text-[var(--app-hint)]">
Create a new git worktree next to the repo
</span>
</>
)}
</div>
</div>
</div>
) : (
<label className="flex items-center gap-2 cursor-pointer min-h-[34px]">
<input
id="session-type-simple"
type="radio"
name="sessionType"
value="simple"
checked={sessionType === 'simple'}
onChange={() => setSessionType('simple')}
disabled={isFormDisabled}
className="accent-[var(--app-link)]"
/>
<span className="text-sm capitalize">Simple</span>
<span className="text-xs text-[var(--app-hint)]">
Use the selected directory as-is
</span>
</label>
)}
</div>
))}
</div>
</div>
{/* Agent Selector */}
<div className="flex flex-col gap-1.5 px-3 py-3">
<label className="text-xs font-medium text-[var(--app-hint)]">
+2
View File
@@ -42,6 +42,7 @@ export function SessionHeader(props: {
onViewFiles?: () => void
}) {
const title = useMemo(() => getSessionTitle(props.session), [props.session])
const worktreeBranch = props.session.metadata?.worktree?.branch
// In Telegram, don't render header (Telegram provides its own)
if (isTelegramApp()) {
@@ -79,6 +80,7 @@ export function SessionHeader(props: {
</div>
<div className="text-xs text-[var(--app-hint)] truncate">
{props.session.metadata?.path ?? props.session.id}
{worktreeBranch ? ` • worktree: ${worktreeBranch}` : ''}
</div>
</div>
+3
View File
@@ -162,6 +162,9 @@ export function SessionList(props: {
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--app-hint)]">
<span> {getAgentLabel(s)}</span>
<span>model: {getModelLabel(s)}</span>
{s.metadata?.worktree?.branch ? (
<span>worktree: {s.metadata.worktree.branch}</span>
) : null}
{(() => {
const lastSeen = getLastSeenLabel(s)
if (!lastSeen) return null
+77 -1
View File
@@ -6,6 +6,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { usePlatform } from '@/hooks/usePlatform'
import { useSpawnSession } from '@/hooks/mutations/useSpawnSession'
type SessionType = 'simple' | 'worktree'
function getMachineTitle(machine: Machine | null): string {
if (!machine) return 'Machine'
if (machine.metadata?.displayName) return machine.metadata.displayName
@@ -22,6 +24,8 @@ export function SpawnSession(props: {
}) {
const { haptic } = usePlatform()
const [directory, setDirectory] = useState('')
const [sessionType, setSessionType] = useState<SessionType>('simple')
const [worktreeName, setWorktreeName] = useState('')
const [error, setError] = useState<string | null>(null)
const { spawnSession, isPending, error: spawnError } = useSpawnSession(props.api)
@@ -33,7 +37,12 @@ export function SpawnSession(props: {
setError(null)
try {
const result = await spawnSession({ machineId: props.machineId, directory: trimmed })
const result = await spawnSession({
machineId: props.machineId,
directory: trimmed,
sessionType,
worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined
})
if (result.type === 'success') {
haptic.notification('success')
props.onSuccess(result.sessionId)
@@ -66,6 +75,73 @@ export function SpawnSession(props: {
className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)]"
/>
<div className="flex flex-col gap-2">
<label className="text-xs font-medium text-[var(--app-hint)]">
Session type
</label>
<div className="flex flex-col gap-3 text-sm">
{(['simple', 'worktree'] as const).map((type) => (
<div key={type} className="flex flex-col gap-2">
{type === 'worktree' ? (
<div className="flex items-start gap-2">
<input
id="session-type-worktree"
type="radio"
name="sessionType"
value="worktree"
checked={sessionType === 'worktree'}
onChange={() => setSessionType('worktree')}
disabled={isPending}
className="mt-1 accent-[var(--app-link)]"
/>
<div className="flex-1">
<div className="min-h-[34px] flex items-center">
{sessionType === 'worktree' ? (
<input
type="text"
placeholder="feature-x (default 1228-xxxx)"
value={worktreeName}
onChange={(e) => setWorktreeName(e.target.value)}
disabled={isPending}
className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-60"
/>
) : (
<label
htmlFor="session-type-worktree"
className="capitalize cursor-pointer"
>
Worktree
</label>
)}
</div>
<span className={`block text-xs text-[var(--app-hint)] ${sessionType === 'worktree' ? 'invisible' : ''}`}>
Create a new worktree next to the repo
</span>
</div>
</div>
) : (
<label className="flex items-center gap-2 cursor-pointer min-h-[34px]">
<input
id="session-type-simple"
type="radio"
name="sessionType"
value="simple"
checked={sessionType === 'simple'}
onChange={() => setSessionType('simple')}
disabled={isPending}
className="accent-[var(--app-link)]"
/>
<span className="capitalize">Simple</span>
<span className="text-xs text-[var(--app-hint)]">
Use the selected directory as-is
</span>
</label>
)}
</div>
))}
</div>
</div>
{(error ?? spawnError) ? (
<div className="text-sm text-red-600">
{error ?? spawnError}
+10 -1
View File
@@ -8,6 +8,8 @@ type SpawnInput = {
directory: string
agent?: 'claude' | 'codex' | 'gemini'
yolo?: boolean
sessionType?: 'simple' | 'worktree'
worktreeName?: string
}
export function useSpawnSession(api: ApiClient | null): {
@@ -22,7 +24,14 @@ export function useSpawnSession(api: ApiClient | null): {
if (!api) {
throw new Error('API unavailable')
}
return await api.spawnSession(input.machineId, input.directory, input.agent, input.yolo)
return await api.spawnSession(
input.machineId,
input.directory,
input.agent,
input.yolo,
input.sessionType,
input.worktreeName
)
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.sessions })
+10
View File
@@ -1,6 +1,14 @@
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | null | undefined
export type ModelMode = 'default' | 'sonnet' | 'opus' | null | undefined
export type WorktreeMetadata = {
basePath: string
branch: string
name: string
worktreePath?: string
createdAt?: number
}
export type SessionMetadataSummary = {
path: string
host: string
@@ -11,6 +19,7 @@ export type SessionMetadataSummary = {
machineId?: string
tools?: string[]
flavor?: string | null
worktree?: WorktreeMetadata
}
export type AgentStateRequest = {
@@ -63,6 +72,7 @@ export type SessionSummaryMetadata = {
path: string
summary?: { text: string }
flavor?: string | null
worktree?: WorktreeMetadata
}
export type SessionSummary = {