Skip to content

Commit db216ca

Browse files
committed
feat: SDK integration, peer review, defensive hardening
- Integrate 8 unused opencode SDK methods (children, diff, revert, unrevert, summarize, share/unshare, permission rules) - Add peer review section for linked sessions with context loading - Extract SessionActions, DiffView components from SessionPanel - Remove merged-view relation type, migrate existing data to linked - Add SSE auto-reconnect with 3s retry - Fix infinite loop in DiffView diff algorithm - Add cycle detection in collectSubtreeIds (BFS visited set) - Filter orphaned relation edges from deleted sessions - Add session deletion DB cleanup (canvas, fork, relation records) - Add input validation on relation routes (type enum, self-link, required fields) - Add error handling on all client fetch calls (res.ok checks, try-catch) - Add abort confirmation dialog and loading state - Add fork/subtask busy guards to prevent double-clicks - Add tree load error banner with retry button - Fix unwrapData error formatting ([object Object] → proper JSON) - Add summarize auto:true fallback when provider/model not specified - Share fetchJson utility across components
1 parent 8f46e92 commit db216ca

19 files changed

Lines changed: 1064 additions & 91 deletions

src/client/canvas/AgentCanvas.tsx

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2-
3-
async function fetchJson(url: string, options?: RequestInit) {
4-
const res = await fetch(url, options)
5-
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
6-
return res.json()
7-
}
2+
import { fetchJson } from '../utils/fetchJson'
83
import {
94
type Node,
105
type Connection,
@@ -22,11 +17,10 @@ import { AgentNode } from './AgentNode'
2217
import { AgentEdge } from './AgentEdge'
2318
import { GroupHeaderNode } from './GroupHeaderNode'
2419

25-
type ConnRelationType = 'linked' | 'merged-view' | 'detached'
20+
type ConnRelationType = 'linked' | 'detached'
2621

2722
const RELATION_COLORS: Record<ConnRelationType, string> = {
2823
linked: '#818cf8',
29-
'merged-view': '#a78bfa',
3024
detached: '#6b7280',
3125
}
3226

@@ -77,7 +71,7 @@ function ConnectDialog({
7771
Connect as
7872
</div>
7973
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 18 }}>
80-
{(['linked', 'merged-view', 'detached'] as const).map((type) => (
74+
{(['linked', 'detached'] as const).map((type) => (
8175
<label
8276
key={type}
8377
style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: 13, color: selected === type ? '#e5e7eb' : '#6b7280' }}
@@ -156,6 +150,7 @@ export function AgentCanvas() {
156150
const addRelation = useAgentStore((s) => s.addRelation)
157151
const hasFramedInitialView = useRef(false)
158152
const [pendingConn, setPendingConn] = useState<{ source: string; target: string } | null>(null)
153+
const [treeError, setTreeError] = useState<string | null>(null)
159154

160155
const onConnect = useCallback((connection: Connection) => {
161156
if (!connection.source || !connection.target) return
@@ -166,8 +161,13 @@ export function AgentCanvas() {
166161
const reactFlowRef = useRef<ReactFlowInstance<Node> | null>(null)
167162

168163
async function reloadTree() {
169-
const data = await fetchJson('/api/tree')
170-
applySessionTree(data)
164+
try {
165+
const data = await fetchJson('/api/tree')
166+
applySessionTree(data)
167+
setTreeError(null)
168+
} catch (err) {
169+
setTreeError(err instanceof Error ? err.message : 'Failed to load sessions')
170+
}
171171
}
172172

173173
const displayNodes = useMemo<Node[]>(
@@ -195,12 +195,30 @@ export function AgentCanvas() {
195195
}, [applySessionTree])
196196

197197
useEffect(() => {
198-
const es = new EventSource('/api/events')
199-
es.onmessage = (e) => {
200-
try { applyEvent(JSON.parse(e.data)) } catch {}
198+
let es: EventSource | null = null
199+
let cancelled = false
200+
let retryTimeout: ReturnType<typeof setTimeout> | null = null
201+
202+
function connect() {
203+
if (cancelled) return
204+
es = new EventSource('/api/events')
205+
es.onmessage = (e) => {
206+
try { applyEvent(JSON.parse(e.data)) } catch (err) { console.warn('[sse] failed to parse event:', err) }
207+
}
208+
es.onerror = () => {
209+
console.warn('[sse] connection lost, reconnecting in 3s...')
210+
es?.close()
211+
es = null
212+
if (!cancelled) retryTimeout = setTimeout(connect, 3000)
213+
}
214+
}
215+
216+
connect()
217+
return () => {
218+
cancelled = true
219+
if (retryTimeout) clearTimeout(retryTimeout)
220+
es?.close()
201221
}
202-
es.onerror = () => console.warn('[sse] connection issue')
203-
return () => es.close()
204222
}, [applyEvent])
205223

206224
useEffect(() => {
@@ -222,6 +240,18 @@ export function AgentCanvas() {
222240

223241
return (
224242
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
243+
{treeError && (
244+
<div style={{
245+
position: 'absolute', top: 16, left: '50%', transform: 'translateX(-50%)', zIndex: 20,
246+
background: '#7f1d1d', color: '#fca5a5', padding: '8px 16px', borderRadius: 8,
247+
fontSize: 12, display: 'flex', alignItems: 'center', gap: 8,
248+
}}>
249+
<span>Failed to load: {treeError}</span>
250+
<button onClick={() => void reloadTree()} style={{ background: 'none', border: '1px solid #fca5a5', color: '#fca5a5', borderRadius: 4, fontSize: 11, cursor: 'pointer', padding: '2px 8px' }}>
251+
Retry
252+
</button>
253+
</div>
254+
)}
225255
<div
226256
style={{
227257
position: 'absolute',

src/client/canvas/AgentNode.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState } from 'react'
22
import { Handle, Position, type NodeProps } from '@xyflow/react'
33
import type { AgentNodeData, NodeStatus } from '../store/agentStore'
44
import { useAgentStore } from '../store/agentStore'
5+
import { fetchJson } from '../utils/fetchJson'
56

67
const STATUS_COLOR: Record<NodeStatus, string> = {
78
running: '#22c55e',
@@ -22,12 +23,6 @@ export function AgentNode({ data, selected }: NodeProps) {
2223
const setSubtaskTargetSession = useAgentStore((state) => state.setSubtaskTargetSession)
2324
const applySessionTree = useAgentStore((state) => state.applySessionTree)
2425

25-
async function fetchJson(url: string, options?: RequestInit) {
26-
const res = await fetch(url, options)
27-
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
28-
return res.json()
29-
}
30-
3126
async function refreshTree() {
3227
const tree = await fetchJson('/api/tree')
3328
applySessionTree(tree)

src/client/panel/ApprovalQueue.tsx

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,16 @@ export function ApprovalQueue() {
6464
async function replyPermission(sessionId: string, reply: 'once' | 'always' | 'reject') {
6565
const requestID = getRequestID(pendingPermissions[sessionId])
6666
if (!requestID) return
67-
await fetch(`/api/permission/${requestID}/reply`, {
68-
method: 'POST',
69-
headers: { 'Content-Type': 'application/json' },
70-
body: JSON.stringify({ reply }),
71-
})
67+
try {
68+
const res = await fetch(`/api/permission/${requestID}/reply`, {
69+
method: 'POST',
70+
headers: { 'Content-Type': 'application/json' },
71+
body: JSON.stringify({ reply }),
72+
})
73+
if (!res.ok) console.error('[ApprovalQueue] permission reply failed:', res.status)
74+
} catch (err) {
75+
console.error('[ApprovalQueue] permission reply error:', err)
76+
}
7277
}
7378

7479
async function submitQuestion(sessionId: string) {
@@ -77,18 +82,28 @@ export function ApprovalQueue() {
7782
const firstQuestion = payload.questions?.[0]
7883
const answer = questionAnswers[sessionId]?.trim()
7984
if (!requestID || !firstQuestion?.id || !answer) return
80-
await fetch(`/api/question/${requestID}/reply`, {
81-
method: 'POST',
82-
headers: { 'Content-Type': 'application/json' },
83-
body: JSON.stringify({ answers: [{ questionID: firstQuestion.id, value: answer }] }),
84-
})
85-
setQuestionAnswers((prev) => { const next = { ...prev }; delete next[sessionId]; return next })
85+
try {
86+
const res = await fetch(`/api/question/${requestID}/reply`, {
87+
method: 'POST',
88+
headers: { 'Content-Type': 'application/json' },
89+
body: JSON.stringify({ answers: [{ questionID: firstQuestion.id, value: answer }] }),
90+
})
91+
if (!res.ok) console.error('[ApprovalQueue] question reply failed:', res.status)
92+
else setQuestionAnswers((prev) => { const next = { ...prev }; delete next[sessionId]; return next })
93+
} catch (err) {
94+
console.error('[ApprovalQueue] question reply error:', err)
95+
}
8696
}
8797

8898
async function rejectQuestion(sessionId: string) {
8999
const requestID = getRequestID(pendingQuestions[sessionId])
90100
if (!requestID) return
91-
await fetch(`/api/question/${requestID}/reject`, { method: 'POST' })
101+
try {
102+
const res = await fetch(`/api/question/${requestID}/reject`, { method: 'POST' })
103+
if (!res.ok) console.error('[ApprovalQueue] question reject failed:', res.status)
104+
} catch (err) {
105+
console.error('[ApprovalQueue] question reject error:', err)
106+
}
92107
}
93108

94109
return (

src/client/panel/DiffView.tsx

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { useState } from 'react'
2+
3+
type FileDiff = {
4+
file: string
5+
before: string
6+
after: string
7+
additions: number
8+
deletions: number
9+
status?: 'added' | 'deleted' | 'modified'
10+
}
11+
12+
export function DiffView({ sessionId }: { sessionId: string }) {
13+
const [diffs, setDiffs] = useState<FileDiff[] | null>(null)
14+
const [loading, setLoading] = useState(false)
15+
const [error, setError] = useState<string | null>(null)
16+
const [expanded, setExpanded] = useState<Set<string>>(new Set())
17+
18+
async function loadDiffs() {
19+
setLoading(true)
20+
setError(null)
21+
try {
22+
const res = await fetch(`/api/session/${sessionId}/diff`)
23+
if (!res.ok) throw new Error('Failed to load diffs')
24+
const data = await res.json()
25+
setDiffs(data)
26+
} catch (err) {
27+
setError(err instanceof Error ? err.message : String(err))
28+
} finally {
29+
setLoading(false)
30+
}
31+
}
32+
33+
function toggleFile(file: string) {
34+
setExpanded((prev) => {
35+
const next = new Set(prev)
36+
if (next.has(file)) next.delete(file)
37+
else next.add(file)
38+
return next
39+
})
40+
}
41+
42+
if (diffs === null) {
43+
return (
44+
<div style={{ padding: '8px 16px' }}>
45+
<button
46+
onClick={() => void loadDiffs()}
47+
disabled={loading}
48+
style={{
49+
background: 'none', border: '1px solid #374151', borderRadius: 4,
50+
color: '#9ca3af', fontSize: 10, cursor: loading ? 'default' : 'pointer', padding: '3px 8px',
51+
}}
52+
>
53+
{loading ? 'Loading...' : 'View Diffs'}
54+
</button>
55+
{error && <span style={{ color: '#ef4444', fontSize: 10, marginLeft: 8 }}>{error}</span>}
56+
</div>
57+
)
58+
}
59+
60+
if (diffs.length === 0) {
61+
return (
62+
<div style={{ padding: '8px 16px', fontSize: 11, color: '#4b5563' }}>
63+
No file changes.
64+
<button onClick={() => setDiffs(null)} style={{ background: 'none', border: 'none', color: '#6b7280', fontSize: 10, cursor: 'pointer', marginLeft: 6 }}>
65+
66+
</button>
67+
</div>
68+
)
69+
}
70+
71+
const statusColor = (s?: string) => s === 'added' ? '#22c55e' : s === 'deleted' ? '#ef4444' : '#eab308'
72+
73+
return (
74+
<div style={{ borderBottom: '1px solid #1f2937' }}>
75+
<div style={{ padding: '8px 16px 4px', display: 'flex', alignItems: 'center', gap: 8 }}>
76+
<span style={{ color: '#6b7280', fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
77+
Diffs ({diffs.length} file{diffs.length !== 1 ? 's' : ''})
78+
</span>
79+
<button onClick={() => setDiffs(null)} style={{ background: 'none', border: 'none', color: '#4b5563', fontSize: 12, cursor: 'pointer', padding: '0 3px', marginLeft: 'auto' }}>
80+
81+
</button>
82+
</div>
83+
{diffs.map((d) => (
84+
<div key={d.file}>
85+
<button
86+
onClick={() => toggleFile(d.file)}
87+
style={{
88+
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '4px 16px',
89+
background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left',
90+
}}
91+
>
92+
<span style={{ color: '#6b7280', fontSize: 10, flexShrink: 0 }}>{expanded.has(d.file) ? '▾' : '▸'}</span>
93+
<span style={{ color: '#e5e7eb', fontSize: 11, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
94+
{d.file}
95+
</span>
96+
{d.status && (
97+
<span style={{ color: statusColor(d.status), fontSize: 9, fontFamily: 'monospace', flexShrink: 0 }}>
98+
{d.status}
99+
</span>
100+
)}
101+
<span style={{ fontSize: 10, fontFamily: 'monospace', flexShrink: 0 }}>
102+
{d.additions > 0 && <span style={{ color: '#22c55e' }}>+{d.additions}</span>}
103+
{d.additions > 0 && d.deletions > 0 && <span style={{ color: '#4b5563' }}> </span>}
104+
{d.deletions > 0 && <span style={{ color: '#ef4444' }}>-{d.deletions}</span>}
105+
</span>
106+
</button>
107+
{expanded.has(d.file) && (
108+
<div style={{
109+
margin: '0 16px 8px', padding: 8, background: '#0b0b0b', borderRadius: 4,
110+
fontSize: 10, fontFamily: 'monospace', whiteSpace: 'pre-wrap', overflowX: 'auto',
111+
maxHeight: 300, overflowY: 'auto', color: '#9ca3af', lineHeight: 1.6,
112+
}}>
113+
{renderUnifiedDiff(d.before, d.after)}
114+
</div>
115+
)}
116+
</div>
117+
))}
118+
</div>
119+
)
120+
}
121+
122+
function renderUnifiedDiff(before: string, after: string): React.ReactNode {
123+
if (!before && !after) return <span style={{ color: '#4b5563' }}>(empty)</span>
124+
125+
const beforeLines = before.split('\n')
126+
const afterLines = after.split('\n')
127+
const lines: React.ReactNode[] = []
128+
129+
// Simple line-by-line diff (not a full diff algorithm — shows before/after blocks)
130+
const maxLen = Math.max(beforeLines.length, afterLines.length)
131+
let i = 0
132+
let j = 0
133+
134+
outer: while (i < beforeLines.length || j < afterLines.length) {
135+
if (i < beforeLines.length && j < afterLines.length && beforeLines[i] === afterLines[j]) {
136+
lines.push(<div key={`c-${i}`} style={{ color: '#6b7280' }}>{' '}{beforeLines[i]}</div>)
137+
i++
138+
j++
139+
} else {
140+
const prevI = i
141+
const prevJ = j
142+
// Show removed lines
143+
while (i < beforeLines.length && (j >= afterLines.length || beforeLines[i] !== afterLines[j])) {
144+
lines.push(<div key={`d-${i}`} style={{ color: '#ef4444', background: 'rgba(239,68,68,0.08)' }}>-{beforeLines[i]}</div>)
145+
i++
146+
if (lines.length > maxLen * 2) break
147+
}
148+
// Show added lines
149+
while (j < afterLines.length && (i >= beforeLines.length || afterLines[j] !== beforeLines[i])) {
150+
lines.push(<div key={`a-${j}`} style={{ color: '#22c55e', background: 'rgba(34,197,94,0.08)' }}>+{afterLines[j]}</div>)
151+
j++
152+
if (lines.length > maxLen * 2) break
153+
}
154+
// Safety: if neither pointer advanced, force progress to prevent infinite loop
155+
if (i === prevI && j === prevJ) {
156+
if (i < beforeLines.length) { lines.push(<div key={`d-${i}`} style={{ color: '#ef4444', background: 'rgba(239,68,68,0.08)' }}>-{beforeLines[i]}</div>); i++ }
157+
if (j < afterLines.length) { lines.push(<div key={`a-${j}`} style={{ color: '#22c55e', background: 'rgba(34,197,94,0.08)' }}>+{afterLines[j]}</div>); j++ }
158+
}
159+
}
160+
if (lines.length > 500) {
161+
lines.push(<div key="truncated" style={{ color: '#4b5563' }}>... (truncated)</div>)
162+
break outer
163+
}
164+
}
165+
166+
return <>{lines}</>
167+
}

0 commit comments

Comments
 (0)