mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user