From a6fb27392b88bb28a4d1874fde8a655f56a4597c Mon Sep 17 00:00:00 2001 From: nyk <93952610+0xNyk@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:38:09 +0700 Subject: [PATCH] fix: deduplicate sessions by sessionId and add task edit modal (#86) Deduplicate gateway sessions server-side using sessionId as primary key, falling back to agent:key composite for sessions without sessionId. This prevents duplicate React keys when OpenClaw tracks cron runs under the same session ID as the parent session. Also adds EditTaskModal to the task board panel with inline edit button in the task detail modal, and improves CreateTaskModal error handling. Cherry-picked and adapted from PR #77 by @arana198. Closes #80 --- src/app/api/sessions/route.ts | 18 +- src/components/panels/task-board-panel.tsx | 221 +++++++++++++++++++-- 2 files changed, 220 insertions(+), 19 deletions(-) diff --git a/src/app/api/sessions/route.ts b/src/app/api/sessions/route.ts index 08e73eb..5ce93b8 100644 --- a/src/app/api/sessions/route.ts +++ b/src/app/api/sessions/route.ts @@ -12,14 +12,26 @@ export async function GET(request: NextRequest) { try { const gatewaySessions = getAllGatewaySessions() - // If gateway sessions exist, return those + // If gateway sessions exist, deduplicate and return those if (gatewaySessions.length > 0) { - const sessions = gatewaySessions.map((s) => { + // Deduplicate by sessionId — OpenClaw tracks cron runs under the same + // session ID as the parent session, causing duplicate React keys (#80). + // Keep the most recently updated entry when duplicates exist. + const sessionMap = new Map() + for (const s of gatewaySessions) { + const id = s.sessionId || `${s.agent}:${s.key}` + const existing = sessionMap.get(id) + if (!existing || s.updatedAt > existing.updatedAt) { + sessionMap.set(id, s) + } + } + + const sessions = Array.from(sessionMap.values()).map((s) => { const total = s.totalTokens || 0 const context = s.contextTokens || 35000 const pct = context > 0 ? Math.round((total / context) * 100) : 0 return { - id: s.sessionId || s.key, + id: s.sessionId || `${s.agent}:${s.key}`, key: s.key, agent: s.agent, kind: s.chatType || 'unknown', diff --git a/src/components/panels/task-board-panel.tsx b/src/components/panels/task-board-panel.tsx index acf3e5f..e2b5418 100644 --- a/src/components/panels/task-board-panel.tsx +++ b/src/components/panels/task-board-panel.tsx @@ -70,6 +70,7 @@ export function TaskBoardPanel() { const [selectedTask, setSelectedTask] = useState(null) const [draggedTask, setDraggedTask] = useState(null) const [showCreateModal, setShowCreateModal] = useState(false) + const [editingTask, setEditingTask] = useState(null) const dragCounter = useRef(0) // Fetch tasks and agents @@ -416,12 +417,16 @@ export function TaskBoardPanel() { {/* Task Detail Modal */} - {selectedTask && ( + {selectedTask && !editingTask && ( setSelectedTask(null)} onUpdate={fetchData} + onEdit={(taskToEdit) => { + setEditingTask(taskToEdit) + setSelectedTask(null) + }} /> )} @@ -433,21 +438,33 @@ export function TaskBoardPanel() { onCreated={fetchData} /> )} + + {/* Edit Task Modal */} + {editingTask && ( + setEditingTask(null)} + onUpdated={() => { fetchData(); setEditingTask(null) }} + /> + )} ) } // Task Detail Modal Component (placeholder - would be implemented separately) -function TaskDetailModal({ - task, - agents, - onClose, - onUpdate -}: { +function TaskDetailModal({ + task, + agents, + onClose, + onUpdate, + onEdit +}: { task: Task agents: Agent[] onClose: () => void onUpdate: () => void + onEdit: (task: Task) => void }) { const [comments, setComments] = useState([]) const [loadingComments, setLoadingComments] = useState(false) @@ -588,12 +605,20 @@ function TaskDetailModal({

{task.title}

- +
+ + +

{task.description || 'No description'}

@@ -795,7 +820,9 @@ function CreateTaskModal({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - + + if (!formData.title.trim()) return + try { const response = await fetch('/api/tasks', { method: 'POST', @@ -807,8 +834,12 @@ function CreateTaskModal({ }) }) - if (!response.ok) throw new Error('Failed to create task') - + if (!response.ok) { + const errorData = await response.json() + const errorMsg = errorData.details ? errorData.details.join(', ') : errorData.error + throw new Error(errorMsg) + } + onCreated() onClose() } catch (error) { @@ -908,3 +939,161 @@ function CreateTaskModal({
) } + +// Edit Task Modal Component +function EditTaskModal({ + task, + agents, + onClose, + onUpdated +}: { + task: Task + agents: Agent[] + onClose: () => void + onUpdated: () => void +}) { + const [formData, setFormData] = useState({ + title: task.title, + description: task.description || '', + priority: task.priority, + status: task.status, + assigned_to: task.assigned_to || '', + tags: task.tags ? task.tags.join(', ') : '', + }) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!formData.title.trim()) return + + try { + const response = await fetch(`/api/tasks/${task.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...formData, + tags: formData.tags ? formData.tags.split(',').map(t => t.trim()) : [], + assigned_to: formData.assigned_to || undefined + }) + }) + + if (!response.ok) { + const errorData = await response.json() + const errorMsg = errorData.details ? errorData.details.join(', ') : errorData.error + throw new Error(errorMsg) + } + + onUpdated() + } catch (error) { + console.error('Error updating task:', error) + } + } + + return ( +
+
+
+

Edit Task

+ +
+
+ + setFormData(prev => ({ ...prev, title: e.target.value }))} + className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50" + required + /> +
+ +
+ +