Skip to content

Commit b47955a

Browse files
ralph: ralph-ralph-status-dialog-for-repo-detail completed after 3 iterations
1 parent 27352ea commit b47955a

6 files changed

Lines changed: 258 additions & 1 deletion

File tree

backend/src/routes/memory.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,46 @@ export function createMemoryRoutes(db: Database): Hono {
525525
}
526526
})
527527

528+
app.get('/ralph/status', async (c) => {
529+
const repoIdParam = c.req.query('repoId')
530+
531+
if (!repoIdParam) {
532+
return c.json({ error: 'Missing repoId' }, 400)
533+
}
534+
535+
const repoId = parseInt(repoIdParam, 10)
536+
537+
if (isNaN(repoId)) {
538+
return c.json({ error: 'Invalid repoId' }, 400)
539+
}
540+
541+
try {
542+
const repo = getRepoById(db, repoId)
543+
544+
if (!repo) {
545+
return c.json({ loops: [] })
546+
}
547+
548+
const projectId = await resolveProjectId(repo.fullPath)
549+
550+
if (!projectId) {
551+
return c.json({ error: 'Failed to resolve project ID' }, 500)
552+
}
553+
554+
const entries = pluginMemory.listKv(projectId, 'ralph:')
555+
const loops = entries
556+
.map(e => e.data)
557+
.filter((data): data is Record<string, unknown> =>
558+
data !== null && typeof data === 'object' && 'active' in data
559+
)
560+
561+
return c.json({ loops })
562+
} catch (error) {
563+
logger.error('Failed to get Ralph status:', error)
564+
return c.json({ error: 'Failed to get Ralph status' }, 500)
565+
}
566+
})
567+
528568
app.post('/ralph/cancel', async (c) => {
529569
try {
530570
const body = await c.req.json()

frontend/src/api/memory.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,30 @@ export async function updateKvEntry(projectId: string, key: string, data: Update
123123
})
124124
}
125125

126+
export interface RalphLoopState {
127+
active: boolean
128+
sessionId: string
129+
worktreeName: string
130+
worktreeDir: string
131+
worktreeBranch?: string
132+
iteration: number
133+
maxIterations: number
134+
startedAt: string
135+
prompt: string
136+
phase: 'coding' | 'auditing'
137+
audit: boolean
138+
lastAuditResult?: string
139+
errorCount: number
140+
auditCount: number
141+
terminationReason?: string
142+
completedAt?: string
143+
inPlace?: boolean
144+
}
145+
146+
export async function getRalphStatus(repoId: number): Promise<{ loops: RalphLoopState[] }> {
147+
return fetchWrapper(`${API_BASE_URL}/api/memory/ralph/status?repoId=${repoId}`)
148+
}
149+
126150
export async function cancelRalphLoop(repoId: string, sessionId: string): Promise<{ cancelled: boolean; worktreeName?: string }> {
127151
return fetchWrapper(`${API_BASE_URL}/api/memory/ralph/cancel`, {
128152
method: 'POST',
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
2+
import { RepoRalphList } from './RepoRalphList'
3+
import { useRalphStatus } from '@/hooks/useRalphStatus'
4+
5+
interface RepoRalphDialogProps {
6+
open: boolean
7+
onOpenChange: (open: boolean) => void
8+
repoId: number
9+
}
10+
11+
export function RepoRalphDialog({ open, onOpenChange, repoId }: RepoRalphDialogProps) {
12+
const { data, isLoading, error, cancelMutation } = useRalphStatus(repoId, open)
13+
14+
if (!repoId) {
15+
return null
16+
}
17+
18+
return (
19+
<Dialog open={open} onOpenChange={onOpenChange}>
20+
<DialogContent mobileFullscreen className="sm:max-w-2xl sm:max-h-[85vh] gap-0 flex flex-col p-0 md:p-6 pb-safe">
21+
<DialogHeader className="p-4 sm:p-6 border-b flex flex-row items-center justify-between space-y-0 shrink-0">
22+
<DialogTitle>Ralph Loops</DialogTitle>
23+
</DialogHeader>
24+
<RepoRalphList
25+
isLoading={isLoading}
26+
data={data?.loops}
27+
error={error}
28+
onCancel={(sessionId) => cancelMutation.mutate({ sessionId })}
29+
cancelPending={cancelMutation.isPending}
30+
/>
31+
</DialogContent>
32+
</Dialog>
33+
)
34+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { Loader2, CheckCircle2, XCircle, Ban, AlertCircle } from 'lucide-react'
2+
import { Button } from '@/components/ui/button'
3+
import { Badge } from '@/components/ui/badge'
4+
import type { RalphLoopState } from '@/api/memory'
5+
6+
interface RepoRalphListProps {
7+
isLoading: boolean
8+
data: RalphLoopState[] | undefined
9+
error: Error | null
10+
onCancel: (sessionId: string) => void
11+
cancelPending: boolean
12+
}
13+
14+
function StatusIcon({ loop }: { loop: RalphLoopState }) {
15+
if (loop.active) return <Loader2 className="h-4 w-4 animate-spin text-blue-400" />
16+
if (loop.terminationReason === 'completed') return <CheckCircle2 className="h-4 w-4 text-green-400" />
17+
if (loop.terminationReason === 'cancelled' || loop.terminationReason === 'user_aborted') return <Ban className="h-4 w-4 text-yellow-400" />
18+
return <XCircle className="h-4 w-4 text-red-400" />
19+
}
20+
21+
function formatDuration(startedAt: string, completedAt?: string): string {
22+
const start = new Date(startedAt).getTime()
23+
const end = completedAt ? new Date(completedAt).getTime() : Date.now()
24+
const seconds = Math.floor((end - start) / 1000)
25+
if (seconds < 60) return `${seconds}s`
26+
const minutes = Math.floor(seconds / 60)
27+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`
28+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
29+
}
30+
31+
export function RepoRalphList({ isLoading, data, error, onCancel, cancelPending }: RepoRalphListProps) {
32+
if (isLoading) {
33+
return (
34+
<div className="flex items-center justify-center p-8">
35+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
36+
</div>
37+
)
38+
}
39+
40+
if (error) {
41+
return (
42+
<div className="flex items-center gap-2 p-6 text-destructive">
43+
<AlertCircle className="h-4 w-4" />
44+
<span className="text-sm">Failed to load Ralph status</span>
45+
</div>
46+
)
47+
}
48+
49+
if (!data?.length) {
50+
return (
51+
<div className="flex flex-col items-center justify-center p-8 text-muted-foreground">
52+
<span className="text-sm">No Ralph loops found</span>
53+
</div>
54+
)
55+
}
56+
57+
return (
58+
<div className="flex flex-col gap-3 p-4 overflow-y-auto">
59+
{data.map((loop) => (
60+
<div key={loop.sessionId} className="rounded-lg border bg-card p-4 flex flex-col gap-2">
61+
<div className="flex items-center justify-between gap-2">
62+
<div className="flex items-center gap-2 min-w-0">
63+
<StatusIcon loop={loop} />
64+
<span className="font-medium text-sm truncate">{loop.worktreeName}</span>
65+
</div>
66+
<div className="flex items-center gap-2 shrink-0">
67+
<Badge variant="outline" className="text-xs capitalize">
68+
{loop.phase}
69+
</Badge>
70+
{loop.active && (
71+
<Button
72+
variant="destructive"
73+
size="sm"
74+
onClick={() => onCancel(loop.sessionId)}
75+
disabled={cancelPending}
76+
className="h-7 text-xs"
77+
>
78+
Cancel
79+
</Button>
80+
)}
81+
</div>
82+
</div>
83+
<div className="flex items-center gap-4 text-xs text-muted-foreground">
84+
<span>Iteration {loop.iteration}/{loop.maxIterations}</span>
85+
<span>{formatDuration(loop.startedAt, loop.completedAt)}</span>
86+
{loop.worktreeBranch && <span className="truncate">{loop.worktreeBranch}</span>}
87+
</div>
88+
{loop.terminationReason && !loop.active && (
89+
<span className="text-xs text-muted-foreground capitalize">
90+
{loop.terminationReason.replace(/_/g, ' ')}
91+
</span>
92+
)}
93+
{loop.lastAuditResult && (
94+
<p className="text-xs text-muted-foreground line-clamp-2 mt-1">{loop.lastAuditResult}</p>
95+
)}
96+
</div>
97+
))}
98+
</div>
99+
)
100+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
2+
import { getRalphStatus, cancelRalphLoop } from '@/api/memory'
3+
import { showToast } from '@/lib/toast'
4+
5+
export function useRalphStatus(repoId: number, open: boolean) {
6+
const queryClient = useQueryClient()
7+
8+
const { data, isLoading, error } = useQuery({
9+
queryKey: ['ralph-status', repoId],
10+
queryFn: () => getRalphStatus(repoId),
11+
enabled: open && !!repoId,
12+
staleTime: 0,
13+
refetchInterval: ({ state }) => {
14+
const loops = state.data?.loops ?? []
15+
return loops.some(l => l.active) ? 5000 : false
16+
},
17+
})
18+
19+
const cancelMutation = useMutation({
20+
mutationFn: ({ sessionId }: { sessionId: string }) =>
21+
cancelRalphLoop(String(repoId), sessionId),
22+
onSuccess: (result) => {
23+
if (result.cancelled) {
24+
queryClient.invalidateQueries({ queryKey: ['ralph-status', repoId] })
25+
} else {
26+
showToast.error('Ralph loop is no longer active')
27+
}
28+
},
29+
onError: () => {
30+
showToast.error('Unable to cancel the Ralph loop. Please try again.')
31+
},
32+
})
33+
34+
return { data, isLoading, error, cancelMutation }
35+
}

frontend/src/pages/RepoDetail.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Header } from "@/components/ui/header";
99
import { SwitchConfigDialog } from "@/components/repo/SwitchConfigDialog";
1010
import { RepoMcpDialog } from "@/components/repo/RepoMcpDialog";
1111
import { RepoSkillsDialog } from "@/components/repo/RepoSkillsDialog";
12+
import { RepoRalphDialog } from "@/components/repo/RepoRalphDialog";
1213
import { SourceControlPanel } from "@/components/source-control";
1314
import { useCreateSession } from "@/hooks/useOpenCode";
1415
import { useSSE } from "@/hooks/useSSE";
@@ -17,7 +18,7 @@ import { useSwipeBack } from "@/hooks/useMobile";
1718
import { Button } from "@/components/ui/button";
1819
import { Badge } from "@/components/ui/badge";
1920
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
20-
import { Plug, FolderOpen, Plus, GitBranch, GitCommitHorizontal, ShieldOff, Brain, Loader2, CalendarClock, Sparkles } from "lucide-react";
21+
import { Plug, FolderOpen, Plus, GitBranch, GitCommitHorizontal, ShieldOff, Brain, Loader2, CalendarClock, Sparkles, Bot } from "lucide-react";
2122
import { ResetPermissionsDialog } from "@/components/repo/ResetPermissionsDialog";
2223
import { PendingActionsGroup } from "@/components/notifications/PendingActionsGroup";
2324
import { invalidateConfigCaches } from "@/lib/queryInvalidation";
@@ -33,6 +34,7 @@ export function RepoDetail() {
3334
const [skillsDialogOpen, setSkillsDialogOpen] = useState(false);
3435
const [sourceControlOpen, setSourceControlOpen] = useState(false);
3536
const [resetPermissionsOpen, setResetPermissionsOpen] = useState(false);
37+
const [ralphDialogOpen, setRalphDialogOpen] = useState(false);
3638
const pageRef = useRef<HTMLDivElement>(null);
3739

3840
const handleSwipeBack = useCallback(() => {
@@ -157,6 +159,17 @@ export function RepoDetail() {
157159
<Sparkles className="w-4 h-4 sm:mr-2" />
158160
<span className="hidden sm:inline">Skills</span>
159161
</Button>
162+
{memoryPluginStatus?.memoryPluginEnabled && (
163+
<Button
164+
variant="outline"
165+
onClick={() => setRalphDialogOpen(true)}
166+
size="sm"
167+
className="hidden md:flex text-foreground border-border hover:bg-accent transition-all duration-200 hover:scale-105"
168+
>
169+
<Bot className="w-4 h-4 sm:mr-2" />
170+
<span className="hidden sm:inline">Ralph</span>
171+
</Button>
172+
)}
160173
<Button
161174
variant="outline"
162175
onClick={() => setSourceControlOpen(true)}
@@ -219,6 +232,11 @@ export function RepoDetail() {
219232
<DropdownMenuItem onClick={() => setSkillsDialogOpen(true)}>
220233
<Sparkles className="w-4 h-4 mr-2" /> Skills
221234
</DropdownMenuItem>
235+
{memoryPluginStatus?.memoryPluginEnabled && (
236+
<DropdownMenuItem onClick={() => setRalphDialogOpen(true)}>
237+
<Bot className="w-4 h-4 mr-2" /> Ralph
238+
</DropdownMenuItem>
239+
)}
222240
<DropdownMenuItem onClick={() => setResetPermissionsOpen(true)}>
223241
<ShieldOff className="w-4 h-4 mr-2" /> Reset Permissions
224242
</DropdownMenuItem>
@@ -271,6 +289,12 @@ export function RepoDetail() {
271289
repoId={repoId}
272290
/>
273291

292+
<RepoRalphDialog
293+
open={ralphDialogOpen}
294+
onOpenChange={setRalphDialogOpen}
295+
repoId={repoId}
296+
/>
297+
274298
<SourceControlPanel
275299
repoId={repoId}
276300
isOpen={sourceControlOpen}

0 commit comments

Comments
 (0)