diff --git a/apps/backend/cmd/agentctl/github_cli_shim_test.go b/apps/backend/cmd/agentctl/github_cli_shim_test.go index 61fc161f95..65703fc021 100644 --- a/apps/backend/cmd/agentctl/github_cli_shim_test.go +++ b/apps/backend/cmd/agentctl/github_cli_shim_test.go @@ -122,6 +122,7 @@ func TestInstallGitHubCLIShimLoginShellRestoresManagedTools(t *testing.T) { env["KANDEV_GITHUB_PARENT_BASH_ENV"] = parentEnv env["KANDEV_BASH_HOOK_MARKER"] = marker env["BASH_ENV"] = env["KANDEV_GITHUB_CLI_BASH_ENV"] + env["HOME"] = root env["PATH"] = "/usr/bin:/bin" commandEnv := make([]string, 0, len(env)) for key, value := range env { diff --git a/apps/backend/internal/agentctl/server/api/git_multi_repo_review_test.go b/apps/backend/internal/agentctl/server/api/git_multi_repo_review_test.go index e042441525..5a93e4f9fb 100644 --- a/apps/backend/internal/agentctl/server/api/git_multi_repo_review_test.go +++ b/apps/backend/internal/agentctl/server/api/git_multi_repo_review_test.go @@ -160,6 +160,7 @@ func TestNestedSubmoduleReviewEndpointsIncludeRootAndStableChildBase(t *testing. childBase := strings.TrimSpace(runGitAPI(t, parent, "rev-parse", "origin/main:vendor/lib")) writeFileAPI(t, parent, "README.md", "root change\n") childDir := filepath.Join(parent, "vendor/lib") + runGitAPI(t, childDir, "config", "core.hooksPath", os.DevNull) runGitAPI(t, childDir, "config", "user.email", "test@test.com") runGitAPI(t, childDir, "config", "user.name", "Test User") writeFileAPI(t, childDir, "child-change.txt", "child change\n") diff --git a/apps/backend/internal/agentctl/server/api/html_injector_test.go b/apps/backend/internal/agentctl/server/api/html_injector_test.go index 8276f9fd0d..9c2ff7c0f5 100644 --- a/apps/backend/internal/agentctl/server/api/html_injector_test.go +++ b/apps/backend/internal/agentctl/server/api/html_injector_test.go @@ -40,49 +40,88 @@ func TestInjectInspectorScript_UpperCaseBodyTag(t *testing.T) { } } -func TestInspectorScript_UsesPreviewRouteForAnnotationPagePath(t *testing.T) { - if !strings.Contains(inspectorScript, "function currentPagePath()") { - t.Fatal("inspector should derive annotation routes through currentPagePath") +func TestInspectorScript_CapturesVersionedTextEvidence(t *testing.T) { + wants := []string{ + "var PROTOCOL_VERSION = 2;", + "function captureTextSelection()", + "selection.getRangeAt(0)", + "function captureTextEndpoint(", + "node_path:", + "range.getClientRects()", + "containing_element:", + "send('capture-completed'", } - if !strings.Contains(inspectorScript, "window.__kandevProxyPrefix") { - t.Fatal("inspector should read the proxy prefix exposed by the runtime shim") - } - if !strings.Contains(inspectorScript, "pagePath: currentPagePath()") { - t.Fatal("annotations should store the app route, not location.pathname directly") + for _, want := range wants { + if !strings.Contains(inspectorScript, want) { + t.Errorf("inspector should include %q", want) + } } } -func TestInspectorScript_PreservesPendingAnnotationAcrossPopupCleanup(t *testing.T) { - openStart := strings.Index(inspectorScript, "function openCommentPopup(") - if openStart < 0 { - t.Fatal("inspector should define openCommentPopup") +func TestInspectorScript_HintsElementCandidateBeforeCapture(t *testing.T) { + wants := []string{ + "function showCandidate(", + "candidateLabel.textContent", + "send('candidate-changed'", + "document.addEventListener('focusin'", + "document.addEventListener('touchstart'", + "case 'element':", } - openEnd := strings.Index(inspectorScript[openStart:], "function commitAnnotation(") - if openEnd < 0 { - t.Fatal("inspector should define commitAnnotation after openCommentPopup") + for _, want := range wants { + if !strings.Contains(inspectorScript, want) { + t.Errorf("inspector should include %q", want) + } } - openBody := inspectorScript[openStart : openStart+openEnd] +} - keep := strings.Index(openBody, "var keep = pendingAnnotation;") - cleanup := strings.Index(openBody, "closePopup();") - restore := strings.Index(openBody, "pendingAnnotation = keep;") - if keep < 0 || cleanup < 0 || restore < 0 { - t.Fatal("openCommentPopup should preserve pendingAnnotation across preventive cleanup") +func TestInspectorScript_TracksRoutesAndProjectsMarkers(t *testing.T) { + wants := []string{ + "function currentPageRoute()", + "window.__kandevProxyPrefix", + "history.pushState", + "window.addEventListener('popstate'", + "case 'project-markers':", + "function renderMarkers()", + "send('route-changed'", } - if keep >= cleanup || cleanup >= restore { - t.Fatal("openCommentPopup should preserve pendingAnnotation across preventive cleanup") + for _, want := range wants { + if !strings.Contains(inspectorScript, want) { + t.Errorf("inspector should include %q", want) + } } +} - closeStart := strings.Index(inspectorScript, "function closePopup()") - if closeStart < 0 { - t.Fatal("inspector should define closePopup") +func TestInspectorScript_SanitizesRoutesAndCompletesTouchKeyboardCaptures(t *testing.T) { + wants := []string{ + "function sanitizedSearch()", + "location.search", + "document.addEventListener('touchend', onTextTouchEnd, true)", + "event.key === 'Enter'", + "event.key === ' '", + "querySelectorAll(selector).length === 1", + "hideCandidate();", } - closeEnd := strings.Index(inspectorScript[closeStart:], "function currentPagePath()") - if closeEnd < 0 { - t.Fatal("inspector should define currentPagePath after closePopup") + for _, want := range wants { + if !strings.Contains(inspectorScript, want) { + t.Errorf("inspector should include %q", want) + } } - if !strings.Contains(inspectorScript[closeStart:closeStart+closeEnd], "pendingAnnotation = null;") { - t.Fatal("direct popup cleanup should still clear pendingAnnotation") +} + +func TestInspectorScript_OwnsDragGesturesOnlyInScreenshotMode(t *testing.T) { + wants := []string{ + "function onScreenshotPointerDown(", + "function onScreenshotPointerMove(", + "function onScreenshotPointerUp(", + "if (mode !== 'screenshot') return;", + "send('screenshot-region-selected'", + "document.addEventListener('pointerdown'", + "document.removeEventListener('pointerdown'", + } + for _, want := range wants { + if !strings.Contains(inspectorScript, want) { + t.Errorf("inspector should include %q", want) + } } } diff --git a/apps/backend/internal/agentctl/server/api/scripts/inspector.js b/apps/backend/internal/agentctl/server/api/scripts/inspector.js index 41fb9d2bef..92a5fb315d 100644 --- a/apps/backend/internal/agentctl/server/api/scripts/inspector.js +++ b/apps/backend/internal/agentctl/server/api/scripts/inspector.js @@ -2,130 +2,45 @@ 'use strict'; var SOURCE = 'kandev-inspector'; - var DRAG_THRESHOLD = 5; - var Z = 2147483640; - - var active = false; - var mouseDownAt = null; - var dragRect = null; - var hoverEl = null; - var counter = 0; - - var hoverOverlay = null; - var dragOverlay = null; - var popup = null; - var pendingAnnotation = null; + var PROTOCOL_VERSION = 2; + var Z_INDEX = 2147483640; + var MAX_OUTER_HTML = 65536; + var mode = null; + var candidate = null; + var candidateOverlay = null; + var candidateLabel = null; + var screenshotOverlay = null; + var screenshotStart = null; + var screenshotCurrent = null; + var projectedMarkers = []; + var originalCursor = document.documentElement.style.cursor; + var originalTouchAction = document.documentElement.style.touchAction; function send(type, payload) { try { - window.parent.postMessage({ source: SOURCE, type: type, payload: payload }, '*'); - } catch (e) {} + window.parent.postMessage({ + source: SOURCE, + version: PROTOCOL_VERSION, + type: type, + payload: payload, + }, '*'); + } catch (error) {} } - function getSelector(el) { - var parts = []; - var cur = el; - while (cur && cur.nodeType === 1 && parts.length < 5) { - var tag = cur.tagName.toLowerCase(); - var id = cur.getAttribute && cur.getAttribute('id'); - if (id) { parts.unshift(tag + '#' + CSS.escape(id)); break; } - var cls = ''; - if (cur.classList && cur.classList.length) { - cls = Array.from(cur.classList).slice(0, 2) - .map(function (c) { return '.' + CSS.escape(c); }).join(''); - } - var part = tag + cls; - var parent = cur.parentElement; - if (parent) { - var sibs = Array.from(parent.children).filter(function (c) { return c.tagName === cur.tagName; }); - if (sibs.length > 1) part += ':nth-of-type(' + (sibs.indexOf(cur) + 1) + ')'; - } - parts.unshift(part); - cur = parent; - } - return parts.join(' > '); + function sanitizedSearch() { + var sensitiveQueryParameter = /(?:^|[_-])(access[_-]?token|id[_-]?token|token|secret|password|passwd|auth(?:orization)?|credential|cookie|session|capability|signature|sig|nonce|code|state|key)(?:$|[_-])/i; + var kept = []; + try { + var params = new URLSearchParams(location.search); + params.forEach(function (value, name) { + if (sensitiveQueryParameter.test(name)) return; + kept.push(encodeURIComponent(name) + '=' + encodeURIComponent(value)); + }); + } catch (error) {} + return kept.length ? '?' + kept.join('&') : ''; } - function captureElement(el) { - if (!el || el.nodeType !== 1) return null; - var cls = ''; - if (el.className && typeof el.className === 'string') { - cls = el.className.trim().split(/\s+/).slice(0, 3).join(' ') || ''; - } - return { - tag: el.tagName.toLowerCase(), - id: el.id || undefined, - classes: cls || undefined, - role: el.getAttribute('role') || undefined, - ariaLabel: el.getAttribute('aria-label') || undefined, - text: ((el.textContent || '').replace(/\s+/g, ' ').trim()).slice(0, 120) || undefined, - selector: getSelector(el), - }; - } - - function elementsInRect(rect) { - var out = []; - var all = document.querySelectorAll('body *'); - for (var i = 0; i < all.length && out.length < 5; i++) { - var el = all[i]; - if (el === hoverOverlay || el === dragOverlay || el === popup) continue; - if (popup && popup.contains(el)) continue; - var r = el.getBoundingClientRect(); - if (r.width < 4 || r.height < 4) continue; - if (r.left >= rect.x && r.top >= rect.y - && r.right <= rect.x + rect.w && r.bottom <= rect.y + rect.h) { - out.push(captureElement(el)); - } - } - return out; - } - - function makeOverlay(borderColor, bg) { - var d = document.createElement('div'); - d.style.cssText = 'position:fixed;pointer-events:none;box-sizing:border-box;display:none;z-index:' + Z + ';' - + 'border:2px solid ' + borderColor + ';background:' + bg + ';'; - document.body.appendChild(d); - return d; - } - - function ensureOverlays() { - if (!hoverOverlay) hoverOverlay = makeOverlay('#6366f1', 'rgba(99,102,241,0.10)'); - if (!dragOverlay) dragOverlay = makeOverlay('#f59e0b', 'rgba(245,158,11,0.15)'); - } - - function positionOverlay(o, x, y, w, h) { - o.style.display = 'block'; - o.style.left = x + 'px'; - o.style.top = y + 'px'; - o.style.width = w + 'px'; - o.style.height = h + 'px'; - } - - function hideOverlay(o) { if (o) o.style.display = 'none'; } - - function placeMarker(num, x, y) { - var m = document.createElement('div'); - m.setAttribute('data-kandev-marker', String(num)); - m.style.cssText = 'position:fixed;left:' + (x - 12) + 'px;top:' + (y - 12) + 'px;width:24px;height:24px;' - + 'border-radius:50%;background:#6366f1;color:#fff;font:600 12px/24px system-ui,sans-serif;' - + 'text-align:center;z-index:' + Z + ';pointer-events:none;box-shadow:0 1px 4px rgba(0,0,0,0.3);'; - m.textContent = String(num); - document.body.appendChild(m); - } - - function clearMarkers() { - var nodes = document.querySelectorAll('[data-kandev-marker]'); - for (var i = 0; i < nodes.length; i++) nodes[i].parentNode.removeChild(nodes[i]); - counter = 0; - } - - function closePopup() { - if (popup && popup.parentNode) popup.parentNode.removeChild(popup); - popup = null; - pendingAnnotation = null; - } - - function currentPagePath() { + function currentPageRoute() { var path = location.pathname; var prefix = window.__kandevProxyPrefix; if (typeof prefix === 'string' && prefix) { @@ -133,179 +48,538 @@ path = path.slice(prefix.length) || '/'; } } - return path + location.search; + return path + sanitizedSearch(); } - function openCommentPopup(anchorX, anchorY, onSubmit) { - var keep = pendingAnnotation; - closePopup(); - pendingAnnotation = keep; - popup = document.createElement('div'); - popup.style.cssText = 'position:fixed;left:' + anchorX + 'px;top:' + anchorY + 'px;z-index:' + (Z + 1) + ';' - + 'background:#fff;border:1px solid #e5e7eb;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.15);' - + 'padding:8px;width:280px;font-family:system-ui,sans-serif;'; - var ta = document.createElement('textarea'); - ta.placeholder = 'Describe the change (Enter to save, Esc to cancel)'; - ta.style.cssText = 'width:100%;min-height:60px;border:1px solid #e5e7eb;border-radius:4px;padding:6px;' - + 'font:13px system-ui,sans-serif;resize:vertical;outline:none;box-sizing:border-box;'; - var row = document.createElement('div'); - row.style.cssText = 'display:flex;gap:6px;justify-content:flex-end;margin-top:6px;'; - var cancel = document.createElement('button'); - cancel.textContent = 'Cancel'; - cancel.style.cssText = 'padding:4px 10px;border:1px solid #e5e7eb;background:#fff;border-radius:4px;cursor:pointer;font:12px system-ui,sans-serif;'; - var save = document.createElement('button'); - save.textContent = 'Save'; - save.style.cssText = 'padding:4px 10px;border:0;background:#6366f1;color:#fff;border-radius:4px;cursor:pointer;font:12px system-ui,sans-serif;'; - - cancel.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); closePopup(); }); - save.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); onSubmit(ta.value.trim()); }); - ta.addEventListener('keydown', function (e) { - e.stopPropagation(); - if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSubmit(ta.value.trim()); } - if (e.key === 'Escape') { e.preventDefault(); closePopup(); } - }); - - popup.appendChild(ta); - row.appendChild(cancel); row.appendChild(save); - popup.appendChild(row); - document.body.appendChild(popup); - ta.focus(); - } - - function commitAnnotation(comment) { - if (!pendingAnnotation) return; - counter += 1; - var a = pendingAnnotation; - a.id = 'a-' + Date.now() + '-' + counter; - a.comment = comment || ''; - placeMarker(counter, a.markerX, a.markerY); - // Note: we do NOT set `a.number` here. The React parent assigns numbers - // so they remain monotonic across iframe refreshes. - send('annotation-added', a); - pendingAnnotation = null; - closePopup(); - } - - function startPending(kind, rect, element, elements, markerX, markerY) { - pendingAnnotation = { - kind: kind, - rect: rect, - element: element || null, - elements: elements || null, - markerX: markerX, - markerY: markerY, - pagePath: currentPagePath(), + function pageIdentity() { + return { + page_route: currentPageRoute(), + page_title: String(document.title || '').slice(0, 1024), }; - var popupX = Math.min(markerX + 14, window.innerWidth - 296); - var popupY = Math.min(markerY + 14, window.innerHeight - 140); - openCommentPopup(popupX, popupY, commitAnnotation); } - function onMouseDown(e) { - if (popup && popup.contains(e.target)) return; - e.preventDefault(); - e.stopPropagation(); - mouseDownAt = { x: e.clientX, y: e.clientY }; - dragRect = null; + function escapeSelector(value) { + if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value); + return String(value).replace(/[^a-zA-Z0-9_-]/g, function (character) { + return '\\' + character; + }); } - function onMouseMove(e) { - if (popup && popup.contains(e.target)) return; - if (mouseDownAt) { - var dx = e.clientX - mouseDownAt.x; - var dy = e.clientY - mouseDownAt.y; - if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) { - dragRect = { - x: Math.min(mouseDownAt.x, e.clientX), - y: Math.min(mouseDownAt.y, e.clientY), - w: Math.abs(dx), - h: Math.abs(dy), - }; - hideOverlay(hoverOverlay); - positionOverlay(dragOverlay, dragRect.x, dragRect.y, dragRect.w, dragRect.h); + function selectorIsUnique(selector) { + try { + return document.querySelectorAll(selector).length === 1; + } catch (error) { + return false; + } + } + + function getSelector(element) { + var parts = []; + var current = element; + while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 8) { + var tag = current.tagName.toLowerCase(); + var id = current.getAttribute('id'); + if (id) { + parts.unshift(tag + '#' + escapeSelector(id)); + } else { + var part = tag; + var parent = current.parentElement; + if (parent) { + var siblings = Array.prototype.filter.call(parent.children, function (child) { + return child.tagName === current.tagName; + }); + if (siblings.length > 1) { + part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')'; + } + } + parts.unshift(part); + } + var selector = parts.join(' > '); + if (selector.length <= 4096 && selectorIsUnique(selector)) return selector; + current = current.parentElement; + } + return undefined; + } + + function elementClasses(element) { + if (!element.classList) return []; + return Array.prototype.slice.call(element.classList, 0, 128) + .map(function (value) { return String(value).slice(0, 1024); }); + } + + function accessibleLabel(element) { + var explicit = element.getAttribute('aria-label'); + if (explicit) return explicit.slice(0, 16384); + var labelledBy = element.getAttribute('aria-labelledby'); + if (labelledBy) { + var labels = labelledBy.split(/\s+/).map(function (id) { + var label = document.getElementById(id); + return label ? label.textContent || '' : ''; + }).join(' ').replace(/\s+/g, ' ').trim(); + if (labels) return labels.slice(0, 16384); + } + var alt = element.getAttribute('alt') || element.getAttribute('title'); + return alt ? alt.slice(0, 16384) : undefined; + } + + function captureElement(element) { + if (!element || element.nodeType !== Node.ELEMENT_NODE) return null; + var html = element.outerHTML || ''; + return { + tag: element.tagName.toLowerCase().slice(0, 128), + id: element.id ? String(element.id).slice(0, 1024) : undefined, + classes: elementClasses(element), + role: (element.getAttribute('role') || '').slice(0, 1024) || undefined, + accessible_label: accessibleLabel(element), + visible_text: (element.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 16384) || undefined, + selector: getSelector(element), + outer_html: html.length > MAX_OUTER_HTML ? html.slice(0, MAX_OUTER_HTML) : html, + }; + } + + function rectSnapshot(rect) { + return { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + document_x: rect.left + window.scrollX, + document_y: rect.top + window.scrollY, + scroll_x: window.scrollX, + scroll_y: window.scrollY, + viewport_width: window.innerWidth, + viewport_height: window.innerHeight, + device_pixel_ratio: window.devicePixelRatio || 1, + }; + } + + function unionRect(rects) { + if (!rects.length) return null; + var left = rects[0].left; + var top = rects[0].top; + var right = rects[0].right; + var bottom = rects[0].bottom; + for (var index = 1; index < rects.length; index += 1) { + left = Math.min(left, rects[index].left); + top = Math.min(top, rects[index].top); + right = Math.max(right, rects[index].right); + bottom = Math.max(bottom, rects[index].bottom); + } + return rectSnapshot({ + left: left, + top: top, + width: right - left, + height: bottom - top, + }); + } + + function commonElement(range) { + var node = range.commonAncestorContainer; + if (node.nodeType === Node.ELEMENT_NODE) return node; + return node.parentElement; + } + + function nodePath(root, node) { + var path = []; + var current = node; + while (current && current !== root) { + var parent = current.parentNode; + if (!parent) return []; + var index = Array.prototype.indexOf.call(parent.childNodes, current); + if (index < 0) return []; + path.unshift(index); + current = parent; + if (path.length > 128) return []; + } + return current === root ? path : []; + } + + function captureTextEndpoint(node, offset, root) { + return { + selector: getSelector(root), + node_path: nodePath(root, node), + offset: offset, + }; + } + + function captureTextSelection() { + var selection = window.getSelection(); + if (!selection || selection.isCollapsed || selection.rangeCount === 0) return false; + var selectedText = selection.toString(); + if (!selectedText) return false; + var range = selection.getRangeAt(0); + var container = commonElement(range); + var element = captureElement(container); + if (!container || !element) return false; + var clientRects = Array.prototype.slice.call(range.getClientRects(), 0, 512); + var identity = pageIdentity(); + send('capture-completed', { + kind: 'text', + page_route: identity.page_route, + page_title: identity.page_title, + selected_text: selectedText.slice(0, 262144), + text_anchor: { + start: captureTextEndpoint(range.startContainer, range.startOffset, container), + end: captureTextEndpoint(range.endContainer, range.endOffset, container), + rects: clientRects.map(rectSnapshot), + union_rect: unionRect(clientRects), + scroll_x: window.scrollX, + scroll_y: window.scrollY, + viewport_width: window.innerWidth, + viewport_height: window.innerHeight, + device_pixel_ratio: window.devicePixelRatio || 1, + containing_element: element, + }, + }); + setMode(null); + return true; + } + + function isInspectorNode(element) { + return !!(element && element.closest && element.closest('[data-kandev-inspector-ui]')); + } + + function candidateName(element) { + var name = element.tagName.toLowerCase(); + if (element.id) name += '#' + element.id; + var classes = elementClasses(element).slice(0, 2); + if (classes.length) name += '.' + classes.join('.'); + return name.slice(0, 1024); + } + + function ensureCandidateOverlay() { + if (candidateOverlay) return; + candidateOverlay = document.createElement('div'); + candidateOverlay.setAttribute('data-kandev-inspector-ui', 'candidate'); + candidateOverlay.setAttribute('aria-hidden', 'true'); + candidateOverlay.style.cssText = 'position:fixed;display:none;pointer-events:none;box-sizing:border-box;' + + 'border:2px solid #6366f1;background:rgba(99,102,241,0.14);z-index:' + Z_INDEX + ';'; + candidateLabel = document.createElement('div'); + candidateLabel.style.cssText = 'position:absolute;left:-2px;bottom:100%;max-width:320px;padding:3px 6px;' + + 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:#4f46e5;color:#fff;' + + 'font:600 11px/16px system-ui,sans-serif;border-radius:3px 3px 0 0;'; + candidateOverlay.appendChild(candidateLabel); + document.documentElement.appendChild(candidateOverlay); + } + + function showCandidate(element) { + if (!element || element.nodeType !== Node.ELEMENT_NODE || isInspectorNode(element)) return; + ensureCandidateOverlay(); + candidate = element; + var rect = element.getBoundingClientRect(); + candidateOverlay.style.display = 'block'; + candidateOverlay.style.left = rect.left + 'px'; + candidateOverlay.style.top = rect.top + 'px'; + candidateOverlay.style.width = rect.width + 'px'; + candidateOverlay.style.height = rect.height + 'px'; + candidateLabel.textContent = candidateName(element); + send('candidate-changed', { label: candidateLabel.textContent }); + } + + function hideCandidate() { + candidate = null; + if (candidateOverlay) candidateOverlay.style.display = 'none'; + send('candidate-changed', { label: null }); + } + + function ensureScreenshotOverlay() { + if (screenshotOverlay) return; + screenshotOverlay = document.createElement('div'); + screenshotOverlay.setAttribute('data-kandev-inspector-ui', 'screenshot-region'); + screenshotOverlay.setAttribute('aria-hidden', 'true'); + screenshotOverlay.style.cssText = 'position:fixed;display:none;pointer-events:none;box-sizing:border-box;' + + 'border:2px solid #f59e0b;background:rgba(245,158,11,0.16);z-index:' + Z_INDEX + ';'; + document.documentElement.appendChild(screenshotOverlay); + } + + function positionScreenshotOverlay(start, end) { + ensureScreenshotOverlay(); + var left = Math.min(start.x, end.x); + var top = Math.min(start.y, end.y); + var width = Math.abs(end.x - start.x); + var height = Math.abs(end.y - start.y); + screenshotOverlay.style.display = 'block'; + screenshotOverlay.style.left = left + 'px'; + screenshotOverlay.style.top = top + 'px'; + screenshotOverlay.style.width = width + 'px'; + screenshotOverlay.style.height = height + 'px'; + } + + function hideScreenshotOverlay() { + screenshotStart = null; + screenshotCurrent = null; + if (screenshotOverlay) screenshotOverlay.style.display = 'none'; + } + + function captureCandidate(element) { + if (!element || isInspectorNode(element)) return; + var snapshot = captureElement(element); + if (!snapshot) return; + var identity = pageIdentity(); + send('capture-completed', { + kind: 'element', + page_route: identity.page_route, + page_title: identity.page_title, + element_snapshot: snapshot, + capture_rect: rectSnapshot(element.getBoundingClientRect()), + }); + setMode(null); + } + + function onElementPointerMove(event) { + if (mode !== 'element') return; + showCandidate(event.target); + } + + function onElementFocus(event) { + if (mode !== 'element') return; + showCandidate(event.target); + } + + function onElementTouchStart(event) { + if (mode !== 'element' || !event.touches || !event.touches.length) return; + var touch = event.touches[0]; + showCandidate(document.elementFromPoint(touch.clientX, touch.clientY)); + } + + function onElementTouchEnd(event) { + if (mode !== 'element' || !candidate) return; + event.preventDefault(); + event.stopPropagation(); + captureCandidate(candidate); + } + + function onElementClick(event) { + if (mode !== 'element' || isInspectorNode(event.target)) return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + captureCandidate(event.target); + } + + function onTextMouseUp() { + if (mode !== 'text') return; + window.setTimeout(captureTextSelection, 0); + } + + function onTextTouchEnd() { + if (mode !== 'text') return; + window.setTimeout(captureTextSelection, 0); + } + + function onScreenshotPointerDown(event) { + if (mode !== 'screenshot') return; + event.preventDefault(); + event.stopPropagation(); + screenshotStart = { x: event.clientX, y: event.clientY, pointerId: event.pointerId }; + screenshotCurrent = { x: event.clientX, y: event.clientY }; + positionScreenshotOverlay(screenshotStart, screenshotStart); + } + + function onScreenshotPointerMove(event) { + if (mode !== 'screenshot') return; + if (!screenshotStart || screenshotStart.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + screenshotCurrent = { x: event.clientX, y: event.clientY }; + positionScreenshotOverlay(screenshotStart, screenshotCurrent); + } + + function completeScreenshot(end) { + if (mode !== 'screenshot' || !screenshotStart || !end) return false; + var start = screenshotStart; + var left = Math.min(start.x, end.x); + var top = Math.min(start.y, end.y); + var width = Math.abs(end.x - start.x); + var height = Math.abs(end.y - start.y); + hideScreenshotOverlay(); + if (width < 5 || height < 5) return false; + var identity = pageIdentity(); + send('screenshot-region-selected', { + page_route: identity.page_route, + page_title: identity.page_title, + capture_rect: rectSnapshot({ left: left, top: top, width: width, height: height }), + }); + setMode(null); + return true; + } + + function onScreenshotPointerUp(event) { + if (mode !== 'screenshot') return; + if (!screenshotStart || screenshotStart.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + completeScreenshot({ x: event.clientX, y: event.clientY }); + } + + function onScreenshotPointerCancel(event) { + if (mode !== 'screenshot') return; + if (screenshotStart && screenshotStart.pointerId !== event.pointerId) return; + hideScreenshotOverlay(); + } + + function onCaptureKeyDown(event) { + if (!mode) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.stopPropagation(); + if (mode === 'text') { + window.setTimeout(captureTextSelection, 0); + } else if (mode === 'screenshot') { + completeScreenshot(screenshotCurrent); } return; } - var el = e.target; - if (el === hoverOverlay || el === dragOverlay || (popup && popup.contains(el))) return; - hoverEl = el; - var r = el.getBoundingClientRect(); - positionOverlay(hoverOverlay, r.left, r.top, r.width, r.height); + if (event.key !== 'Escape') return; + event.preventDefault(); + setMode(null); + send('capture-cancelled', {}); } - function onMouseUp(e) { - if (popup && popup.contains(e.target)) return; - var wasDragging = !!dragRect; - var rect = dragRect; - var down = mouseDownAt; - mouseDownAt = null; - dragRect = null; - hideOverlay(dragOverlay); + function removeCaptureListeners() { + document.removeEventListener('mousemove', onElementPointerMove, true); + document.removeEventListener('focusin', onElementFocus, true); + document.removeEventListener('touchstart', onElementTouchStart, true); + document.removeEventListener('touchend', onElementTouchEnd, true); + document.removeEventListener('click', onElementClick, true); + document.removeEventListener('mouseup', onTextMouseUp, true); + document.removeEventListener('touchend', onTextTouchEnd, true); + document.removeEventListener('pointerdown', onScreenshotPointerDown, true); + document.removeEventListener('pointermove', onScreenshotPointerMove, true); + document.removeEventListener('pointerup', onScreenshotPointerUp, true); + document.removeEventListener('pointercancel', onScreenshotPointerCancel, true); + document.removeEventListener('keydown', onCaptureKeyDown, true); + } - if (wasDragging && rect && rect.w > DRAG_THRESHOLD && rect.h > DRAG_THRESHOLD) { - var inside = elementsInRect(rect); - startPending('area', rect, null, inside, rect.x + rect.w, rect.y); - return; - } - if (down) { - var clickEl = document.elementFromPoint(down.x, down.y); - if (!clickEl || clickEl === hoverOverlay || clickEl === dragOverlay) return; - if (popup && popup.contains(clickEl)) return; - var el = captureElement(clickEl); - startPending('pin', null, el, null, down.x, down.y); + function setMode(nextMode) { + removeCaptureListeners(); + mode = nextMode; + hideCandidate(); + hideScreenshotOverlay(); + document.documentElement.style.cursor = mode === 'element' || mode === 'screenshot' + ? 'crosshair' + : originalCursor; + document.documentElement.style.touchAction = mode === 'screenshot' ? 'none' : originalTouchAction; + if (!mode) return; + document.addEventListener('keydown', onCaptureKeyDown, true); + switch (mode) { + case 'element': + document.addEventListener('mousemove', onElementPointerMove, true); + document.addEventListener('focusin', onElementFocus, true); + document.addEventListener('touchstart', onElementTouchStart, true); + document.addEventListener('touchend', onElementTouchEnd, true); + document.addEventListener('click', onElementClick, true); + break; + case 'text': + document.addEventListener('mouseup', onTextMouseUp, true); + document.addEventListener('touchend', onTextTouchEnd, true); + break; + case 'screenshot': + document.addEventListener('pointerdown', onScreenshotPointerDown, true); + document.addEventListener('pointermove', onScreenshotPointerMove, true); + document.addEventListener('pointerup', onScreenshotPointerUp, true); + document.addEventListener('pointercancel', onScreenshotPointerCancel, true); + break; + default: + break; } } - function onClickCapture(e) { - if (popup && popup.contains(e.target)) return; - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); + function clearMarkerNodes() { + var markers = document.querySelectorAll('[data-kandev-inspector-marker]'); + for (var index = 0; index < markers.length; index += 1) { + if (markers[index].parentNode) markers[index].parentNode.removeChild(markers[index]); + } } - function onKeyDown(e) { - if (popup) return; - if (e.key === 'Escape') { e.preventDefault(); setActive(false); send('inspect-exited', {}); } + function markerRect(marker) { + var selector = marker.element_snapshot && marker.element_snapshot.selector; + if (selector) { + try { + var element = document.querySelector(selector); + if (element) return element.getBoundingClientRect(); + } catch (error) {} + } + var rect = marker.capture_rect || (marker.text_anchor && marker.text_anchor.union_rect); + if (!rect) return null; + var documentX = typeof rect.document_x === 'number' + ? rect.document_x + : rect.x + (typeof rect.scroll_x === 'number' ? rect.scroll_x : 0); + var documentY = typeof rect.document_y === 'number' + ? rect.document_y + : rect.y + (typeof rect.scroll_y === 'number' ? rect.scroll_y : 0); + return { left: documentX - window.scrollX, top: documentY - window.scrollY }; } - function setActive(isOn) { - if (isOn === active) return; - active = isOn; - if (isOn) { - ensureOverlays(); - document.body.style.cursor = 'crosshair'; - document.addEventListener('mousedown', onMouseDown, true); - document.addEventListener('mousemove', onMouseMove, true); - document.addEventListener('mouseup', onMouseUp, true); - document.addEventListener('click', onClickCapture, true); - document.addEventListener('keydown', onKeyDown, true); + function renderMarkers() { + clearMarkerNodes(); + var route = currentPageRoute(); + var visible = projectedMarkers.filter(function (marker) { return marker.page_route === route; }); + visible.forEach(function (marker, index) { + var rect = markerRect(marker); + if (!rect) return; + var node = document.createElement('div'); + node.setAttribute('data-kandev-inspector-marker', marker.id); + node.setAttribute('aria-hidden', 'true'); + node.style.cssText = 'position:fixed;left:' + (rect.left - 11) + 'px;top:' + (rect.top - 11) + + 'px;width:22px;height:22px;border-radius:50%;background:#4f46e5;color:#fff;' + + 'font:600 11px/22px system-ui,sans-serif;text-align:center;pointer-events:none;' + + 'box-shadow:0 1px 4px rgba(0,0,0,0.3);z-index:' + Z_INDEX + ';'; + node.textContent = String(index + 1); + document.documentElement.appendChild(node); + }); + } + + function routeDidChange() { + if (mode) { + setMode(null); + send('capture-cancelled', {}); } else { - document.removeEventListener('mousedown', onMouseDown, true); - document.removeEventListener('mousemove', onMouseMove, true); - document.removeEventListener('mouseup', onMouseUp, true); - document.removeEventListener('click', onClickCapture, true); - document.removeEventListener('keydown', onKeyDown, true); - document.body.style.cursor = ''; - hideOverlay(hoverOverlay); - hideOverlay(dragOverlay); - closePopup(); + hideCandidate(); } + var identity = pageIdentity(); + send('route-changed', identity); + renderMarkers(); } - function removeMarker(num) { - if (num == null) return; - var nodes = document.querySelectorAll('[data-kandev-marker="' + String(num) + '"]'); - for (var i = 0; i < nodes.length; i++) { - if (nodes[i].parentNode) nodes[i].parentNode.removeChild(nodes[i]); - } - } - - window.addEventListener('message', function (e) { - if (!e.data || e.data.source !== SOURCE) return; - if (e.data.type === 'toggle-inspect') setActive(!!(e.data.payload && e.data.payload.active)); - if (e.data.type === 'clear-annotations') clearMarkers(); - if (e.data.type === 'remove-marker') removeMarker(e.data.payload && e.data.payload.number); + var originalPushState = history.pushState; + history.pushState = function () { + var result = originalPushState.apply(this, arguments); + routeDidChange(); + return result; + }; + var originalReplaceState = history.replaceState; + history.replaceState = function () { + var result = originalReplaceState.apply(this, arguments); + routeDidChange(); + return result; + }; + window.addEventListener('popstate', routeDidChange); + window.addEventListener('hashchange', routeDidChange); + window.addEventListener('scroll', renderMarkers, true); + window.addEventListener('resize', function () { + if (candidate) showCandidate(candidate); + renderMarkers(); }); - send('inspector-ready', {}); + window.addEventListener('message', function (event) { + var message = event.data; + if (!message || message.source !== SOURCE || message.version !== PROTOCOL_VERSION) return; + switch (message.type) { + case 'set-capture-mode': + setMode(message.payload && message.payload.mode); + break; + case 'project-markers': + projectedMarkers = message.payload && Array.isArray(message.payload.markers) + ? message.payload.markers.slice() + : []; + renderMarkers(); + break; + default: + break; + } + }); + + send('inspector-ready', pageIdentity()); })(); diff --git a/apps/backend/internal/agentctl/server/config/config_test.go b/apps/backend/internal/agentctl/server/config/config_test.go index 60dabd3bb1..9868cc827e 100644 --- a/apps/backend/internal/agentctl/server/config/config_test.go +++ b/apps/backend/internal/agentctl/server/config/config_test.go @@ -275,6 +275,7 @@ func TestCollectAgentEnvGitHubCLIShimSurvivesLoginShell(t *testing.T) { "KANDEV_GITHUB_CLI_BASH_ENV": bashEnv, "BASH_ENV": parentBashEnv, "KANDEV_BASH_HOOK_MARKER": marker, + "HOME": t.TempDir(), pathEnvKey: "/usr/bin:/bin", }) if err != nil { diff --git a/apps/backend/internal/agentctl/server/process/git_empty_remote_test.go b/apps/backend/internal/agentctl/server/process/git_empty_remote_test.go index 0d03a4d9d5..82cc2663ca 100644 --- a/apps/backend/internal/agentctl/server/process/git_empty_remote_test.go +++ b/apps/backend/internal/agentctl/server/process/git_empty_remote_test.go @@ -339,6 +339,7 @@ func setupEmptyRemoteTaskRepo(t *testing.T) (string, string, *GitOperator) { originDir := filepath.Join(root, "origin.git") repoDir := filepath.Join(root, "repo") runGit(t, root, "init", "--bare", "--initial-branch=main", originDir) + runGit(t, originDir, "config", "core.hooksPath", filepath.Join(originDir, "hooks")) runGit(t, root, "init", "-b", "main", repoDir) runGit(t, repoDir, "config", "user.name", "Task User") runGit(t, repoDir, "config", "user.email", "task@example.com") diff --git a/apps/backend/internal/agentctl/server/process/git_push_preflight_test.go b/apps/backend/internal/agentctl/server/process/git_push_preflight_test.go index da3338905b..f4b5ac375e 100644 --- a/apps/backend/internal/agentctl/server/process/git_push_preflight_test.go +++ b/apps/backend/internal/agentctl/server/process/git_push_preflight_test.go @@ -88,17 +88,17 @@ func TestPushPreflightUsesStableGitEnvironment(t *testing.T) { } t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("KANDEV_TEST_PUSH_ENV", envPath) - t.Setenv("LANG", "pt_PT.UTF-8") - t.Setenv("LC_ALL", "pt_PT.UTF-8") + t.Setenv("LANG", "POSIX") + t.Setenv("LC_ALL", "POSIX") t.Setenv("GIT_TERMINAL_PROMPT", "1") - _, err = operator.PushPreflight(context.Background(), PushOptions{}) + result, err := operator.PushPreflight(context.Background(), PushOptions{}) if err != nil { t.Fatalf("PushPreflight returned error: %v", err) } env, err := os.ReadFile(envPath) if err != nil { - t.Fatalf("read recorded Git environment: %v", err) + t.Fatalf("read recorded Git environment: %v; result=%+v", err, result) } want := "LANG=C\nLC_ALL=C\nGIT_TERMINAL_PROMPT=0\n" if got := string(env); got != want { diff --git a/apps/backend/internal/agentctl/server/process/manager_rescan_test.go b/apps/backend/internal/agentctl/server/process/manager_rescan_test.go index 0c44c08e15..b0edd0555a 100644 --- a/apps/backend/internal/agentctl/server/process/manager_rescan_test.go +++ b/apps/backend/internal/agentctl/server/process/manager_rescan_test.go @@ -670,6 +670,7 @@ func initGitRepoAt(t *testing.T, dir string) { runIn("git", "config", "user.email", "test@example.com") runIn("git", "config", "user.name", "Test User") runIn("git", "config", "commit.gpgsign", "false") + runIn("git", "config", "core.hooksPath", os.DevNull) runIn("git", "commit", "--allow-empty", "-m", "initial") } diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go b/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go index 09ec348b0f..da188ad2db 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go @@ -158,6 +158,7 @@ func TestRunGit_DisablesCommitSigning(t *testing.T) { runGit(t, repoDir, "init", "--initial-branch=main") runGit(t, repoDir, "config", "user.email", "test@test.com") runGit(t, repoDir, "config", "user.name", "Test User") + runGit(t, repoDir, "config", "core.hooksPath", os.DevNull) runGit(t, repoDir, "config", "commit.gpgsign", "true") runGit(t, repoDir, "config", "gpg.format", "ssh") runGit(t, repoDir, "config", "user.signingkey", "~/.ssh/id_ed25519.pub") @@ -333,6 +334,7 @@ func TestGetGitStatus_ReportsUpstreamHeadForHistoryStates(t *testing.T) { providerDir := filepath.Join(providerRoot, "provider") runGit(t, providerDir, "config", "user.email", "provider@test.com") runGit(t, providerDir, "config", "user.name", "Provider User") + runGit(t, providerDir, "config", "core.hooksPath", os.DevNull) runGit(t, providerDir, "checkout", "-b", "feature/pr", "origin/feature/pr") writeFile(t, providerDir, "provider.txt", "provider") runGit(t, providerDir, "add", ".") diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index db08368361..b859975a4f 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -746,6 +746,10 @@ func registerRoutes(p routeParams) { } // Per-user task scoping for plan reads/writes (opt-in auth). planService.SetTaskAuthorizer(p.taskSvc.AuthorizeTaskAccess) + if attachments := p.taskSvc.AttachmentService(); attachments != nil { + planService.SetPreviewAttachmentCleaner(attachments) + planService.SetPreviewScreenshotValidator(attachments) + } // Stamps each plan revision with the task's workflow step at write time. planService.SetWorkflowStepGetter(&workflowStepGetterAdapter{svc: p.services.Workflow}) clarificationStore := clarification.NewStore(2 * time.Hour) diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 72a2a77948..40d7899ecd 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -123,12 +123,13 @@ const TaskStatusSummaryUpdated = "task.status_summary.updated" // Event types for task plans const ( - TaskPlanCreated = "task_plan.created" - TaskPlanUpdated = "task_plan.updated" - TaskPlanDeleted = "task_plan.deleted" - TaskPlanRevisionCreated = "task_plan.revision.created" - TaskPlanReverted = "task_plan.reverted" - TaskPlanCommentsChanged = "task_plan.comments.changed" + TaskPlanCreated = "task_plan.created" + TaskPlanUpdated = "task_plan.updated" + TaskPlanDeleted = "task_plan.deleted" + TaskPlanRevisionCreated = "task_plan.revision.created" + TaskPlanReverted = "task_plan.reverted" + TaskPlanCommentsChanged = "task_plan.comments.changed" + TaskPreviewFeedbackChanged = "task.preview_feedback.changed" ) // Event types for task walkthroughs (agent-authored guided code tours) diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go index 22b53a60a3..81d6dfb7d2 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications.go +++ b/apps/backend/internal/gateway/websocket/task_notifications.go @@ -53,6 +53,7 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub * b.subscribe(eventBus, events.TaskPlanRevisionCreated, ws.ActionTaskPlanRevisionCreated) b.subscribe(eventBus, events.TaskPlanReverted, ws.ActionTaskPlanReverted) b.subscribe(eventBus, events.TaskPlanCommentsChanged, ws.ActionTaskPlanCommentsChanged) + b.subscribe(eventBus, events.TaskPreviewFeedbackChanged, ws.ActionTaskPreviewFeedbackChanged) b.subscribe(eventBus, events.TaskWalkthroughCreated, ws.ActionTaskWalkthroughCreated) b.subscribe(eventBus, events.TaskWalkthroughUpdated, ws.ActionTaskWalkthroughUpdated) b.subscribe(eventBus, events.TaskWalkthroughDeleted, ws.ActionTaskWalkthroughDeleted) @@ -266,11 +267,14 @@ func (b *TaskEventBroadcaster) routeBroadcast( msg *ws.Message, ) error { switch action { - case ws.ActionTaskPlanCommentsChanged: + case ws.ActionTaskPlanCommentsChanged, ws.ActionTaskPreviewFeedbackChanged: taskID := extractStringField(data, "task_id") if snapshot, ok := data.(*models.TaskPlanCommentSnapshot); ok { taskID = snapshot.TaskID } + if snapshot, ok := data.(*models.TaskPreviewFeedbackSnapshot); ok { + taskID = snapshot.TaskID + } if taskID != "" { b.hub.BroadcastToTask(taskID, msg) } diff --git a/apps/backend/internal/gateway/websocket/task_notifications_test.go b/apps/backend/internal/gateway/websocket/task_notifications_test.go index d81cb748a0..fcbc430271 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications_test.go +++ b/apps/backend/internal/gateway/websocket/task_notifications_test.go @@ -245,6 +245,7 @@ func TestTaskEventBroadcaster_NoDuplicateSubscriptions(t *testing.T) { events.GitHubTaskPRDeleted, events.GitLabTaskMRUpdated, events.GitLabTaskMRDeleted, + events.TaskPreviewFeedbackChanged, } { subject := subject t.Run(subject, func(t *testing.T) { diff --git a/apps/backend/internal/launcher/service_native_update_test.go b/apps/backend/internal/launcher/service_native_update_test.go index 451af24142..4c316ba0db 100644 --- a/apps/backend/internal/launcher/service_native_update_test.go +++ b/apps/backend/internal/launcher/service_native_update_test.go @@ -66,6 +66,8 @@ func TestNativeServiceRenderersIncludeManagedIdentity(t *testing.T) { } func TestInstallSystemdWritesOwnerOnlyNativeMetadata(t *testing.T) { + t.Setenv("KANDEV_BUNDLE_DIR", "") + t.Setenv("KANDEV_VERSION", "") originalExecutablePath := executablePath originalExecuteServiceCommand := executeServiceCommand originalServicePrintln := servicePrintln @@ -126,6 +128,8 @@ func TestInstallSystemdWritesOwnerOnlyNativeMetadata(t *testing.T) { } func TestInstallLaunchdWritesNativeMetadata(t *testing.T) { + t.Setenv("KANDEV_BUNDLE_DIR", "") + t.Setenv("KANDEV_VERSION", "") originalExecutablePath := executablePath originalExecuteServiceCommand := executeServiceCommand originalServicePrintln := servicePrintln diff --git a/apps/backend/internal/orchestrator/handlers/queue_handlers.go b/apps/backend/internal/orchestrator/handlers/queue_handlers.go index 85d7a663db..5793512b3f 100644 --- a/apps/backend/internal/orchestrator/handlers/queue_handlers.go +++ b/apps/backend/internal/orchestrator/handlers/queue_handlers.go @@ -21,7 +21,9 @@ import ( "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/plancomments" + "github.com/kandev/kandev/internal/task/previewfeedback" "github.com/kandev/kandev/internal/task/repository/plancommenttx" + "github.com/kandev/kandev/internal/task/repository/previewfeedbacktx" "github.com/kandev/kandev/internal/task/repository/repoerrors" v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" @@ -289,6 +291,16 @@ type QueueAttachmentClaimPreparer interface { PrepareQueueAttachmentClaim(context.Context, string, []v1.MessageAttachment) (messagequeue.QueueAttachmentClaim, error) } +type previewFeedbackAttachmentPreparer interface { + PreviewFeedbackAttachments(context.Context, string, []models.TaskPreviewFeedbackRef) ([]v1.MessageAttachment, error) +} + +type planCommentQueueAttachmentPreparation struct { + claim *messagequeue.QueueAttachmentClaim + previewResolver func(context.Context, string, []models.TaskPreviewFeedbackRef) ([]messagequeue.MessageAttachment, error) + claimResolver func(context.Context, string, []messagequeue.MessageAttachment) (messagequeue.QueueAttachmentClaim, error) +} + type QueueAttachmentReleaser interface { ReleaseMessageAttachments(ctx context.Context, taskID, sessionID string, attachments []v1.MessageAttachment) error } @@ -561,6 +573,7 @@ type wsQueueMessageRequest struct { ContextFiles []v1.ContextFileMeta `json:"context_files,omitempty"` EntityReferences []v1.EntityReference `json:"entity_references,omitempty"` PlanCommentRefs []models.TaskPlanCommentRef `json:"plan_comment_refs,omitempty"` + PreviewFeedbackRefs []models.TaskPreviewFeedbackRef `json:"preview_feedback_refs,omitempty"` RequirePrimarySession bool `json:"require_primary_session,omitempty"` UserID string `json:"user_id,omitempty"` } @@ -588,7 +601,7 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w } return denied, nil } - if req.Content == "" && len(req.Attachments) == 0 && len(req.PlanCommentRefs) == 0 { + if req.Content == "" && len(req.Attachments) == 0 && len(req.PlanCommentRefs) == 0 && len(req.PreviewFeedbackRefs) == 0 { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "content or attachments are required", nil) } if validationError := validatePlanCommentQueueRequest(req); validationError != "" { @@ -606,7 +619,7 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, reservedIdentityError(req.UserID), nil) } var err error - identifiedOrdinary := req.ClientQueueID != "" && len(req.PlanCommentRefs) == 0 + identifiedOrdinary := req.ClientQueueID != "" && len(req.PlanCommentRefs) == 0 && len(req.PreviewFeedbackRefs) == 0 if identifiedOrdinary { references, normalizeErr := entityrefs.NormalizeForSubmission(req.EntityReferences) if normalizeErr != nil { @@ -639,6 +652,7 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w metadata[messagequeue.MetadataQueueAdmissionIDs] = []string{req.ClientQueueID} } var snapshot *models.TaskPlanCommentSnapshot + var previewSnapshot *models.TaskPreviewFeedbackSnapshot var replay bool var queued *messagequeue.QueuedMessage switch { @@ -647,11 +661,11 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w if errors.Is(err, errQueueInvalidReferences) { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, queueInvalidReferences, nil) } - case len(req.PlanCommentRefs) > 0: + case len(req.PlanCommentRefs) > 0 || len(req.PreviewFeedbackRefs) > 0: result, admissionErr := h.admitPlanCommentQueuedMessage(ctx, &req, queuedBy, metadata) err = admissionErr if result != nil { - queued, snapshot, replay = result.Message, result.Snapshot, result.Replay + queued, snapshot, previewSnapshot, replay = result.Message, result.Snapshot, result.PreviewSnapshot, result.Replay } default: queued, err = h.admitQueuedMessage(ctx, &req, queuedBy, metadata) @@ -686,6 +700,9 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w if errors.Is(err, errQueuedAttachmentUnavailable) { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Attachment is no longer available", nil) } + if errors.Is(err, errPreviewFeedbackAttachmentInvalid) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "attachment metadata is invalid", nil) + } if conflict := planCommentQueueError(msg, err); conflict != nil { return conflict, nil } @@ -700,6 +717,7 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w } if !replay { h.publishPlanCommentSnapshot(ctx, snapshot) + h.publishPreviewFeedbackSnapshot(ctx, previewSnapshot) } h.publishStatusForIdentity(ctx, identity, queued) if h.queueReadiness != nil { @@ -709,11 +727,12 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w } var ( - errQueueInvalidReferences = errors.New("invalid queued entity references") - errQueuedAttachmentUnavailable = errors.New("queued attachment unavailable") - errQueuedAttachmentRollback = errors.New("queued attachment rollback failed") - errAttachmentCleanupLeaseActive = errors.New("queue attachment cleanup blocked by edit lease") - errAttachmentCleanupEntryChanged = errors.New("queue attachment cleanup entry changed") + errQueueInvalidReferences = errors.New("invalid queued entity references") + errQueuedAttachmentUnavailable = errors.New("queued attachment unavailable") + errQueuedAttachmentRollback = errors.New("queued attachment rollback failed") + errPreviewFeedbackAttachmentInvalid = errors.New("preview feedback attachment metadata is invalid") + errAttachmentCleanupLeaseActive = errors.New("queue attachment cleanup blocked by edit lease") + errAttachmentCleanupEntryChanged = errors.New("queue attachment cleanup entry changed") ) func (h *QueueHandlers) admitIdentifiedOrdinaryQueuedMessage( @@ -917,36 +936,111 @@ func (h *QueueHandlers) admitPlanCommentQueuedMessage( return nil, errors.New("plan comment queue admission is unavailable") } attachments := queueAttachmentsToV1(req.Attachments) - var attachmentClaim *messagequeue.QueueAttachmentClaim - if h.attachmentClaimer != nil && len(attachments) > 0 { - preparer, ok := h.attachmentClaimer.(QueueAttachmentClaimPreparer) - if !ok { - return nil, errors.New("transactional attachment admission is unavailable") - } - claim, err := preparer.PrepareQueueAttachmentClaim(ctx, req.TaskID, attachments) - if err != nil { - return nil, fmt.Errorf("%w: %v", errQueuedAttachmentUnavailable, err) - } - attachmentClaim = &claim + prepared, err := h.preparePlanCommentQueueAttachments(ctx, req, attachments) + if err != nil { + return nil, err } return service.QueueMessageWithPlanComments(ctx, messagequeue.PlanCommentQueueRequest{ ClientQueueID: req.ClientQueueID, SessionID: req.SessionID, TaskID: req.TaskID, SessionIncarnationID: req.SessionIncarnationID, Content: req.Content, Model: req.Model, UserID: queuedBy, PlanMode: req.PlanMode, Attachments: req.Attachments, Metadata: metadata, PlanCommentRefs: req.PlanCommentRefs, - RequirePrimarySession: req.RequirePrimarySession, AttachmentClaim: attachmentClaim, + PreviewFeedbackRefs: req.PreviewFeedbackRefs, + RequirePrimarySession: req.RequirePrimarySession, AttachmentClaim: prepared.claim, + PreviewFeedbackAttachmentResolver: prepared.previewResolver, + AttachmentClaimResolver: prepared.claimResolver, }) } +func (h *QueueHandlers) preparePlanCommentQueueAttachments( + ctx context.Context, + req *wsQueueMessageRequest, + attachments []v1.MessageAttachment, +) (planCommentQueueAttachmentPreparation, error) { + if len(req.PreviewFeedbackRefs) == 0 { + return h.prepareOrdinaryQueueAttachments(ctx, req.TaskID, attachments) + } + return h.preparePreviewFeedbackQueueAttachments(req) +} + +func (h *QueueHandlers) prepareOrdinaryQueueAttachments( + ctx context.Context, + taskID string, + attachments []v1.MessageAttachment, +) (planCommentQueueAttachmentPreparation, error) { + if h.attachmentClaimer == nil || len(attachments) == 0 { + return planCommentQueueAttachmentPreparation{}, nil + } + preparer, ok := h.attachmentClaimer.(QueueAttachmentClaimPreparer) + if !ok { + return planCommentQueueAttachmentPreparation{}, errors.New("transactional attachment admission is unavailable") + } + claim, err := preparer.PrepareQueueAttachmentClaim(ctx, taskID, attachments) + if err != nil { + return planCommentQueueAttachmentPreparation{}, fmt.Errorf("%w: %v", errQueuedAttachmentUnavailable, err) + } + return planCommentQueueAttachmentPreparation{claim: &claim}, nil +} + +func (h *QueueHandlers) preparePreviewFeedbackQueueAttachments( + req *wsQueueMessageRequest, +) (planCommentQueueAttachmentPreparation, error) { + preparer, ok := h.attachmentClaimer.(previewFeedbackAttachmentPreparer) + if !ok { + return planCommentQueueAttachmentPreparation{}, nil + } + prepared := planCommentQueueAttachmentPreparation{ + previewResolver: func( + resolveCtx context.Context, + taskID string, + refs []models.TaskPreviewFeedbackRef, + ) ([]messagequeue.MessageAttachment, error) { + resolved, err := preparer.PreviewFeedbackAttachments(resolveCtx, taskID, refs) + if err != nil { + return nil, err + } + converted := queueAttachmentsFromV1(resolved) + if err := validatePreviewFeedbackQueueAttachments(req.Attachments, converted); err != nil { + return nil, err + } + return converted, nil + }, + } + if claimPreparer, canPrepare := h.attachmentClaimer.(QueueAttachmentClaimPreparer); canPrepare && len(req.Attachments) > 0 { + prepared.claimResolver = func( + resolveCtx context.Context, + taskID string, + candidate []messagequeue.MessageAttachment, + ) (messagequeue.QueueAttachmentClaim, error) { + return claimPreparer.PrepareQueueAttachmentClaim(resolveCtx, taskID, queueAttachmentsToV1(candidate)) + } + } + return prepared, nil +} + +func validatePreviewFeedbackQueueAttachments( + caller []messagequeue.MessageAttachment, + resolved []messagequeue.MessageAttachment, +) error { + all := append(append([]messagequeue.MessageAttachment{}, caller...), resolved...) + if invalid := firstInvalidDeliveryMode(all); invalid >= 0 { + return fmt.Errorf("%w: attachment delivery mode at index %d", errPreviewFeedbackAttachmentInvalid, invalid) + } + if invalid := firstInvalidAttachment(all); invalid >= 0 { + return fmt.Errorf("%w at index %d", errPreviewFeedbackAttachmentInvalid, invalid) + } + return nil +} + func validatePlanCommentQueueRequest(req wsQueueMessageRequest) string { if len(req.ClientQueueID) > messagequeue.MaxQueueAdmissionIDLength { return "client_queue_id is too long" } - if len(req.PlanCommentRefs) == 0 { + if len(req.PlanCommentRefs) == 0 && len(req.PreviewFeedbackRefs) == 0 { return "" } if req.ClientQueueID == "" { - return "client_queue_id is required with plan comments" + return "client_queue_id is required with task feedback" } if plancomments.ContainsReservedPlaceholder(req.Content) { return "content contains a reserved plan comment marker" @@ -961,14 +1055,34 @@ func validatePlanCommentQueueRequest(req wsQueueMessageRequest) string { } seen[ref.ID] = struct{}{} } + seenPreview := make(map[string]struct{}, len(req.PreviewFeedbackRefs)) + for _, ref := range req.PreviewFeedbackRefs { + if ref.ID == "" || ref.Version <= 0 { + return "preview_feedback_refs are invalid" + } + if _, duplicate := seenPreview[ref.ID]; duplicate { + return "preview_feedback_refs contain duplicates" + } + seenPreview[ref.ID] = struct{}{} + } return "" } func planCommentQueueError(msg *ws.Message, err error) *ws.Message { - if errors.Is(err, plancomments.ErrRenderedTooLarge) { + if errors.Is(err, plancomments.ErrRenderedTooLarge) || errors.Is(err, previewfeedback.ErrPromptTooLarge) { response, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Rendered message content is too long", nil) return response } + var previewChanged *previewfeedbacktx.FeedbackChangedError + if errors.As(err, &previewChanged) { + details := map[string]interface{}{} + if previewChanged.Snapshot != nil { + details["snapshot"] = dto.TaskPreviewFeedbackSnapshotFromModel(previewChanged.Snapshot) + } + response, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodePreviewFeedbackChanged, + "Task preview feedback changed", details) + return response + } var commentsChanged *plancommenttx.CommentsChangedError if errors.As(err, &commentsChanged) { details := map[string]interface{}{} @@ -1023,6 +1137,19 @@ func (h *QueueHandlers) publishPlanCommentSnapshot( } } +func (h *QueueHandlers) publishPreviewFeedbackSnapshot( + ctx context.Context, + snapshot *models.TaskPreviewFeedbackSnapshot, +) { + if snapshot == nil || h.eventBus == nil { + return + } + if err := h.eventBus.Publish(ctx, events.TaskPreviewFeedbackChanged, + bus.NewEvent(events.TaskPreviewFeedbackChanged, "queue-handlers", snapshot)); err != nil { + h.logger.Error("publish consumed preview feedback", zap.String("task_id", snapshot.TaskID), zap.Error(err)) + } +} + func (h *QueueHandlers) claimQueuedAttachmentsAfterInsert(req *wsQueueMessageRequest) func(context.Context, *messagequeue.QueuedMessage) error { return func(admittedCtx context.Context, source *messagequeue.QueuedMessage) error { claimErr := h.attachmentClaimer.ClaimMessageAttachments( @@ -2322,6 +2449,18 @@ func queueAttachmentsToV1(attachments []messagequeue.MessageAttachment) []v1.Mes return converted } +func queueAttachmentsFromV1(attachments []v1.MessageAttachment) []messagequeue.MessageAttachment { + converted := make([]messagequeue.MessageAttachment, 0, len(attachments)) + for _, attachment := range attachments { + converted = append(converted, messagequeue.MessageAttachment{ + AttachmentID: attachment.AttachmentID, Type: attachment.Type, Data: attachment.Data, + MimeType: attachment.MimeType, Name: attachment.Name, SizeBytes: attachment.SizeBytes, + DeliveryMode: attachment.DeliveryMode, + }) + } + return converted +} + type wsAppendToQueueRequest struct { SessionID string `json:"session_id"` TaskID string `json:"task_id"` diff --git a/apps/backend/internal/orchestrator/handlers/queue_handlers_plan_comments_test.go b/apps/backend/internal/orchestrator/handlers/queue_handlers_plan_comments_test.go index 50c07096f9..4658db69d4 100644 --- a/apps/backend/internal/orchestrator/handlers/queue_handlers_plan_comments_test.go +++ b/apps/backend/internal/orchestrator/handlers/queue_handlers_plan_comments_test.go @@ -12,6 +12,7 @@ import ( "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/plancomments" "github.com/kandev/kandev/internal/task/repository/plancommenttx" + "github.com/kandev/kandev/internal/task/repository/previewfeedbacktx" v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" "github.com/stretchr/testify/require" @@ -57,6 +58,15 @@ type recordingPlanCommentAttachmentPreparer struct { prepared []string claim messagequeue.QueueAttachmentClaim err error + preview []v1.MessageAttachment +} + +func (p *recordingPlanCommentAttachmentPreparer) PreviewFeedbackAttachments( + _ context.Context, + _ string, + _ []models.TaskPreviewFeedbackRef, +) ([]v1.MessageAttachment, error) { + return p.preview, p.err } func (*recordingPlanCommentAttachmentPreparer) ClaimMessageAttachments( @@ -134,6 +144,31 @@ func TestWsQueueMessageAdmitsPlanCommentsAndPublishesSnapshot(t *testing.T) { require.Same(t, snapshot, eventBus.events[0].Data) } +func TestWsQueueMessageAdmitsPreviewFeedbackAndPublishesSnapshot(t *testing.T) { + handlers, service, eventBus := newPlanCommentQueueHandlers(t) + handlers.SetAttachmentClaimer(&recordingPlanCommentAttachmentPreparer{}) + refs := []models.TaskPreviewFeedbackRef{{ID: "preview", Version: 3}} + snapshot := &models.TaskPreviewFeedbackSnapshot{TaskID: "task", Revision: 7} + service.result = &messagequeue.PlanCommentQueueResult{ + Message: &messagequeue.QueuedMessage{ + ID: "client-queue", SessionID: "session", TaskID: "task", + Content: "resolved feedback", QueuedBy: messagequeue.QueuedByUser, + }, + PreviewSnapshot: snapshot, + } + + response, err := handlers.wsQueueMessage(context.Background(), createTestMessage(t, ws.ActionMessageQueueAdd, map[string]interface{}{ + "session_id": "session", "task_id": "task", "session_incarnation_id": "incarnation", + "client_queue_id": "client-queue", "preview_feedback_refs": refs, + })) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, response.Type) + require.NotNil(t, service.request) + require.Equal(t, refs, service.request.PreviewFeedbackRefs) + require.Equal(t, []string{events.TaskPreviewFeedbackChanged, events.MessageQueueStatusChanged}, eventBus.subjects) + require.Same(t, snapshot, eventBus.events[0].Data) +} + func TestWsQueueMessageValidatesPlanCommentAdmission(t *testing.T) { tests := []struct { name string @@ -190,6 +225,13 @@ func TestWsQueueMessageMapsPlanCommentConflicts(t *testing.T) { }}, code: ws.ErrorCodePlanCommentsChanged, }, + { + name: "preview feedback changed", + err: &previewfeedbacktx.FeedbackChangedError{Snapshot: &models.TaskPreviewFeedbackSnapshot{ + TaskID: "task", Revision: 9, + }}, + code: ws.ErrorCodePreviewFeedbackChanged, + }, { name: "primary changed", err: &plancommenttx.PrimarySessionChangedError{ @@ -210,6 +252,7 @@ func TestWsQueueMessageMapsPlanCommentConflicts(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { handlers, service, eventBus := newPlanCommentQueueHandlers(t) + handlers.SetAttachmentClaimer(&recordingPlanCommentAttachmentPreparer{}) service.err = test.err response, err := handlers.wsQueueMessage(context.Background(), createTestMessage(t, ws.ActionMessageQueueAdd, map[string]interface{}{ "session_id": "session", "task_id": "task", "client_queue_id": "queue", diff --git a/apps/backend/internal/orchestrator/handlers/queue_handlers_preview_feedback_test.go b/apps/backend/internal/orchestrator/handlers/queue_handlers_preview_feedback_test.go new file mode 100644 index 0000000000..0d5c3a08f6 --- /dev/null +++ b/apps/backend/internal/orchestrator/handlers/queue_handlers_preview_feedback_test.go @@ -0,0 +1,228 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/common/logger" + dbutil "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/task/models" + tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite" + v1 "github.com/kandev/kandev/pkg/api/v1" + ws "github.com/kandev/kandev/pkg/websocket" + "github.com/stretchr/testify/require" +) + +type replayPreviewFeedbackPreparer struct { + calls int + err error + preview []v1.MessageAttachment +} + +func (p *replayPreviewFeedbackPreparer) PreviewFeedbackAttachments( + context.Context, + string, + []models.TaskPreviewFeedbackRef, +) ([]v1.MessageAttachment, error) { + p.calls++ + return p.preview, p.err +} + +func (*replayPreviewFeedbackPreparer) ClaimMessageAttachments( + context.Context, + string, + string, + []v1.MessageAttachment, +) error { + return nil +} + +// @covers AC-TASKS-WEB-PREVIEW-FEEDBACK-003.5 +// @covers AC-TASKS-WEB-PREVIEW-FEEDBACK-003.6 +func TestWsQueuePreviewFeedbackLostResponseReplay(t *testing.T) { + taskRepo, queueRepo := newPreviewFeedbackQueueStores(t) + ctx := context.Background() + seedPreviewFeedbackQueueTask(t, ctx, taskRepo) + preview := &models.TaskPreviewFeedback{ + ID: "preview-handler-replay", + TaskID: "task-handler-replay", + Kind: models.TaskPreviewFeedbackText, + Comment: "Keep the heading visible", + SourceKind: models.TaskPreviewFeedbackBrowser, + SourceLabel: "Local app", + PageRoute: "/checkout", + SelectedText: "Checkout", + TextAnchor: json.RawMessage(`{"start":{"node_path":[0],"offset":0},"end":{"node_path":[0],"offset":8}}`), + } + _, err := taskRepo.CreateTaskPreviewFeedback(ctx, preview, "", "") + require.NoError(t, err) + + service := messagequeue.NewService(queueRepo, 10, logger.Default()) + service.SetAutoMergeEnabled(false) + preparer := &replayPreviewFeedbackPreparer{} + handlers := NewQueueHandlers(service, &mockEventBus{}, logger.Default(), nil, allowQueueIdentityAccess{}, nil) + handlers.SetAttachmentClaimer(preparer) + + identity, err := service.ResolveSessionIdentity(ctx, preview.TaskID, "session-handler-replay") + require.NoError(t, err) + payload := map[string]interface{}{ + "task_id": preview.TaskID, + "session_id": identity.SessionID, + "session_incarnation_id": identity.SessionIncarnationID, + "client_queue_id": "queue-handler-replay", + "content": "Please review this", + "preview_feedback_refs": []models.TaskPreviewFeedbackRef{{ID: preview.ID, Version: 1}}, + } + + first, err := handlers.wsQueueMessage(ctx, createTestMessage(t, ws.ActionMessageQueueAdd, payload)) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, first.Type) + require.Equal(t, 1, preparer.calls) + + reserved, autoRun, err := queueRepo.ReserveHeadIfAutoRunForSession(ctx, identity) + require.NoError(t, err) + require.True(t, autoRun) + require.NotNil(t, reserved) + require.NoError(t, queueRepo.AcknowledgeByIDForSession(ctx, identity, reserved)) + + preparer.err = errors.New("pending feedback was consumed") + newPending := &models.TaskPreviewFeedback{ + ID: "preview-handler-replay-new", + TaskID: preview.TaskID, + Kind: models.TaskPreviewFeedbackText, + Comment: "Keep the footer visible", + SourceKind: models.TaskPreviewFeedbackBrowser, + SourceLabel: "Local app", + PageRoute: "/account", + SelectedText: "Account", + TextAnchor: json.RawMessage(`{"start":{"node_path":[0],"offset":0},"end":{"node_path":[0],"offset":7}}`), + } + _, err = taskRepo.CreateTaskPreviewFeedback(ctx, newPending, "", "") + require.NoError(t, err) + second, err := handlers.wsQueueMessage(ctx, createTestMessage(t, ws.ActionMessageQueueAdd, payload)) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, second.Type) + require.Equal(t, 1, preparer.calls, "an exact replay must not read consumed feedback") + + var replayed messagequeue.QueuedMessage + require.NoError(t, json.Unmarshal(second.Payload, &replayed)) + require.Contains(t, replayed.Content, "Keep the heading visible") + entries, err := queueRepo.ListBySession(ctx, identity.SessionID) + require.NoError(t, err) + require.Empty(t, entries) + pending, err := taskRepo.ListTaskPreviewFeedback(ctx, preview.TaskID) + require.NoError(t, err) + require.Len(t, pending.Items, 1) + require.Equal(t, newPending.ID, pending.Items[0].ID) +} + +// @covers AC-TASKS-WEB-PREVIEW-FEEDBACK-003.5 +// @covers AC-TASKS-WEB-PREVIEW-FEEDBACK-003.6 +func TestWsQueuePreviewFeedbackScreenshotLostResponseReplay(t *testing.T) { + taskRepo, queueRepo := newPreviewFeedbackQueueStores(t) + ctx := context.Background() + seedPreviewFeedbackQueueTask(t, ctx, taskRepo) + attachment := &models.TaskMessageAttachment{ + ID: "attachment-handler-replay", OwnerID: "owner-handler-replay", + WorkspaceID: "workspace-handler-replay", Name: "checkout.png", MimeType: "image/png", + Kind: "image", DeliveryMode: "prompt", SizeBytes: 128, StorageKey: "handler-replay", + State: models.AttachmentStateStaged, ExpiresAt: time.Now().UTC().Add(time.Hour), + } + require.NoError(t, taskRepo.CreateMessageAttachment(ctx, attachment)) + preview := &models.TaskPreviewFeedback{ + ID: "preview-handler-screenshot-replay", + TaskID: "task-handler-replay", + Kind: models.TaskPreviewFeedbackScreenshot, + Comment: "Move the checkout button", + SourceKind: models.TaskPreviewFeedbackBrowser, + SourceLabel: "Local app", + PageRoute: "/checkout", + CaptureRect: json.RawMessage(`{"x":4,"y":8,"width":320,"height":180}`), + ScreenshotAttachmentID: attachment.ID, + } + _, err := taskRepo.CreateTaskPreviewFeedback(ctx, preview, attachment.OwnerID, attachment.WorkspaceID) + require.NoError(t, err) + + service := messagequeue.NewService(queueRepo, 10, logger.Default()) + service.SetAutoMergeEnabled(false) + preparer := &replayPreviewFeedbackPreparer{ + preview: []v1.MessageAttachment{{ + Type: "image", AttachmentID: attachment.ID, MimeType: attachment.MimeType, + Name: attachment.Name, SizeBytes: attachment.SizeBytes, DeliveryMode: attachment.DeliveryMode, + }}, + } + handlers := NewQueueHandlers(service, &mockEventBus{}, logger.Default(), nil, allowQueueIdentityAccess{}, nil) + handlers.SetAttachmentClaimer(preparer) + + identity, err := service.ResolveSessionIdentity(ctx, preview.TaskID, "session-handler-replay") + require.NoError(t, err) + payload := map[string]interface{}{ + "task_id": preview.TaskID, + "session_id": identity.SessionID, + "session_incarnation_id": identity.SessionIncarnationID, + "client_queue_id": "queue-handler-screenshot-replay", + "content": "Please review this image", + "preview_feedback_refs": []models.TaskPreviewFeedbackRef{{ID: preview.ID, Version: 1}}, + } + + first, err := handlers.wsQueueMessage(ctx, createTestMessage(t, ws.ActionMessageQueueAdd, payload)) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, first.Type) + require.Equal(t, 1, preparer.calls) + var firstQueued messagequeue.QueuedMessage + require.NoError(t, json.Unmarshal(first.Payload, &firstQueued)) + require.Len(t, firstQueued.Attachments, 1) + require.Equal(t, attachment.ID, firstQueued.Attachments[0].AttachmentID) + + reserved, autoRun, err := queueRepo.ReserveHeadIfAutoRunForSession(ctx, identity) + require.NoError(t, err) + require.True(t, autoRun) + require.NotNil(t, reserved) + require.NoError(t, queueRepo.AcknowledgeByIDForSession(ctx, identity, reserved)) + + preparer.err = errors.New("pending screenshot feedback was consumed") + second, err := handlers.wsQueueMessage(ctx, createTestMessage(t, ws.ActionMessageQueueAdd, payload)) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, second.Type) + require.Equal(t, 1, preparer.calls, "an exact replay must not read consumed screenshot feedback") + var replayed messagequeue.QueuedMessage + require.NoError(t, json.Unmarshal(second.Payload, &replayed)) + require.Len(t, replayed.Attachments, 1) + require.Equal(t, attachment.ID, replayed.Attachments[0].AttachmentID) + entries, err := queueRepo.ListBySession(ctx, identity.SessionID) + require.NoError(t, err) + require.Empty(t, entries) +} + +func newPreviewFeedbackQueueStores(t *testing.T) (*tasksqlite.Repository, messagequeue.Repository) { + t.Helper() + dbConn, err := dbutil.OpenSQLite(filepath.Join(t.TempDir(), "preview-feedback-queue.db")) + require.NoError(t, err) + db := sqlx.NewDb(dbConn, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + taskRepo, err := tasksqlite.NewWithDB(db, db, nil) + require.NoError(t, err) + queueRepo, err := messagequeue.NewSQLiteRepository(db, db) + require.NoError(t, err) + return taskRepo, queueRepo +} + +func seedPreviewFeedbackQueueTask(t *testing.T, ctx context.Context, repo *tasksqlite.Repository) { + t.Helper() + workspaceID := "workspace-handler-replay" + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: workspaceID, Name: "Replay"})) + require.NoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "task-handler-replay", WorkspaceID: workspaceID, Title: "Replay", + })) + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-handler-replay", TaskID: "task-handler-replay", + State: models.TaskSessionStateWaitingForInput, + })) + require.NoError(t, repo.SetSessionPrimary(ctx, "session-handler-replay")) +} diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_preview_feedback.go b/apps/backend/internal/orchestrator/messagequeue/repository_preview_feedback.go new file mode 100644 index 0000000000..0199f975fe --- /dev/null +++ b/apps/backend/internal/orchestrator/messagequeue/repository_preview_feedback.go @@ -0,0 +1,235 @@ +package messagequeue + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/plancomments" + "github.com/kandev/kandev/internal/task/previewfeedback" + "github.com/kandev/kandev/internal/task/repository/plancommenttx" + "github.com/kandev/kandev/internal/task/repository/previewfeedbacktx" +) + +// InsertWithTaskFeedback admits one caller-owned queue row and consumes exact +// plan-comment and preview-feedback versions in the same transaction. +func (r *sqliteRepository) InsertWithTaskFeedback( + ctx context.Context, + identity QueueSessionIdentity, + msg *QueuedMessage, + planRefs []models.TaskPlanCommentRef, + previewRefs []models.TaskPreviewFeedbackRef, + replayFingerprint string, + requirePrimary bool, + claim *QueueAttachmentClaim, + maxPerSession int, +) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, bool, error) { + if err := validateTaskFeedbackQueueRequest(identity, msg, previewRefs, replayFingerprint); err != nil { + return nil, nil, false, err + } + release, err := plancommenttx.AcquireLocalAdmission(ctx, msg.TaskID) + if err != nil { + return nil, nil, false, err + } + defer release() + candidate := *msg + tx, err := r.beginPlanCommentQueueAdmission(ctx, identity, &candidate) + if err != nil { + return nil, nil, false, err + } + defer func() { _ = tx.Rollback() }() + existing, replayed, err := r.commitTaskFeedbackQueueReplay( + ctx, tx, identity, &candidate, planRefs, previewRefs, replayFingerprint, + ) + if err != nil { + return nil, nil, false, err + } + if replayed { + *msg = *existing + return nil, nil, true, nil + } + if err := r.ensureQueueCapacity(ctx, tx, candidate.SessionID, maxPerSession); err != nil { + return nil, nil, false, err + } + if err := claimOptionalMessageAttachmentsTx( + ctx, tx, &identity, claim, candidate.TaskID, candidate.SessionID, + ); err != nil { + return nil, nil, false, err + } + planResolution, previewResolution, err := r.resolveQueuedTaskFeedback( + ctx, tx, &candidate, planRefs, previewRefs, requirePrimary, + ) + if err != nil { + return nil, nil, false, err + } + if err := r.transferPreviewScreenshotsToQueue( + ctx, tx, previewResolution, candidate.SessionID, + ); err != nil { + return nil, nil, false, err + } + candidate.Content = previewResolution.Content + if err := r.insertCoalesced(ctx, tx, &candidate, 0); err != nil { + return nil, nil, false, err + } + planSnapshot, previewSnapshot, err := consumeQueuedTaskFeedback( + ctx, tx, r.db, planResolution, previewResolution, + ) + if err != nil { + return nil, nil, false, err + } + if err := r.insertQueueAdmissionReceiptTx( + ctx, tx, identity, candidate.ID, replayFingerprint, &candidate, + ); err != nil { + return nil, nil, false, err + } + if err := tx.Commit(); err != nil { + return nil, nil, false, fmt.Errorf("commit task-feedback queue admission: %w", err) + } + *msg = candidate + return planSnapshot, previewSnapshot, false, nil +} + +func validateTaskFeedbackQueueRequest( + identity QueueSessionIdentity, + msg *QueuedMessage, + previewRefs []models.TaskPreviewFeedbackRef, + replayFingerprint string, +) error { + if msg == nil || msg.ID == "" || len(previewRefs) == 0 || replayFingerprint == "" { + return errors.New("client queue id and preview feedback are required") + } + if msg.TaskID != identity.TaskID || msg.SessionID != identity.SessionID { + return ErrSessionIdentityMismatch + } + return nil +} + +func (r *sqliteRepository) commitTaskFeedbackQueueReplay( + ctx context.Context, + tx *sqlx.Tx, + identity QueueSessionIdentity, + candidate *QueuedMessage, + planRefs []models.TaskPlanCommentRef, + previewRefs []models.TaskPreviewFeedbackRef, + replayFingerprint string, +) (*QueuedMessage, bool, error) { + receipt, err := r.readQueueAdmissionReceiptTx(ctx, tx, identity, candidate.ID) + if err != nil { + return nil, false, err + } + if receipt != nil { + if receipt.Fingerprint != replayFingerprint || + !sameTaskFeedbackQueueRequest(receipt.Message, candidate, planRefs, previewRefs) { + return nil, false, ErrQueueIDConflict + } + if err := tx.Commit(); err != nil { + return nil, false, fmt.Errorf("commit task-feedback queue receipt replay: %w", err) + } + return receipt.Message, true, nil + } + existing, err := r.findQueuedMessageByIDTx(ctx, tx, candidate.ID) + if err != nil || existing == nil { + return existing, false, err + } + if !sameTaskFeedbackQueueRequest(existing, candidate, planRefs, previewRefs) { + return nil, false, ErrQueueIDConflict + } + if err := tx.Commit(); err != nil { + return nil, false, fmt.Errorf("commit task-feedback queue replay: %w", err) + } + return existing, true, nil +} + +func (r *sqliteRepository) resolveQueuedTaskFeedback( + ctx context.Context, + tx *sqlx.Tx, + candidate *QueuedMessage, + planRefs []models.TaskPlanCommentRef, + previewRefs []models.TaskPreviewFeedbackRef, + requirePrimary bool, +) (*plancommenttx.Resolution, *previewfeedbacktx.Resolution, error) { + content := candidate.Content + var planResolution *plancommenttx.Resolution + var err error + if len(planRefs) > 0 { + planResolution, err = plancommenttx.ResolveQueue( + ctx, tx, r.db, candidate.TaskID, candidate.SessionID, content, planRefs, requirePrimary, + ) + if err != nil { + return nil, nil, err + } + content = planResolution.Content + } else if err := plancommenttx.ValidateTargetSession( + ctx, tx, r.db, candidate.TaskID, candidate.SessionID, requirePrimary, "", + ); err != nil { + return nil, nil, err + } + previewResolution, err := previewfeedbacktx.Resolve( + ctx, tx, r.db, candidate.TaskID, content, previewRefs, + ) + return planResolution, previewResolution, err +} + +func consumeQueuedTaskFeedback( + ctx context.Context, + tx *sqlx.Tx, + db *sqlx.DB, + planResolution *plancommenttx.Resolution, + previewResolution *previewfeedbacktx.Resolution, +) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, error) { + var planSnapshot *models.TaskPlanCommentSnapshot + var err error + if planResolution != nil { + planSnapshot, err = plancommenttx.Consume(ctx, tx, db, planResolution) + if err != nil { + return nil, nil, err + } + } + previewSnapshot, err := previewfeedbacktx.Consume(ctx, tx, db, previewResolution) + return planSnapshot, previewSnapshot, err +} + +func (r *sqliteRepository) transferPreviewScreenshotsToQueue( + ctx context.Context, + tx interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + }, + resolved *previewfeedbacktx.Resolution, + sessionID string, +) error { + for _, item := range resolved.Items { + if item.Kind != models.TaskPreviewFeedbackScreenshot { + continue + } + result, err := tx.ExecContext(ctx, r.db.Rebind(` + UPDATE task_message_attachments + SET session_id = ?, updated_at = ? + WHERE id = ? AND task_id = ? AND state = ? + AND session_id = '' AND message_id = '' AND queue_id = '' + `), sessionID, time.Now().UTC(), item.ScreenshotAttachmentID, + resolved.TaskID, models.AttachmentStateClaimed) + if err != nil { + return fmt.Errorf("transfer preview screenshot to queue: %w", err) + } + if changed, _ := result.RowsAffected(); changed != 1 { + return models.ErrAttachmentClaimConflict + } + } + return nil +} + +func sameTaskFeedbackQueueRequest( + existing, candidate *QueuedMessage, + planRefs []models.TaskPlanCommentRef, + previewRefs []models.TaskPreviewFeedbackRef, +) bool { + want, _ := candidate.Metadata[plancomments.MetadataRequestFingerprint].(string) + got, _ := existing.Metadata[plancomments.MetadataRequestFingerprint].(string) + return want != "" && got == want && + plancomments.MetadataRefsMatch(existing.Metadata, planRefs) && + previewfeedback.MetadataRefsMatch(existing.Metadata, previewRefs) +} diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_preview_feedback_test.go b/apps/backend/internal/orchestrator/messagequeue/repository_preview_feedback_test.go new file mode 100644 index 0000000000..22abc43317 --- /dev/null +++ b/apps/backend/internal/orchestrator/messagequeue/repository_preview_feedback_test.go @@ -0,0 +1,143 @@ +package messagequeue_test + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/task/models" + tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +type taskFeedbackQueueWriter interface { + InsertWithTaskFeedback( + context.Context, + messagequeue.QueueSessionIdentity, + *messagequeue.QueuedMessage, + []models.TaskPlanCommentRef, + []models.TaskPreviewFeedbackRef, + string, + bool, + *messagequeue.QueueAttachmentClaim, + int, + ) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, bool, error) +} + +func TestSQLiteRepositoryInsertWithTaskFeedbackQueuesAndConsumesMixedContext(t *testing.T) { + taskRepo, queueRepo := newPlanCommentQueueRepos(t) + ctx := context.Background() + seedQueuePlanComment(t, ctx, taskRepo, "preview") + preview := seedQueuePreviewFeedback(t, ctx, taskRepo, "preview") + queued := planCommentQueuedMessage( + "preview", "queue-preview", "fingerprint-preview", + []models.TaskPlanCommentRef{{ID: "comment-preview", Version: 1}}, + ) + queued.Metadata["preview_feedback_refs"] = []models.TaskPreviewFeedbackRef{{ID: preview.ID, Version: 1}} + identity := resolvePlanCommentQueueIdentity(t, ctx, queueRepo, queued) + writer, ok := queueRepo.(taskFeedbackQueueWriter) + if !ok { + t.Fatal("queue repository does not implement task feedback admission") + } + + planSnapshot, previewSnapshot, replay, err := writer.InsertWithTaskFeedback( + ctx, identity, queued, + []models.TaskPlanCommentRef{{ID: "comment-preview", Version: 1}}, + []models.TaskPreviewFeedbackRef{{ID: preview.ID, Version: 1}}, + "fingerprint-preview", + true, nil, 10, + ) + if err != nil || replay { + t.Fatalf("InsertWithTaskFeedback replay=%v err=%v", replay, err) + } + if planSnapshot == nil || len(planSnapshot.Comments) != 0 || + previewSnapshot == nil || len(previewSnapshot.Items) != 0 { + t.Fatalf("snapshots plan=%#v preview=%#v", planSnapshot, previewSnapshot) + } + for _, want := range []string{ + "### Plan Comments", "typed content", "### Web Preview Feedback", + "Runtime queue copy", "node_path", + } { + if !strings.Contains(queued.Content, want) { + t.Fatalf("queued content missing %q:\n%s", want, queued.Content) + } + } + entries, err := queueRepo.ListBySession(ctx, queued.SessionID) + if err != nil || len(entries) != 1 || entries[0].Content != queued.Content { + t.Fatalf("stored queue=%#v err=%v", entries, err) + } +} + +func TestSQLiteRepositoryTaskFeedbackReplaySurvivesQueueDrain(t *testing.T) { + taskRepo, queueRepo := newPlanCommentQueueRepos(t) + ctx := context.Background() + seedQueuePlanComment(t, ctx, taskRepo, "preview-drained") + preview := seedQueuePreviewFeedback(t, ctx, taskRepo, "preview-drained") + refs := []models.TaskPreviewFeedbackRef{{ID: preview.ID, Version: 1}} + queued := planCommentQueuedMessage("preview-drained", "queue-preview-drained", "fingerprint-preview-drained", nil) + queued.Metadata["preview_feedback_refs"] = refs + identity := resolvePlanCommentQueueIdentity(t, ctx, queueRepo, queued) + writer, ok := queueRepo.(taskFeedbackQueueWriter) + if !ok { + t.Fatal("queue repository does not implement task feedback admission") + } + + _, _, replay, err := writer.InsertWithTaskFeedback( + ctx, identity, queued, nil, refs, "fingerprint-preview-drained", false, nil, 10, + ) + if err != nil || replay { + t.Fatalf("first insert replay=%v err=%v", replay, err) + } + reserved, autoRun, err := queueRepo.ReserveHeadIfAutoRunForSession(ctx, identity) + if err != nil || !autoRun || reserved == nil { + t.Fatalf("reserve accepted feedback = %#v auto_run=%v err=%v", reserved, autoRun, err) + } + if err := queueRepo.AcknowledgeByIDForSession(ctx, identity, reserved); err != nil { + t.Fatalf("acknowledge accepted feedback: %v", err) + } + + retry := planCommentQueuedMessage("preview-drained", queued.ID, "fingerprint-preview-drained", nil) + retry.Metadata["preview_feedback_refs"] = refs + planSnapshot, previewSnapshot, replay, err := writer.InsertWithTaskFeedback( + ctx, identity, retry, nil, refs, "fingerprint-preview-drained", false, nil, 10, + ) + if err != nil || !replay || planSnapshot != nil || previewSnapshot != nil { + t.Fatalf("drained replay plan=%#v preview=%#v replay=%v err=%v", planSnapshot, previewSnapshot, replay, err) + } + if retry.Content != queued.Content { + t.Fatalf("replayed content = %q, want %q", retry.Content, queued.Content) + } + conflict := planCommentQueuedMessage("preview-drained", queued.ID, "different-fingerprint", nil) + conflict.Metadata["preview_feedback_refs"] = refs + if _, _, _, err := writer.InsertWithTaskFeedback( + ctx, identity, conflict, nil, refs, "different-fingerprint", false, nil, 10, + ); !errors.Is(err, messagequeue.ErrQueueIDConflict) { + t.Fatalf("conflicting drained replay error = %v, want ErrQueueIDConflict", err) + } + entries, err := queueRepo.ListBySession(ctx, queued.SessionID) + if err != nil || len(entries) != 0 { + t.Fatalf("queue after replay = %#v, err=%v", entries, err) + } +} + +func seedQueuePreviewFeedback( + t *testing.T, + ctx context.Context, + repo *tasksqlite.Repository, + suffix string, +) *models.TaskPreviewFeedback { + t.Helper() + item := &models.TaskPreviewFeedback{ + ID: "preview-queue-" + suffix, TaskID: "task-" + suffix, + Kind: models.TaskPreviewFeedbackText, Comment: "Runtime queue copy", + SourceKind: models.TaskPreviewFeedbackBrowser, SourceLabel: "Local app", + PageRoute: "/queue", SelectedText: "Generated", + TextAnchor: json.RawMessage(`{"start":{"node_path":[0],"offset":0},"end":{"node_path":[0],"offset":9},"containing_element":{"tag":"span","outer_html":"Generated"}}`), + } + if _, err := repo.CreateTaskPreviewFeedback(ctx, item, "", ""); err != nil { + t.Fatal(err) + } + return item +} diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go index 0b6adc6331..73bafe7099 100644 --- a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go +++ b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go @@ -871,7 +871,13 @@ func claimMessageAttachmentTx( ctx, tx, claim, taskID, sessionID, attachmentID, attachment, now, ) case models.AttachmentStateClaimed: - return 0, validateClaimedMessageAttachment(attachment, taskID, sessionID) + if err := validateClaimedMessageAttachment(attachment, taskID, sessionID); err != nil { + return 0, err + } + if attachment.SizeBytes < 0 || attachment.SizeBytes > models.MaxMessageAttachmentBytes { + return 0, models.ErrAttachmentTooLarge + } + return attachment.SizeBytes, nil default: return 0, models.ErrAttachmentClaimConflict } diff --git a/apps/backend/internal/orchestrator/messagequeue/service_plan_comment.go b/apps/backend/internal/orchestrator/messagequeue/service_plan_comment.go index 4bf05b95a6..08b7e9fb09 100644 --- a/apps/backend/internal/orchestrator/messagequeue/service_plan_comment.go +++ b/apps/backend/internal/orchestrator/messagequeue/service_plan_comment.go @@ -6,32 +6,51 @@ import ( "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/plancomments" + "github.com/kandev/kandev/internal/task/previewfeedback" "github.com/kandev/kandev/internal/task/repository/plancommenttx" ) // PlanCommentQueueRequest describes a caller-identified queued prompt that // consumes exact task-owned plan comments when admitted. type PlanCommentQueueRequest struct { - ClientQueueID string - SessionID string - TaskID string - SessionIncarnationID string - Content string - Model string - UserID string - PlanMode bool - Attachments []MessageAttachment - Metadata map[string]interface{} - PlanCommentRefs []models.TaskPlanCommentRef - RequirePrimarySession bool - AttachmentClaim *QueueAttachmentClaim + ClientQueueID string + SessionID string + TaskID string + SessionIncarnationID string + Content string + Model string + UserID string + PlanMode bool + Attachments []MessageAttachment + Metadata map[string]interface{} + PlanCommentRefs []models.TaskPlanCommentRef + PreviewFeedbackRefs []models.TaskPreviewFeedbackRef + RequirePrimarySession bool + AttachmentClaim *QueueAttachmentClaim + PreviewFeedbackAttachmentResolver func(context.Context, string, []models.TaskPreviewFeedbackRef) ([]MessageAttachment, error) + AttachmentClaimResolver func(context.Context, string, []MessageAttachment) (QueueAttachmentClaim, error) } // PlanCommentQueueResult distinguishes a first admission from an exact replay. type PlanCommentQueueResult struct { - Message *QueuedMessage - Snapshot *models.TaskPlanCommentSnapshot - Replay bool + Message *QueuedMessage + Snapshot *models.TaskPlanCommentSnapshot + PreviewSnapshot *models.TaskPreviewFeedbackSnapshot + Replay bool +} + +type taskFeedbackQueueWriter interface { + InsertWithTaskFeedback( + context.Context, + QueueSessionIdentity, + *QueuedMessage, + []models.TaskPlanCommentRef, + []models.TaskPreviewFeedbackRef, + string, + bool, + *QueueAttachmentClaim, + int, + ) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, bool, error) } type planCommentQueueWriter interface { @@ -47,18 +66,19 @@ type planCommentQueueWriter interface { } type planCommentQueueReplayIdentity struct { - ClientQueueID string `json:"client_queue_id"` - SessionID string `json:"session_id"` - TaskID string `json:"task_id"` - SessionIncarnationID string `json:"session_incarnation_id"` - Content string `json:"content"` - Model string `json:"model"` - UserID string `json:"user_id"` - PlanMode bool `json:"plan_mode"` - Attachments []MessageAttachment `json:"attachments"` - Metadata map[string]interface{} `json:"metadata"` - Refs []models.TaskPlanCommentRef `json:"refs"` - RequirePrimarySession bool `json:"require_primary_session"` + ClientQueueID string `json:"client_queue_id"` + SessionID string `json:"session_id"` + TaskID string `json:"task_id"` + SessionIncarnationID string `json:"session_incarnation_id"` + Content string `json:"content"` + Model string `json:"model"` + UserID string `json:"user_id"` + PlanMode bool `json:"plan_mode"` + Attachments []MessageAttachment `json:"attachments"` + Metadata map[string]interface{} `json:"metadata"` + Refs []models.TaskPlanCommentRef `json:"refs"` + PreviewRefs []models.TaskPreviewFeedbackRef `json:"preview_refs"` + RequirePrimarySession bool `json:"require_primary_session"` } // QueueMessageWithPlanComments admits a comment-bearing row without automatic @@ -71,8 +91,8 @@ func (s *Service) QueueMessageWithPlanComments( if req.ClientQueueID == "" { return nil, errors.New("client queue id is required") } - if len(req.PlanCommentRefs) == 0 { - return nil, errors.New("plan comment refs are required") + if len(req.PlanCommentRefs) == 0 && len(req.PreviewFeedbackRefs) == 0 { + return nil, errors.New("task feedback refs are required") } identity := QueueSessionIdentity{ TaskID: req.TaskID, SessionID: req.SessionID, SessionIncarnationID: req.SessionIncarnationID, @@ -80,17 +100,17 @@ func (s *Service) QueueMessageWithPlanComments( if identity.TaskID == "" || identity.SessionID == "" || identity.SessionIncarnationID == "" { return nil, ErrSessionIdentityMismatch } - writer, ok := s.repo.(planCommentQueueWriter) - if !ok { - return nil, errors.New("plan comment queue admission is unavailable") - } metadata, err := preparePlanCommentQueueMetadata(req) if err != nil { return nil, err } + content := req.Content + if len(req.PlanCommentRefs) > 0 { + content = plancomments.WithPlaceholder(content) + } message := &QueuedMessage{ ID: req.ClientQueueID, SessionID: req.SessionID, TaskID: req.TaskID, - Content: plancomments.WithPlaceholder(req.Content), Model: req.Model, + Content: content, Model: req.Model, PlanMode: req.PlanMode, Attachments: req.Attachments, Metadata: metadata, QueuedBy: req.UserID, } @@ -102,25 +122,121 @@ func (s *Service) QueueMessageWithPlanComments( ctx = plancommenttx.WithLocalAdmission(ctx, req.TaskID) var result *PlanCommentQueueResult err = s.WithSessionAdmission(ctx, req.SessionID, func(admittedCtx context.Context) error { - snapshot, replay, insertErr := writer.InsertWithPlanComments( - admittedCtx, identity, message, req.PlanCommentRefs, req.RequirePrimarySession, - req.AttachmentClaim, s.MaxPerSession(), - ) - if insertErr != nil { - return insertErr - } - result = &PlanCommentQueueResult{Message: message, Snapshot: snapshot, Replay: replay} - return nil + var err error + result, err = s.admitTaskFeedbackQueue(admittedCtx, identity, req, message) + return err }) return result, err } +func (s *Service) admitTaskFeedbackQueue( + ctx context.Context, + identity QueueSessionIdentity, + req PlanCommentQueueRequest, + message *QueuedMessage, +) (*PlanCommentQueueResult, error) { + if len(req.PreviewFeedbackRefs) == 0 { + writer, ok := s.repo.(planCommentQueueWriter) + if !ok { + return nil, errors.New("plan comment queue admission is unavailable") + } + snapshot, replay, err := writer.InsertWithPlanComments( + ctx, identity, message, req.PlanCommentRefs, req.RequirePrimarySession, + req.AttachmentClaim, s.MaxPerSession(), + ) + if err != nil { + return nil, err + } + return &PlanCommentQueueResult{Message: message, Snapshot: snapshot, Replay: replay}, nil + } + writer, ok := s.repo.(taskFeedbackQueueWriter) + if !ok { + return nil, errors.New("task feedback queue admission is unavailable") + } + reader, ok := s.repo.(queueAdmissionRepository) + if !ok { + return nil, ErrQueueAdmissionUnavailable + } + replayFingerprint, existing, replay, err := lookupTaskFeedbackQueueReplay( + ctx, reader, identity, req.ClientQueueID, message, + ) + if err != nil { + return nil, err + } + if replay { + return &PlanCommentQueueResult{Message: existing, Replay: true}, nil + } + claim, err := s.resolveTaskFeedbackQueueInputs(ctx, req, message) + if err != nil { + return nil, err + } + snapshot, previewSnapshot, replay, err := writer.InsertWithTaskFeedback( + ctx, identity, message, req.PlanCommentRefs, req.PreviewFeedbackRefs, + replayFingerprint, req.RequirePrimarySession, claim, s.MaxPerSession(), + ) + if err != nil { + return nil, err + } + return &PlanCommentQueueResult{ + Message: message, Snapshot: snapshot, PreviewSnapshot: previewSnapshot, Replay: replay, + }, nil +} + +func lookupTaskFeedbackQueueReplay( + ctx context.Context, + reader queueAdmissionRepository, + identity QueueSessionIdentity, + clientQueueID string, + message *QueuedMessage, +) (string, *QueuedMessage, bool, error) { + fingerprint, err := queueAdmissionFingerprint(identity, message) + if err != nil { + return "", nil, false, err + } + existing, replay, err := reader.LookupQueueAdmission(ctx, identity, clientQueueID, message) + if err != nil { + return "", nil, false, err + } + if replay { + return "", existing, true, nil + } + return fingerprint, nil, false, nil +} + +func (s *Service) resolveTaskFeedbackQueueInputs( + ctx context.Context, + req PlanCommentQueueRequest, + message *QueuedMessage, +) (*QueueAttachmentClaim, error) { + if req.PreviewFeedbackAttachmentResolver == nil { + return nil, errors.New("preview feedback attachments are unavailable") + } + attachments, err := req.PreviewFeedbackAttachmentResolver(ctx, req.TaskID, req.PreviewFeedbackRefs) + if err != nil { + return nil, err + } + message.Attachments = append(message.Attachments, attachments...) + claim := req.AttachmentClaim + if claim == nil && len(req.Attachments) > 0 { + if req.AttachmentClaimResolver == nil { + return nil, errors.New("transactional attachment admission is unavailable") + } + prepared, err := req.AttachmentClaimResolver(ctx, req.TaskID, req.Attachments) + if err != nil { + return nil, err + } + claim = &prepared + } + return claim, nil +} + func preparePlanCommentQueueMetadata(req PlanCommentQueueRequest) (map[string]interface{}, error) { - metadata := make(map[string]interface{}, len(req.Metadata)+3) + metadata := make(map[string]interface{}, len(req.Metadata)+4) for key, value := range req.Metadata { if key != plancomments.MetadataRefs && key != plancomments.MetadataRequestFingerprint && - key != plancomments.MetadataClientQueueID { + key != plancomments.MetadataClientQueueID && + key != previewfeedback.MetadataRefs { metadata[key] = value } } @@ -129,6 +245,7 @@ func preparePlanCommentQueueMetadata(req PlanCommentQueueRequest) (map[string]in SessionIncarnationID: req.SessionIncarnationID, Content: req.Content, Model: req.Model, UserID: req.UserID, PlanMode: req.PlanMode, Attachments: req.Attachments, Metadata: metadata, Refs: req.PlanCommentRefs, + PreviewRefs: req.PreviewFeedbackRefs, RequirePrimarySession: req.RequirePrimarySession, } fingerprint, err := plancomments.Fingerprint(identity) @@ -138,5 +255,6 @@ func preparePlanCommentQueueMetadata(req PlanCommentQueueRequest) (map[string]in metadata[plancomments.MetadataRefs] = req.PlanCommentRefs metadata[plancomments.MetadataRequestFingerprint] = fingerprint metadata[plancomments.MetadataClientQueueID] = req.ClientQueueID + metadata[previewfeedback.MetadataRefs] = req.PreviewFeedbackRefs return metadata, nil } diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index c46b02bdd5..7263b10efc 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -1,6 +1,7 @@ package dto import ( + "encoding/json" "time" "github.com/kandev/kandev/internal/authz" @@ -1358,6 +1359,77 @@ func TaskPlanCommentSnapshotFromModel(snapshot *models.TaskPlanCommentSnapshot) return out } +type TaskPreviewScreenshotDTO struct { + AttachmentID string `json:"attachment_id"` + Name string `json:"name"` + MimeType string `json:"mime_type"` + Kind string `json:"kind"` + DeliveryMode string `json:"delivery_mode"` + SizeBytes int64 `json:"size_bytes"` + State string `json:"state"` +} + +type TaskPreviewFeedbackDTO struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Kind models.TaskPreviewFeedbackKind `json:"kind"` + Comment string `json:"comment"` + SourceKind models.TaskPreviewFeedbackSourceKind `json:"source_kind"` + SourceSessionID string `json:"source_session_id,omitempty"` + SourceLabel string `json:"source_label"` + SourcePath string `json:"source_path,omitempty"` + PageRoute string `json:"page_route"` + PageTitle string `json:"page_title"` + SelectedText string `json:"selected_text,omitempty"` + TextAnchor json.RawMessage `json:"text_anchor,omitempty"` + ElementSnapshot json.RawMessage `json:"element_snapshot,omitempty"` + CaptureRect json.RawMessage `json:"capture_rect,omitempty"` + ScreenshotAttachmentID string `json:"screenshot_attachment_id,omitempty"` + ScreenshotAttachment *TaskPreviewScreenshotDTO `json:"screenshot_attachment,omitempty"` + Version int64 `json:"version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type TaskPreviewFeedbackSnapshotDTO struct { + TaskID string `json:"task_id"` + Revision int64 `json:"revision"` + Items []*TaskPreviewFeedbackDTO `json:"items"` +} + +func TaskPreviewFeedbackSnapshotFromModel( + snapshot *models.TaskPreviewFeedbackSnapshot, +) *TaskPreviewFeedbackSnapshotDTO { + if snapshot == nil { + return nil + } + out := &TaskPreviewFeedbackSnapshotDTO{ + TaskID: snapshot.TaskID, Revision: snapshot.Revision, + Items: make([]*TaskPreviewFeedbackDTO, 0, len(snapshot.Items)), + } + for _, item := range snapshot.Items { + dtoItem := &TaskPreviewFeedbackDTO{ + ID: item.ID, TaskID: item.TaskID, Kind: item.Kind, Comment: item.Comment, + SourceKind: item.SourceKind, SourceSessionID: item.SourceSessionID, + SourceLabel: item.SourceLabel, SourcePath: item.SourcePath, + PageRoute: item.PageRoute, PageTitle: item.PageTitle, + SelectedText: item.SelectedText, TextAnchor: item.TextAnchor, + ElementSnapshot: item.ElementSnapshot, CaptureRect: item.CaptureRect, + ScreenshotAttachmentID: item.ScreenshotAttachmentID, + Version: item.Version, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, + } + if attachment := item.ScreenshotAttachment; attachment != nil { + dtoItem.ScreenshotAttachment = &TaskPreviewScreenshotDTO{ + AttachmentID: attachment.ID, Name: attachment.Name, MimeType: attachment.MimeType, + Kind: attachment.Kind, DeliveryMode: attachment.DeliveryMode, + SizeBytes: attachment.SizeBytes, State: attachment.State, + } + } + out.Items = append(out.Items, dtoItem) + } + return out +} + // TaskPlanRevisionDTO represents a plan revision for API responses. // Content is optional so list responses can omit it (fetched on demand). type TaskPlanRevisionDTO struct { diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index 92a56a75c1..159e809fcf 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -25,7 +25,9 @@ import ( "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/plancomments" + "github.com/kandev/kandev/internal/task/previewfeedback" "github.com/kandev/kandev/internal/task/repository/plancommenttx" + "github.com/kandev/kandev/internal/task/repository/previewfeedbacktx" "github.com/kandev/kandev/internal/task/repository/repoerrors" taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" "github.com/kandev/kandev/internal/task/service" @@ -492,20 +494,21 @@ func (h *MessageHandlers) httpListMessages(c *gin.Context) { // WS handlers type wsAddMessageRequest struct { - TaskID string `json:"task_id"` - TaskSessionID string `json:"session_id"` - MessageID string `json:"message_id,omitempty"` - ClientMessageID string `json:"client_message_id,omitempty"` - Content string `json:"content"` - AuthorID string `json:"author_id,omitempty"` - Model string `json:"model,omitempty"` - PlanMode bool `json:"plan_mode,omitempty"` - HasReviewComments bool `json:"has_review_comments,omitempty"` - Attachments []v1.MessageAttachment `json:"attachments,omitempty"` - ContextFiles []v1.ContextFileMeta `json:"context_files,omitempty"` - EntityReferences []v1.EntityReference `json:"entity_references,omitempty"` - PlanCommentRefs []models.TaskPlanCommentRef `json:"plan_comment_refs,omitempty"` - RequirePrimarySession bool `json:"require_primary_session,omitempty"` + TaskID string `json:"task_id"` + TaskSessionID string `json:"session_id"` + MessageID string `json:"message_id,omitempty"` + ClientMessageID string `json:"client_message_id,omitempty"` + Content string `json:"content"` + AuthorID string `json:"author_id,omitempty"` + Model string `json:"model,omitempty"` + PlanMode bool `json:"plan_mode,omitempty"` + HasReviewComments bool `json:"has_review_comments,omitempty"` + Attachments []v1.MessageAttachment `json:"attachments,omitempty"` + ContextFiles []v1.ContextFileMeta `json:"context_files,omitempty"` + EntityReferences []v1.EntityReference `json:"entity_references,omitempty"` + PlanCommentRefs []models.TaskPlanCommentRef `json:"plan_comment_refs,omitempty"` + PreviewFeedbackRefs []models.TaskPreviewFeedbackRef `json:"preview_feedback_refs,omitempty"` + RequirePrimarySession bool `json:"require_primary_session,omitempty"` // These fields are server-owned and are carried only from message admission // to the created-session dispatch. They are intentionally not JSON fields. canvasGuidanceResolved bool @@ -515,18 +518,19 @@ type wsAddMessageRequest struct { } type addMessageReplayIdentity struct { - TaskID string `json:"task_id"` - TaskSessionID string `json:"session_id"` - Content string `json:"content"` - AuthorID string `json:"author_id"` - Model string `json:"model"` - PlanMode bool `json:"plan_mode"` - HasReviewComments bool `json:"has_review_comments"` - Attachments []v1.MessageAttachment `json:"attachments"` - ContextFiles []v1.ContextFileMeta `json:"context_files"` - EntityReferences []v1.EntityReference `json:"entity_references"` - PlanCommentRefs []models.TaskPlanCommentRef `json:"plan_comment_refs"` - RequirePrimarySession bool `json:"require_primary_session"` + TaskID string `json:"task_id"` + TaskSessionID string `json:"session_id"` + Content string `json:"content"` + AuthorID string `json:"author_id"` + Model string `json:"model"` + PlanMode bool `json:"plan_mode"` + HasReviewComments bool `json:"has_review_comments"` + Attachments []v1.MessageAttachment `json:"attachments"` + ContextFiles []v1.ContextFileMeta `json:"context_files"` + EntityReferences []v1.EntityReference `json:"entity_references"` + PlanCommentRefs []models.TaskPlanCommentRef `json:"plan_comment_refs"` + PreviewFeedbackRefs []models.TaskPreviewFeedbackRef `json:"preview_feedback_refs"` + RequirePrimarySession bool `json:"require_primary_session"` } func addMessageRequestFingerprint(req wsAddMessageRequest) (string, error) { @@ -535,7 +539,8 @@ func addMessageRequestFingerprint(req wsAddMessageRequest) (string, error) { AuthorID: req.AuthorID, Model: req.Model, PlanMode: req.PlanMode, HasReviewComments: req.HasReviewComments, Attachments: req.Attachments, ContextFiles: req.ContextFiles, EntityReferences: req.EntityReferences, - PlanCommentRefs: req.PlanCommentRefs, RequirePrimarySession: req.RequirePrimarySession, + PlanCommentRefs: req.PlanCommentRefs, PreviewFeedbackRefs: req.PreviewFeedbackRefs, + RequirePrimarySession: req.RequirePrimarySession, }) } @@ -569,7 +574,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w return response, nil } admissionCtx := ctx - if len(req.PlanCommentRefs) > 0 { + if len(req.PlanCommentRefs) > 0 || len(req.PreviewFeedbackRefs) > 0 { var releaseAdmission func() admissionCtx, releaseAdmission, err = h.service.AcquirePlanCommentAndMessageAdmission( ctx, req.TaskID, req.ClientMessageID, @@ -587,7 +592,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w return response, nil } } - if len(req.PlanCommentRefs) == 0 && req.ClientMessageID != "" { + if len(req.PlanCommentRefs) == 0 && len(req.PreviewFeedbackRefs) == 0 && req.ClientMessageID != "" { var releaseMessageAdmission func() admissionCtx, releaseMessageAdmission, err = h.service.AcquireMessageAdmission( admissionCtx, req.ClientMessageID, @@ -640,9 +645,10 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w } req.EntityReferences = references } - if len(req.PlanCommentRefs) > 0 { - if err := h.service.ValidatePlanCommentMessage( - admissionCtx, req.TaskID, req.TaskSessionID, req.Content, req.PlanCommentRefs, + if len(req.PlanCommentRefs) > 0 || len(req.PreviewFeedbackRefs) > 0 { + if err := h.service.ValidateTaskFeedbackMessage( + admissionCtx, req.TaskID, req.TaskSessionID, req.Content, + req.PlanCommentRefs, req.PreviewFeedbackRefs, req.RequirePrimarySession, sessionResp.Session.State, ); err != nil { if response := planCommentMessageError(msg, err); response != nil { @@ -652,8 +658,23 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to validate plan comments", nil) } } + if len(req.PreviewFeedbackRefs) > 0 { + previewAttachments, err := h.service.PreviewFeedbackAttachments( + admissionCtx, req.TaskID, req.PreviewFeedbackRefs, + ) + if err != nil { + if response := planCommentMessageError(msg, err); response != nil { + return response, nil + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Invalid preview feedback attachments", nil) + } + req.Attachments = append(req.Attachments, previewAttachments...) + if err := validateAttachments(req.Attachments); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil) + } + } var planCommentAttachmentClaim *messagequeue.QueueAttachmentClaim - if len(req.PlanCommentRefs) > 0 && len(req.Attachments) > 0 { + if (len(req.PlanCommentRefs) > 0 || len(req.PreviewFeedbackRefs) > 0) && len(req.Attachments) > 0 { claim, err := h.service.PrepareMessageAttachmentClaim(ctx, req.TaskID, req.Attachments) if err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil) @@ -755,7 +776,8 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w storedContent = orchestrator.AppendEntityReferenceContext(storedContent, req.EntityReferences) configMode, _ := sessionResp.Session.Metadata["config_mode"].(bool) titleOwner := false - hasMessageContent := req.Content != "" || len(req.Attachments) > 0 || len(req.PlanCommentRefs) > 0 + hasMessageContent := req.Content != "" || len(req.Attachments) > 0 || + len(req.PlanCommentRefs) > 0 || len(req.PreviewFeedbackRefs) > 0 task, titleOwner, wsErr = h.resolveMessageTaskAndTitleOwner( ctx, msg, task, req.TaskID, req.TaskSessionID, configMode, startCreatedSession, hasMessageContent, ) @@ -817,6 +839,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w AuthorID: req.AuthorID, Metadata: messageMetadata, PlanCommentRefs: req.PlanCommentRefs, + PreviewFeedbackRefs: req.PreviewFeedbackRefs, RequirePrimarySession: req.RequirePrimarySession, ExpectedSessionState: sessionResp.Session.State, AttachmentClaim: planCommentAttachmentClaim, @@ -826,9 +849,9 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w // Every comment-bearing message uses the queue row as a durable dispatch // receipt. Promptable sessions drain it immediately; workflow waits and // process restarts retain the same caller-owned delivery identity. - atomicQueuedPlanComments := len(req.PlanCommentRefs) > 0 + atomicQueuedTaskFeedback := len(req.PlanCommentRefs) > 0 || len(req.PreviewFeedbackRefs) > 0 var queuedCoordinator AtomicQueuedPromptCoordinator - if atomicQueuedPlanComments { + if atomicQueuedTaskFeedback { var ok bool queuedCoordinator, ok = h.orchestrator.(AtomicQueuedPromptCoordinator) if !ok { @@ -836,7 +859,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w } } createMessage := func() (*models.Message, error) { - if atomicQueuedPlanComments { + if atomicQueuedTaskFeedback { queueMetadata := make(map[string]interface{}, len(createRequest.Metadata)+1) for key, value := range createRequest.Metadata { queueMetadata[key] = value @@ -913,7 +936,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w promptReferencesPrepared = initialTaskBrief.PromptReferencesPrepared req.promptReferencesPrepared = promptReferencesPrepared } - if initialTaskBriefQueued && h.orchestrator != nil && !atomicQueuedPlanComments { + if initialTaskBriefQueued && h.orchestrator != nil && !atomicQueuedTaskFeedback { queueMetadata := meta.ToMap() if queueMetadata == nil { queueMetadata = make(map[string]interface{}) @@ -937,7 +960,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to queue prompt", nil) } - } else if turnStartResult.Queued && !atomicQueuedPlanComments { + } else if turnStartResult.Queued && !atomicQueuedTaskFeedback { if err := h.orchestrator.QueueUserPrompt( ctx, req.TaskID, @@ -967,7 +990,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w // an orchestrator is available. This runs async so the WS request can // respond immediately. Plan mode changes the execution prompt and agent // behavior; it does not make message.add a record-only operation. - if h.orchestrator != nil && !turnStartResult.Queued && !atomicQueuedPlanComments && !initialTaskBriefQueued { + if h.orchestrator != nil && !turnStartResult.Queued && !atomicQueuedTaskFeedback && !initialTaskBriefQueued { h.dispatchPromptAsync( ctx, req, sessionResp.Session.AgentProfileID, startCreatedSession, steer, trustedPromptContext, ) @@ -1011,7 +1034,7 @@ func (h *MessageHandlers) addMessageReplayResponse( // A replay may be the first process that survives long enough to kick // the atomically persisted delivery receipt. Notification is idempotent: // an acknowledged receipt makes this a no-op. - if len(req.PlanCommentRefs) > 0 { + if len(req.PlanCommentRefs) > 0 || len(req.PreviewFeedbackRefs) > 0 { if coordinator, ok := h.orchestrator.(AtomicQueuedPromptCoordinator); ok { coordinator.NotifyQueuedUserPrompt(ctx, existing.TaskID, existing.TaskSessionID) } @@ -1050,8 +1073,9 @@ func addMessageReplayConflicts( } storedFingerprint, _ := existing.Metadata[plancomments.MetadataClientMessageFingerprint].(string) return (storedFingerprint != "" && storedFingerprint != requestFingerprint) || - (storedFingerprint == "" && len(req.PlanCommentRefs) > 0) || - !plancomments.MetadataRefsMatch(existing.Metadata, req.PlanCommentRefs) + (storedFingerprint == "" && (len(req.PlanCommentRefs) > 0 || len(req.PreviewFeedbackRefs) > 0)) || + !plancomments.MetadataRefsMatch(existing.Metadata, req.PlanCommentRefs) || + !previewfeedback.MetadataRefsMatch(existing.Metadata, req.PreviewFeedbackRefs) } // lockMessageID serializes acceptance for one caller-owned message ID. The @@ -1159,7 +1183,7 @@ func validateAddMessageRequest(req wsAddMessageRequest) string { return "task_id is required" } // Content can be empty if there are attachments (image-only messages) - if req.Content == "" && len(req.Attachments) == 0 && len(req.PlanCommentRefs) == 0 { + if req.Content == "" && len(req.Attachments) == 0 && len(req.PlanCommentRefs) == 0 && len(req.PreviewFeedbackRefs) == 0 { return "content or attachments are required" } seenPlanComments := make(map[string]struct{}, len(req.PlanCommentRefs)) @@ -1172,11 +1196,21 @@ func validateAddMessageRequest(req wsAddMessageRequest) string { } seenPlanComments[ref.ID] = struct{}{} } + seenPreviewFeedback := make(map[string]struct{}, len(req.PreviewFeedbackRefs)) + for _, ref := range req.PreviewFeedbackRefs { + if ref.ID == "" || ref.Version <= 0 { + return "preview_feedback_refs are invalid" + } + if _, duplicate := seenPreviewFeedback[ref.ID]; duplicate { + return "preview_feedback_refs contain duplicates" + } + seenPreviewFeedback[ref.ID] = struct{}{} + } if len(req.ClientMessageID) > 128 { return "client_message_id is too long" } - if len(req.PlanCommentRefs) > 0 && req.ClientMessageID == "" { - return "client_message_id is required with plan comments" + if (len(req.PlanCommentRefs) > 0 || len(req.PreviewFeedbackRefs) > 0) && req.ClientMessageID == "" { + return "client_message_id is required with task feedback" } if len(req.PlanCommentRefs) > 0 && plancomments.ContainsReservedPlaceholder(req.Content) { return "content contains a reserved plan comment marker" @@ -1203,10 +1237,20 @@ func queuedMessageAttachments(attachments []v1.MessageAttachment) []messagequeue } func planCommentMessageError(msg *ws.Message, err error) *ws.Message { - if errors.Is(err, plancomments.ErrRenderedTooLarge) { + if errors.Is(err, plancomments.ErrRenderedTooLarge) || errors.Is(err, previewfeedback.ErrPromptTooLarge) { response, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Rendered message content is too long", nil) return response } + var previewChanged *previewfeedbacktx.FeedbackChangedError + if errors.As(err, &previewChanged) { + details := map[string]interface{}{} + if previewChanged.Snapshot != nil { + details["snapshot"] = dto.TaskPreviewFeedbackSnapshotFromModel(previewChanged.Snapshot) + } + response, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodePreviewFeedbackChanged, + "Task preview feedback changed", details) + return response + } var commentsChanged *plancommenttx.CommentsChangedError if errors.As(err, &commentsChanged) { details := map[string]interface{}{} diff --git a/apps/backend/internal/task/handlers/message_handlers_plan_comments_test.go b/apps/backend/internal/task/handlers/message_handlers_plan_comments_test.go index ee673168f6..e295193698 100644 --- a/apps/backend/internal/task/handlers/message_handlers_plan_comments_test.go +++ b/apps/backend/internal/task/handlers/message_handlers_plan_comments_test.go @@ -13,12 +13,98 @@ import ( "github.com/kandev/kandev/internal/orchestrator/messagequeue" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/plancomments" + "github.com/kandev/kandev/internal/task/previewfeedback" "github.com/kandev/kandev/internal/task/repository/plancommenttx" "github.com/kandev/kandev/internal/task/service" ws "github.com/kandev/kandev/pkg/websocket" "github.com/stretchr/testify/require" ) +func (r *messageAddSwitchRepo) ValidateMessageTaskFeedback( + _ context.Context, + _, _, content string, + planRefs []models.TaskPlanCommentRef, + previewRefs []models.TaskPreviewFeedbackRef, + requirePrimary bool, + _ models.TaskSessionState, +) error { + if !requirePrimary || len(planRefs) != 0 || len(previewRefs) != 1 || + previewRefs[0] != (models.TaskPreviewFeedbackRef{ID: "preview-handler", Version: 2}) || + content != "" { + return errors.New("preview feedback preflight was not forwarded") + } + return nil +} + +func (r *messageAddSwitchRepo) ListTaskPreviewFeedback( + context.Context, + string, +) (*models.TaskPreviewFeedbackSnapshot, error) { + return handlerPreviewFeedbackSnapshot(), nil +} + +func (r *messageAddSwitchRepo) CreateMessageWithTaskFeedback( + _ context.Context, + message *models.Message, + planRefs []models.TaskPlanCommentRef, + previewRefs []models.TaskPreviewFeedbackRef, + requirePrimary bool, + _ models.TaskSessionState, + _ *messagequeue.QueueAttachmentClaim, +) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, error) { + if !requirePrimary || len(planRefs) != 0 || len(previewRefs) != 1 || + previewRefs[0] != (models.TaskPreviewFeedbackRef{ID: "preview-handler", Version: 2}) { + return nil, nil, errors.New("preview feedback request was not forwarded") + } + content, err := previewfeedback.AppendMarkdown(message.Content, handlerPreviewFeedbackSnapshot().Items) + if err != nil { + return nil, nil, err + } + message.Content = content + message.PromptIndex = 1 + r.messagesMu.Lock() + r.messages = append(r.messages, message) + r.idempotentMessage = message + r.messagesMu.Unlock() + return nil, &models.TaskPreviewFeedbackSnapshot{TaskID: message.TaskID, Revision: 4, Items: []*models.TaskPreviewFeedback{}}, nil +} + +func (r *messageAddSwitchRepo) CreateMessageWithTaskFeedbackAndQueue( + ctx context.Context, + message *models.Message, + queued *messagequeue.QueuedMessage, + planRefs []models.TaskPlanCommentRef, + previewRefs []models.TaskPreviewFeedbackRef, + requirePrimary bool, + expectedState models.TaskSessionState, + claim *messagequeue.QueueAttachmentClaim, + _ int, +) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, error) { + planSnapshot, previewSnapshot, err := r.CreateMessageWithTaskFeedback( + ctx, message, planRefs, previewRefs, requirePrimary, expectedState, claim, + ) + if err != nil { + return nil, nil, err + } + queued.Content = message.Content + copy := *queued + r.queuedMessage = © + return planSnapshot, previewSnapshot, nil +} + +func handlerPreviewFeedbackSnapshot() *models.TaskPreviewFeedbackSnapshot { + return &models.TaskPreviewFeedbackSnapshot{ + TaskID: "t1", Revision: 3, + Items: []*models.TaskPreviewFeedback{{ + ID: "preview-handler", TaskID: "t1", Version: 2, + Kind: models.TaskPreviewFeedbackText, Comment: "Generated copy is unclear", + SourceKind: models.TaskPreviewFeedbackBrowser, SourceLabel: "Local app", + PageRoute: "/generated", SelectedText: "Runtime label", + TextAnchor: json.RawMessage(`{"start":{"node_path":[0],"offset":0},"end":{"node_path":[0],"offset":13},"containing_element":{"tag":"span","outer_html":"Runtime label"}}`), + }}, + } +} + func (r *messageAddSwitchRepo) ValidateMessagePlanComments( _ context.Context, _, _, content string, @@ -136,6 +222,44 @@ func TestWSAddMessageAcceptsEmptyBodyWithPlanCommentsAndUsesResolvedContent(t *t require.False(t, strings.ContainsRune(stored, '\x00')) } +func TestWSAddMessageAcceptsEmptyBodyWithPreviewFeedbackAndUsesResolvedContent(t *testing.T) { + now := time.Now().UTC() + repo := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{ + "t1": {ID: "t1", State: "IN_PROGRESS", UpdatedAt: now}, + }, + sessions: map[string]*models.TaskSession{ + "s1": {ID: "s1", TaskID: "t1", State: models.TaskSessionStateWaitingForInput, UpdatedAt: now}, + }, + primaryID: "s1", + } + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + h := NewMessageHandlers(svc, &firstTurnCaptureOrchestrator{}, log) + req, err := ws.NewRequest("req-preview-feedback", ws.ActionMessageAdd, map[string]interface{}{ + "task_id": "t1", "session_id": "s1", "content": "", + "client_message_id": "message-handler-preview-feedback", + "preview_feedback_refs": []map[string]interface{}{{"id": "preview-handler", "version": 2}}, + "require_primary_session": true, + }) + require.NoError(t, err) + + response, err := h.wsAddMessage(t.Context(), req) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, response.Type, string(response.Payload)) + stored := repo.firstMessageContent() + require.Contains(t, stored, "### Web Preview Feedback") + require.Contains(t, stored, "Runtime label") + require.Contains(t, stored, "containing_element") +} + func TestWSAddMessageRunRejectsPrimaryReplacedByOnTurnStart(t *testing.T) { now := time.Now().UTC() repo := &messageAddSwitchRepo{ diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 71f330458f..26ee6887df 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -284,6 +284,11 @@ func (h *TaskHandlers) registerWS(dispatcher *ws.Dispatcher) { dispatcher.RegisterFunc(ws.ActionTaskPlanCommentCreate, h.wsCreateTaskPlanComment) dispatcher.RegisterFunc(ws.ActionTaskPlanCommentUpdate, h.wsUpdateTaskPlanComment) dispatcher.RegisterFunc(ws.ActionTaskPlanCommentDelete, h.wsDeleteTaskPlanComment) + dispatcher.RegisterFunc(ws.ActionTaskPreviewFeedbackList, h.wsListTaskPreviewFeedback) + dispatcher.RegisterFunc(ws.ActionTaskPreviewFeedbackCreate, h.wsCreateTaskPreviewFeedback) + dispatcher.RegisterFunc(ws.ActionTaskPreviewFeedbackUpdate, h.wsUpdateTaskPreviewFeedback) + dispatcher.RegisterFunc(ws.ActionTaskPreviewFeedbackDelete, h.wsDeleteTaskPreviewFeedback) + dispatcher.RegisterFunc(ws.ActionTaskPreviewFeedbackClear, h.wsClearTaskPreviewFeedback) } // convertToServiceRepos converts dto.TaskRepositoryInput slice to service.TaskRepositoryInput slice. diff --git a/apps/backend/internal/task/handlers/task_preview_feedback_handlers.go b/apps/backend/internal/task/handlers/task_preview_feedback_handlers.go new file mode 100644 index 0000000000..f8f6120a5b --- /dev/null +++ b/apps/backend/internal/task/handlers/task_preview_feedback_handlers.go @@ -0,0 +1,155 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/previewfeedback" + "github.com/kandev/kandev/internal/task/repository" + "github.com/kandev/kandev/internal/task/service" + ws "github.com/kandev/kandev/pkg/websocket" +) + +func (h *TaskHandlers) wsListTaskPreviewFeedback(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return invalidPreviewFeedbackPayload(msg) + } + snapshot, err := h.planService.ListPreviewFeedback(ctx, req.TaskID) + if err != nil { + return previewFeedbackError(msg, err, snapshot) + } + return ws.NewResponse(msg.ID, msg.Action, dto.TaskPreviewFeedbackSnapshotFromModel(snapshot)) +} + +func (h *TaskHandlers) wsCreateTaskPreviewFeedback(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + ID string `json:"id"` + Kind models.TaskPreviewFeedbackKind `json:"kind"` + Comment string `json:"comment"` + SourceKind models.TaskPreviewFeedbackSourceKind `json:"source_kind"` + SourceSessionID string `json:"source_session_id"` + SourceLabel string `json:"source_label"` + SourcePath string `json:"source_path"` + PageRoute string `json:"page_route"` + PageTitle string `json:"page_title"` + SelectedText string `json:"selected_text"` + TextAnchor json.RawMessage `json:"text_anchor"` + ElementSnapshot json.RawMessage `json:"element_snapshot"` + CaptureRect json.RawMessage `json:"capture_rect"` + ScreenshotAttachmentID string `json:"screenshot_attachment_id"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return invalidPreviewFeedbackPayload(msg) + } + snapshot, err := h.planService.CreatePreviewFeedback(ctx, &models.TaskPreviewFeedback{ + ID: req.ID, TaskID: req.TaskID, Kind: req.Kind, Comment: req.Comment, + SourceKind: req.SourceKind, SourceSessionID: req.SourceSessionID, + SourceLabel: req.SourceLabel, SourcePath: req.SourcePath, + PageRoute: req.PageRoute, PageTitle: req.PageTitle, + SelectedText: req.SelectedText, TextAnchor: req.TextAnchor, + ElementSnapshot: req.ElementSnapshot, CaptureRect: req.CaptureRect, + ScreenshotAttachmentID: req.ScreenshotAttachmentID, + }) + if err != nil { + return previewFeedbackError(msg, err, snapshot) + } + return ws.NewResponse(msg.ID, msg.Action, dto.TaskPreviewFeedbackSnapshotFromModel(snapshot)) +} + +func (h *TaskHandlers) wsUpdateTaskPreviewFeedback(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + ID string `json:"id"` + Comment string `json:"comment"` + ExpectedVersion int64 `json:"expected_version"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return invalidPreviewFeedbackPayload(msg) + } + snapshot, err := h.planService.UpdatePreviewFeedback( + ctx, req.TaskID, req.ID, req.Comment, req.ExpectedVersion, + ) + if err != nil { + return previewFeedbackError(msg, err, snapshot) + } + return ws.NewResponse(msg.ID, msg.Action, dto.TaskPreviewFeedbackSnapshotFromModel(snapshot)) +} + +func (h *TaskHandlers) wsDeleteTaskPreviewFeedback(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + ID string `json:"id"` + ExpectedVersion int64 `json:"expected_version"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return invalidPreviewFeedbackPayload(msg) + } + snapshot, err := h.planService.DeletePreviewFeedback(ctx, req.TaskID, req.ID, req.ExpectedVersion) + if err != nil { + return previewFeedbackError(msg, err, snapshot) + } + return ws.NewResponse(msg.ID, msg.Action, dto.TaskPreviewFeedbackSnapshotFromModel(snapshot)) +} + +func (h *TaskHandlers) wsClearTaskPreviewFeedback(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + ExpectedRevision int64 `json:"expected_revision"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return invalidPreviewFeedbackPayload(msg) + } + snapshot, err := h.planService.ClearPreviewFeedback(ctx, req.TaskID, req.ExpectedRevision) + if err != nil { + return previewFeedbackError(msg, err, snapshot) + } + return ws.NewResponse(msg.ID, msg.Action, dto.TaskPreviewFeedbackSnapshotFromModel(snapshot)) +} + +func invalidPreviewFeedbackPayload(msg *ws.Message) (*ws.Message, error) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload", nil) +} + +func previewFeedbackError( + msg *ws.Message, + err error, + snapshot *models.TaskPreviewFeedbackSnapshot, +) (*ws.Message, error) { + if errors.Is(err, service.ErrTaskPreviewFeedbackChanged) { + details := map[string]interface{}{} + if snapshot != nil { + details["snapshot"] = dto.TaskPreviewFeedbackSnapshotFromModel(snapshot) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodePreviewFeedbackChanged, + "Task preview feedback changed", details) + } + code := ws.ErrorCodeValidation + message := "Invalid preview feedback" + switch { + case errors.Is(err, repository.ErrTaskNotFound): + code, message = ws.ErrorCodeNotFound, "Task not found" + case errors.Is(err, models.ErrAttachmentClaimConflict), errors.Is(err, models.ErrAttachmentForbidden): + code, message = ws.ErrorCodeConflict, "Screenshot attachment is unavailable" + case errors.Is(err, previewfeedback.ErrTooManyItems), errors.Is(err, previewfeedback.ErrTooManyScreenshots), + errors.Is(err, previewfeedback.ErrCollectionTooLarge): + message = "Task preview feedback collection is too large" + case errors.Is(err, previewfeedback.ErrCommentRequired): + message = "Preview feedback comment is required" + case errors.Is(err, previewfeedback.ErrCommentTooLarge), errors.Is(err, previewfeedback.ErrCaptureTooLarge), + errors.Is(err, previewfeedback.ErrSourceIdentityLarge): + message = "Preview feedback is too large" + case errors.Is(err, service.ErrTaskIDRequired), errors.Is(err, service.ErrPreviewFeedbackIDRequired), + errors.Is(err, service.ErrPreviewFeedbackIDInvalid), errors.Is(err, service.ErrPreviewFeedbackVersionRequired), + errors.Is(err, service.ErrPreviewFeedbackRevisionInvalid), errors.Is(err, previewfeedback.ErrCaptureInvalid): + default: + code, message = ws.ErrorCodeInternalError, "Failed to update task preview feedback" + } + return ws.NewError(msg.ID, msg.Action, code, message, nil) +} diff --git a/apps/backend/internal/task/handlers/task_preview_feedback_handlers_test.go b/apps/backend/internal/task/handlers/task_preview_feedback_handlers_test.go new file mode 100644 index 0000000000..3edfac5d54 --- /dev/null +++ b/apps/backend/internal/task/handlers/task_preview_feedback_handlers_test.go @@ -0,0 +1,93 @@ +package handlers + +import ( + "context" + "encoding/json" + "testing" + + ws "github.com/kandev/kandev/pkg/websocket" +) + +type previewFeedbackHandlerContract interface { + wsListTaskPreviewFeedback(context.Context, *ws.Message) (*ws.Message, error) + wsCreateTaskPreviewFeedback(context.Context, *ws.Message) (*ws.Message, error) + wsUpdateTaskPreviewFeedback(context.Context, *ws.Message) (*ws.Message, error) + wsDeleteTaskPreviewFeedback(context.Context, *ws.Message) (*ws.Message, error) + wsClearTaskPreviewFeedback(context.Context, *ws.Message) (*ws.Message, error) +} + +func requirePreviewFeedbackHandlers(t *testing.T, handlers *TaskHandlers) previewFeedbackHandlerContract { + t.Helper() + contract, ok := any(handlers).(previewFeedbackHandlerContract) + if !ok { + t.Fatal("TaskHandlers does not implement task preview feedback actions") + } + return contract +} + +func TestTaskPreviewFeedbackHandlersCRUDAndConflictSnapshot(t *testing.T) { + h := newPlanTestHandlers(t) + handlers := requirePreviewFeedbackHandlers(t, h) + ctx := context.Background() + + created, err := handlers.wsCreateTaskPreviewFeedback(ctx, planMsg(t, "task.preview_feedback.create", + `{"task_id":"`+planTaskID+`","id":"e587551e-cb61-4c12-b1b6-e205ad9c65fb",`+ + `"kind":"text","comment":"Increase contrast","source_kind":"browser",`+ + `"source_label":"Local app","page_route":"/products","page_title":"Products",`+ + `"selected_text":"Choose a plan","text_anchor":{"start":{"path":[0],"offset":0}}}`)) + if err != nil { + t.Fatalf("wsCreateTaskPreviewFeedback: %v", err) + } + snapshot := decodePreviewFeedbackSnapshot(t, created) + if snapshot.Revision != 1 || len(snapshot.Items) != 1 || snapshot.Items[0].Version != 1 { + t.Fatalf("created snapshot = %#v", snapshot) + } + + updated, err := handlers.wsUpdateTaskPreviewFeedback(ctx, planMsg(t, "task.preview_feedback.update", + `{"task_id":"`+planTaskID+`","id":"e587551e-cb61-4c12-b1b6-e205ad9c65fb",`+ + `"comment":"Use stronger contrast","expected_version":1}`)) + if err != nil { + t.Fatalf("wsUpdateTaskPreviewFeedback: %v", err) + } + snapshot = decodePreviewFeedbackSnapshot(t, updated) + if snapshot.Revision != 2 || snapshot.Items[0].Comment != "Use stronger contrast" { + t.Fatalf("updated snapshot = %#v", snapshot) + } + + conflict, err := handlers.wsDeleteTaskPreviewFeedback(ctx, planMsg(t, "task.preview_feedback.delete", + `{"task_id":"`+planTaskID+`","id":"e587551e-cb61-4c12-b1b6-e205ad9c65fb",`+ + `"expected_version":1}`)) + if err != nil { + t.Fatalf("wsDeleteTaskPreviewFeedback: %v", err) + } + var payload ws.ErrorPayload + if err := json.Unmarshal(conflict.Payload, &payload); err != nil { + t.Fatal(err) + } + current, ok := payload.Details["snapshot"].(map[string]any) + if payload.Code != "preview_feedback_changed" || !ok || current["revision"] != float64(2) { + t.Fatalf("conflict payload = %#v", payload) + } +} + +type previewFeedbackSnapshotPayload struct { + TaskID string `json:"task_id"` + Revision int64 `json:"revision"` + Items []struct { + ID string `json:"id"` + Comment string `json:"comment"` + Version int64 `json:"version"` + } `json:"items"` +} + +func decodePreviewFeedbackSnapshot(t *testing.T, msg *ws.Message) previewFeedbackSnapshotPayload { + t.Helper() + if msg.Type != ws.MessageTypeResponse { + t.Fatalf("message type = %q, want response (payload %s)", msg.Type, msg.Payload) + } + var snapshot previewFeedbackSnapshotPayload + if err := json.Unmarshal(msg.Payload, &snapshot); err != nil { + t.Fatalf("decode snapshot: %v", err) + } + return snapshot +} diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 7b52b32b53..5e6df41099 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -2722,6 +2722,57 @@ type TaskPlanCommentRef struct { Version int64 `json:"version"` } +type TaskPreviewFeedbackKind string + +const ( + TaskPreviewFeedbackText TaskPreviewFeedbackKind = "text" + TaskPreviewFeedbackElement TaskPreviewFeedbackKind = "element" + TaskPreviewFeedbackScreenshot TaskPreviewFeedbackKind = "screenshot" +) + +type TaskPreviewFeedbackSourceKind string + +const ( + TaskPreviewFeedbackBrowser TaskPreviewFeedbackSourceKind = "browser" + TaskPreviewFeedbackHTMLFile TaskPreviewFeedbackSourceKind = "html_file" +) + +// TaskPreviewFeedback is one immutable rendered-page capture with an editable comment. +type TaskPreviewFeedback struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Kind TaskPreviewFeedbackKind `json:"kind"` + Comment string `json:"comment"` + SourceKind TaskPreviewFeedbackSourceKind `json:"source_kind"` + SourceSessionID string `json:"source_session_id,omitempty"` + SourceLabel string `json:"source_label"` + SourcePath string `json:"source_path,omitempty"` + PageRoute string `json:"page_route"` + PageTitle string `json:"page_title"` + SelectedText string `json:"selected_text,omitempty"` + TextAnchor json.RawMessage `json:"text_anchor,omitempty"` + ElementSnapshot json.RawMessage `json:"element_snapshot,omitempty"` + CaptureRect json.RawMessage `json:"capture_rect,omitempty"` + ScreenshotAttachmentID string `json:"screenshot_attachment_id,omitempty"` + ScreenshotAttachment *TaskMessageAttachment `json:"screenshot_attachment,omitempty"` + Version int64 `json:"version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TaskPreviewFeedbackSnapshot is the authoritative pending collection for a task. +type TaskPreviewFeedbackSnapshot struct { + TaskID string `json:"task_id" db:"task_id"` + Revision int64 `json:"revision" db:"revision"` + Items []*TaskPreviewFeedback `json:"items"` +} + +// TaskPreviewFeedbackRef identifies the exact pending item version included in delivery. +type TaskPreviewFeedbackRef struct { + ID string `json:"id"` + Version int64 `json:"version"` +} + // TaskPlanRevision is one immutable snapshot in the revision history of a task plan. // Revisions are the source of truth for history; TaskPlan stores the latest revision's content as HEAD. type TaskPlanRevision struct { diff --git a/apps/backend/internal/task/previewfeedback/format.go b/apps/backend/internal/task/previewfeedback/format.go new file mode 100644 index 0000000000..7dd3a2a071 --- /dev/null +++ b/apps/backend/internal/task/previewfeedback/format.go @@ -0,0 +1,179 @@ +package previewfeedback + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/kandev/kandev/internal/task/models" +) + +var ErrPromptTooLarge = errors.New("preview feedback prompt is too large") + +const ( + MetadataRefs = "preview_feedback_refs" + MetadataRequestFingerprint = "preview_feedback_request_fingerprint" +) + +// AppendMarkdown adds persisted rendered-page evidence as visible user content. +func AppendMarkdown(content string, items []*models.TaskPreviewFeedback) (string, error) { + if len(items) == 0 { + return "", errors.New("preview feedback items are required") + } + var out strings.Builder + out.WriteString(content) + if content != "" && !strings.HasSuffix(content, "\n\n") { + if strings.HasSuffix(content, "\n") { + out.WriteByte('\n') + } else { + out.WriteString("\n\n") + } + } + out.WriteString("### Web Preview Feedback\n\n") + for index, item := range items { + if item == nil { + return "", ErrCaptureInvalid + } + if err := appendItemMarkdown(&out, index, item); err != nil { + return "", ErrCaptureInvalid + } + } + if out.Len() > MaxPromptBytes { + return "", ErrPromptTooLarge + } + return out.String(), nil +} + +func appendItemMarkdown(out *strings.Builder, index int, item *models.TaskPreviewFeedback) error { + fmt.Fprintf(out, "#### %d. %s feedback\n\n", index+1, formatKind(item.Kind)) + writeQuotedField(out, "Source", item.SourceLabel) + writeQuotedField(out, "Source type", string(item.SourceKind)) + if item.SourcePath != "" { + writeQuotedField(out, "Source path", item.SourcePath) + } + writeQuotedField(out, "Page route", item.PageRoute) + if item.PageTitle != "" { + writeQuotedField(out, "Page title", item.PageTitle) + } + out.WriteString("- Comment:\n") + writeQuote(out, item.Comment) + out.WriteByte('\n') + + if err := appendCaptureMarkdown(out, item); err != nil { + return err + } + out.WriteByte('\n') + return nil +} + +func appendCaptureMarkdown(out *strings.Builder, item *models.TaskPreviewFeedback) error { + switch item.Kind { + case models.TaskPreviewFeedbackText: + out.WriteString("- Selected text:\n") + writeQuote(out, item.SelectedText) + out.WriteString("\n- Rendered text anchor (containing element, DOM endpoints, rectangles, scroll, viewport, and pixel ratio):\n") + writeJSONFence(out, item.TextAnchor) + case models.TaskPreviewFeedbackElement: + out.WriteString("- Rendered element snapshot:\n") + writeJSONFence(out, item.ElementSnapshot) + out.WriteString("- Rendered position:\n") + writeJSONFence(out, item.CaptureRect) + case models.TaskPreviewFeedbackScreenshot: + writeQuotedField(out, "Image", screenshotName(item)) + out.WriteString("- Captured region and dimensions:\n") + writeJSONFence(out, item.CaptureRect) + default: + return ErrCaptureInvalid + } + return nil +} + +func screenshotName(item *models.TaskPreviewFeedback) string { + if item.ScreenshotAttachment != nil && item.ScreenshotAttachment.Name != "" { + return item.ScreenshotAttachment.Name + } + return item.ScreenshotAttachmentID +} + +// MetadataRefsMatch verifies the persisted delivery identity for an exact retry. +func MetadataRefsMatch(metadata map[string]interface{}, refs []models.TaskPreviewFeedbackRef) bool { + raw, exists := metadata[MetadataRefs] + if !exists { + return len(refs) == 0 + } + data, err := json.Marshal(raw) + if err != nil { + return false + } + var stored []models.TaskPreviewFeedbackRef + if err := json.Unmarshal(data, &stored); err != nil || len(stored) != len(refs) { + return false + } + for index := range refs { + if stored[index] != refs[index] { + return false + } + } + return true +} + +func formatKind(kind models.TaskPreviewFeedbackKind) string { + switch kind { + case models.TaskPreviewFeedbackText: + return "Text" + case models.TaskPreviewFeedbackElement: + return "Element" + case models.TaskPreviewFeedbackScreenshot: + return "Screenshot" + default: + return "Unknown" + } +} + +func writeQuotedField(out *strings.Builder, label, value string) { + fmt.Fprintf(out, "- %s: %s\n", label, strconv.Quote(value)) +} + +func writeQuote(out *strings.Builder, value string) { + for _, line := range strings.Split(value, "\n") { + out.WriteString("> ") + out.WriteString(line) + out.WriteByte('\n') + } +} + +func writeJSONFence(out *strings.Builder, raw json.RawMessage) { + formatted := raw + var pretty bytes.Buffer + if json.Indent(&pretty, raw, "", " ") == nil { + formatted = pretty.Bytes() + } + fence := strings.Repeat("`", longestBacktickRun(string(formatted))+1) + if len(fence) < 3 { + fence = "```" + } + out.WriteString(fence) + out.WriteString("json\n") + out.Write(formatted) + out.WriteByte('\n') + out.WriteString(fence) + out.WriteByte('\n') +} + +func longestBacktickRun(value string) int { + longest, current := 0, 0 + for _, char := range value { + if char == '`' { + current++ + if current > longest { + longest = current + } + } else { + current = 0 + } + } + return longest +} diff --git a/apps/backend/internal/task/previewfeedback/format_test.go b/apps/backend/internal/task/previewfeedback/format_test.go new file mode 100644 index 0000000000..d97bb4bd68 --- /dev/null +++ b/apps/backend/internal/task/previewfeedback/format_test.go @@ -0,0 +1,90 @@ +package previewfeedback + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestAppendMarkdownIncludesRuntimeTextLocationAndEscapesFences(t *testing.T) { + item := &models.TaskPreviewFeedback{ + Kind: models.TaskPreviewFeedbackText, Comment: "Generated at runtime", + SourceKind: models.TaskPreviewFeedbackBrowser, SourceLabel: "Local app", + PageRoute: "/products?tab=all", PageTitle: "Products", + SelectedText: "Choose `this` plan", + TextAnchor: json.RawMessage(`{ + "start":{"selector":"#offer","node_path":[0,1],"offset":2}, + "end":{"selector":"#offer","node_path":[0,1],"offset":20}, + "rects":[{"x":20,"y":30,"width":160,"height":20}], + "union_rect":{"x":20,"y":30,"width":160,"height":20}, + "scroll_x":0,"scroll_y":480,"viewport_width":1280,"viewport_height":720, + "device_pixel_ratio":2, + "containing_element":{"tag":"button","classes":["offer"],"outer_html":""} + }`), + } + + got, err := AppendMarkdown("Please fix this.", []*models.TaskPreviewFeedback{item}) + if err != nil { + t.Fatalf("AppendMarkdown: %v", err) + } + for _, want := range []string{ + "Please fix this.", "### Web Preview Feedback", "Text feedback", + `Source: "Local app"`, `Page route: "/products?tab=all"`, + "Choose `this` plan", "containing_element", "node_path", "union_rect", + "scroll_y", "viewport_width", "device_pixel_ratio", "````json", + } { + if !strings.Contains(got, want) { + t.Fatalf("formatted prompt missing %q:\n%s", want, got) + } + } +} + +func TestAppendMarkdownFormatsElementAndScreenshot(t *testing.T) { + items := []*models.TaskPreviewFeedback{ + { + Kind: models.TaskPreviewFeedbackElement, Comment: "Align with price", + SourceKind: models.TaskPreviewFeedbackHTMLFile, SourceLabel: "checkout.html", + SourcePath: "mockups/checkout.html", PageRoute: "/checkout", PageTitle: "Checkout", + ElementSnapshot: json.RawMessage(`{"tag":"button","selector":"button.checkout","outer_html":""}`), + CaptureRect: json.RawMessage(`{"x":12,"y":24,"width":80,"height":32}`), + }, + { + Kind: models.TaskPreviewFeedbackScreenshot, Comment: "The form clips here", + SourceKind: models.TaskPreviewFeedbackBrowser, SourceLabel: "Local app", + PageRoute: "/account", PageTitle: "Account", + CaptureRect: json.RawMessage(`{"x":10,"y":20,"width":820,"height":360}`), + ScreenshotAttachmentID: "shot-1", + ScreenshotAttachment: &models.TaskMessageAttachment{ + ID: "shot-1", Name: "account-form.png", MimeType: "image/png", SizeBytes: 1234, + }, + }, + } + + got, err := AppendMarkdown("", items) + if err != nil { + t.Fatalf("AppendMarkdown: %v", err) + } + for _, want := range []string{ + "Element feedback", ``, + "Rendered position", "Screenshot feedback", `Image: "account-form.png"`, + "820", "360", "The form clips here", + } { + if !strings.Contains(got, want) { + t.Fatalf("formatted prompt missing %q:\n%s", want, got) + } + } +} + +func TestAppendMarkdownRejectsOversizedPrompt(t *testing.T) { + _, err := AppendMarkdown(strings.Repeat("x", MaxPromptBytes), []*models.TaskPreviewFeedback{{ + Kind: models.TaskPreviewFeedbackElement, Comment: "comment", + SourceKind: models.TaskPreviewFeedbackBrowser, SourceLabel: "app", + PageRoute: "/", ElementSnapshot: json.RawMessage(`{"outer_html":"
"}`), + CaptureRect: json.RawMessage(`{"x":0,"y":0,"width":1,"height":1}`), + }}) + if err == nil { + t.Fatal("AppendMarkdown accepted an oversized prompt") + } +} diff --git a/apps/backend/internal/task/previewfeedback/validate.go b/apps/backend/internal/task/previewfeedback/validate.go new file mode 100644 index 0000000000..d4c5fce761 --- /dev/null +++ b/apps/backend/internal/task/previewfeedback/validate.go @@ -0,0 +1,174 @@ +// Package previewfeedback validates task-owned rendered-page feedback at every +// persistence and delivery boundary. +package previewfeedback + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/kandev/kandev/internal/task/models" +) + +const ( + MaxItems = 100 + MaxCommentBytes = 64 * 1024 + MaxCaptureFieldBytes = 256 * 1024 + MaxCollectionPayloadBytes = 1024 * 1024 + MaxScreenshots = 10 + MaxScreenshotBytes int64 = 10 * 1024 * 1024 + MaxScreenshotPixels int64 = 16_000_000 + MaxPromptBytes = 1024 * 1024 + MaxSourceLabelBytes = 4 * 1024 + MaxPageIdentityBytes = 16 * 1024 +) + +var ( + ErrCommentRequired = errors.New("preview feedback comment is required") + ErrCommentTooLarge = errors.New("preview feedback comment is too large") + ErrCaptureTooLarge = errors.New("preview feedback capture is too large") + ErrCaptureInvalid = errors.New("preview feedback capture is invalid") + ErrCollectionTooLarge = errors.New("preview feedback collection is too large") + ErrTooManyItems = errors.New("too many preview feedback items") + ErrTooManyScreenshots = errors.New("too many preview feedback screenshots") + ErrSourceIdentityLarge = errors.New("preview feedback source identity is too large") +) + +func ValidateComment(comment string) error { + if strings.TrimSpace(comment) == "" { + return ErrCommentRequired + } + if len(comment) > MaxCommentBytes { + return ErrCommentTooLarge + } + return nil +} + +// ValidateItem enforces kind-specific capture fields and bounded source data. +func ValidateItem(item *models.TaskPreviewFeedback) error { + if item == nil { + return ErrCaptureInvalid + } + if err := ValidateComment(item.Comment); err != nil { + return err + } + if err := validateSource(item); err != nil { + return err + } + if err := validateOptionalJSON(item.CaptureRect); err != nil { + return err + } + switch item.Kind { + case models.TaskPreviewFeedbackText: + return validateTextItem(item) + case models.TaskPreviewFeedbackElement: + return validateElementItem(item) + case models.TaskPreviewFeedbackScreenshot: + return validateScreenshotItem(item) + default: + return ErrCaptureInvalid + } +} + +func validateSource(item *models.TaskPreviewFeedback) error { + if strings.TrimSpace(item.SourceLabel) == "" || strings.TrimSpace(item.PageRoute) == "" { + return ErrCaptureInvalid + } + if len(item.SourceLabel) > MaxSourceLabelBytes || len(item.SourcePath) > MaxPageIdentityBytes || + len(item.PageRoute) > MaxPageIdentityBytes || len(item.PageTitle) > MaxPageIdentityBytes { + return ErrSourceIdentityLarge + } + switch item.SourceKind { + case models.TaskPreviewFeedbackBrowser: + if item.SourcePath != "" { + return ErrCaptureInvalid + } + case models.TaskPreviewFeedbackHTMLFile: + if strings.TrimSpace(item.SourcePath) == "" { + return ErrCaptureInvalid + } + default: + return ErrCaptureInvalid + } + return nil +} + +func validateTextItem(item *models.TaskPreviewFeedback) error { + if strings.TrimSpace(item.SelectedText) == "" || len(item.TextAnchor) == 0 || + len(item.ElementSnapshot) != 0 || item.ScreenshotAttachmentID != "" { + return ErrCaptureInvalid + } + if len(item.SelectedText) > MaxCaptureFieldBytes { + return ErrCaptureTooLarge + } + return validateRequiredJSON(item.TextAnchor) +} + +func validateElementItem(item *models.TaskPreviewFeedback) error { + if item.SelectedText != "" || len(item.TextAnchor) != 0 || len(item.ElementSnapshot) == 0 || + len(item.CaptureRect) == 0 || item.ScreenshotAttachmentID != "" { + return ErrCaptureInvalid + } + return validateRequiredJSON(item.ElementSnapshot) +} + +func validateScreenshotItem(item *models.TaskPreviewFeedback) error { + if item.SelectedText != "" || len(item.TextAnchor) != 0 || len(item.ElementSnapshot) != 0 || + len(item.CaptureRect) == 0 || item.ScreenshotAttachmentID == "" { + return ErrCaptureInvalid + } + return nil +} + +// ValidateCollection applies count and aggregate non-image payload limits. +func ValidateCollection(items []*models.TaskPreviewFeedback) error { + if len(items) > MaxItems { + return ErrTooManyItems + } + total := 0 + screenshots := 0 + for _, item := range items { + if err := ValidateItem(item); err != nil { + return err + } + if item.Kind == models.TaskPreviewFeedbackScreenshot { + screenshots++ + } + total += itemPayloadBytes(item) + } + if screenshots > MaxScreenshots { + return ErrTooManyScreenshots + } + if total > MaxCollectionPayloadBytes { + return ErrCollectionTooLarge + } + return nil +} + +func validateRequiredJSON(value json.RawMessage) error { + if len(value) == 0 { + return ErrCaptureInvalid + } + return validateOptionalJSON(value) +} + +func validateOptionalJSON(value json.RawMessage) error { + if len(value) == 0 { + return nil + } + if len(value) > MaxCaptureFieldBytes { + return ErrCaptureTooLarge + } + if !json.Valid(value) { + return fmt.Errorf("%w: malformed JSON", ErrCaptureInvalid) + } + return nil +} + +func itemPayloadBytes(item *models.TaskPreviewFeedback) int { + return len(item.Comment) + len(item.SourceSessionID) + len(item.SourceLabel) + + len(item.SourcePath) + len(item.PageRoute) + len(item.PageTitle) + + len(item.SelectedText) + len(item.TextAnchor) + len(item.ElementSnapshot) + + len(item.CaptureRect) +} diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 5992bd7c30..e97664738a 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -19,6 +19,7 @@ var ErrNoPrimarySession = repoerrors.ErrNoPrimarySession var ErrTaskParentMismatch = repoerrors.ErrTaskParentMismatch var ErrTaskPlanNotFound = repoerrors.ErrTaskPlanNotFound var ErrTaskPlanCommentsChanged = repoerrors.ErrTaskPlanCommentsChanged +var ErrTaskPreviewFeedbackChanged = repoerrors.ErrTaskPreviewFeedbackChanged var ErrRepositoryNotFound = repoerrors.ErrRepositoryNotFound var ErrTaskEnvironmentNotFound = repoerrors.ErrTaskEnvironmentNotFound var ErrTaskEnvironmentOwnershipChanged = repoerrors.ErrTaskEnvironmentOwnershipChanged @@ -387,6 +388,15 @@ type AttachmentRepository interface { MarkExpiredMessageAttachments(ctx context.Context, now time.Time) ([]*models.TaskMessageAttachment, error) } +// PreviewFeedbackRepository stores one revisioned pending collection per task. +type PreviewFeedbackRepository interface { + ListTaskPreviewFeedback(ctx context.Context, taskID string) (*models.TaskPreviewFeedbackSnapshot, error) + CreateTaskPreviewFeedback(ctx context.Context, item *models.TaskPreviewFeedback, ownerID, workspaceID string) (*models.TaskPreviewFeedbackSnapshot, error) + UpdateTaskPreviewFeedback(ctx context.Context, taskID, itemID, comment string, expectedVersion int64) (*models.TaskPreviewFeedbackSnapshot, error) + DeleteTaskPreviewFeedback(ctx context.Context, taskID, itemID string, expectedVersion int64) (*models.TaskPreviewFeedbackSnapshot, []*models.TaskMessageAttachment, error) + ClearTaskPreviewFeedback(ctx context.Context, taskID string, expectedRevision int64) (*models.TaskPreviewFeedbackSnapshot, []*models.TaskMessageAttachment, error) +} + // QueueAttachmentAdmissionRepository scopes provisional attachment claims to // one caller-owned queue admission so rollback can restore only that attempt. type QueueAttachmentAdmissionRepository interface { diff --git a/apps/backend/internal/task/repository/plancommenttx/consume.go b/apps/backend/internal/task/repository/plancommenttx/consume.go index a9d335cab4..354de2c6c2 100644 --- a/apps/backend/internal/task/repository/plancommenttx/consume.go +++ b/apps/backend/internal/task/repository/plancommenttx/consume.go @@ -106,6 +106,19 @@ func ResolveQueue( return resolve(ctx, tx, db, taskID, sessionID, contentTemplate, refs, requirePrimary, "") } +// ValidateTargetSession exposes the shared task/session route guard to other +// task-owned context admitted by the same message transaction. +func ValidateTargetSession( + ctx context.Context, + tx *sqlx.Tx, + db *sqlx.DB, + taskID, sessionID string, + requirePrimary bool, + expectedState models.TaskSessionState, +) error { + return validateTargetSession(ctx, tx, db, taskID, sessionID, requirePrimary, expectedState) +} + func resolve( ctx context.Context, tx *sqlx.Tx, diff --git a/apps/backend/internal/task/repository/previewfeedbacktx/consume.go b/apps/backend/internal/task/repository/previewfeedbacktx/consume.go new file mode 100644 index 0000000000..af6f8fd9c5 --- /dev/null +++ b/apps/backend/internal/task/repository/previewfeedbacktx/consume.go @@ -0,0 +1,205 @@ +// Package previewfeedbacktx resolves and consumes task-owned preview feedback +// inside the final message or queue admission transaction. +package previewfeedbacktx + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/previewfeedback" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +// Resolution is the immutable evidence selected under the task admission lock. +type Resolution struct { + TaskID string + Content string + Items []*models.TaskPreviewFeedback + Before *models.TaskPreviewFeedbackSnapshot +} + +// FeedbackChangedError carries the authoritative snapshot for client recovery. +type FeedbackChangedError struct { + Snapshot *models.TaskPreviewFeedbackSnapshot +} + +func (e *FeedbackChangedError) Error() string { + return repoerrors.ErrTaskPreviewFeedbackChanged.Error() +} +func (e *FeedbackChangedError) Unwrap() error { return repoerrors.ErrTaskPreviewFeedbackChanged } + +// Resolve validates exact task-owned references and formats their visible prompt section. +func Resolve( + ctx context.Context, + tx *sqlx.Tx, + db *sqlx.DB, + taskID, content string, + refs []models.TaskPreviewFeedbackRef, +) (*Resolution, error) { + snapshot, err := readSnapshot(ctx, tx, db, taskID) + if err != nil { + return nil, err + } + items, ok := resolveReferences(snapshot, refs) + if !ok { + return nil, &FeedbackChangedError{Snapshot: snapshot} + } + formatted, err := previewfeedback.AppendMarkdown(content, items) + if err != nil { + return nil, err + } + return &Resolution{TaskID: taskID, Content: formatted, Items: items, Before: snapshot}, nil +} + +// Consume conditionally deletes exact versions and advances the collection revision. +func Consume( + ctx context.Context, + tx *sqlx.Tx, + db *sqlx.DB, + resolved *Resolution, +) (*models.TaskPreviewFeedbackSnapshot, error) { + if resolved == nil || len(resolved.Items) == 0 { + return nil, errors.New("resolved preview feedback is required") + } + query := `DELETE FROM task_preview_feedback WHERE task_id = ? AND (` + args := []interface{}{resolved.TaskID} + for index, item := range resolved.Items { + if index > 0 { + query += ` OR ` + } + query += `(id = ? AND version = ?)` + args = append(args, item.ID, item.Version) + } + query += `)` + result, err := tx.ExecContext(ctx, db.Rebind(query), args...) + if err != nil { + return nil, fmt.Errorf("consume task preview feedback: %w", err) + } + if changed, _ := result.RowsAffected(); changed != int64(len(resolved.Items)) { + return nil, &FeedbackChangedError{Snapshot: resolved.Before} + } + result, err = tx.ExecContext(ctx, db.Rebind(` + UPDATE task_preview_feedback_collections SET revision = revision + 1 + WHERE task_id = ? + `), resolved.TaskID) + if err != nil { + return nil, fmt.Errorf("advance task preview feedback revision: %w", err) + } + if changed, _ := result.RowsAffected(); changed != 1 { + return nil, repoerrors.ErrTaskNotFound + } + return readSnapshot(ctx, tx, db, resolved.TaskID) +} + +func resolveReferences( + snapshot *models.TaskPreviewFeedbackSnapshot, + refs []models.TaskPreviewFeedbackRef, +) ([]*models.TaskPreviewFeedback, bool) { + if snapshot == nil || len(refs) == 0 { + return nil, false + } + wanted := make(map[string]int64, len(refs)) + for _, ref := range refs { + if ref.ID == "" || ref.Version <= 0 { + return nil, false + } + if _, duplicate := wanted[ref.ID]; duplicate { + return nil, false + } + wanted[ref.ID] = ref.Version + } + items := make([]*models.TaskPreviewFeedback, 0, len(refs)) + for _, item := range snapshot.Items { + if version, selected := wanted[item.ID]; selected { + if item.Version != version { + return nil, false + } + items = append(items, item) + } + } + return items, len(items) == len(refs) +} + +func readSnapshot( + ctx context.Context, + q sqlx.QueryerContext, + db *sqlx.DB, + taskID string, +) (*models.TaskPreviewFeedbackSnapshot, error) { + snapshot := &models.TaskPreviewFeedbackSnapshot{TaskID: taskID, Items: []*models.TaskPreviewFeedback{}} + if err := sqlx.GetContext(ctx, q, &snapshot.Revision, db.Rebind(` + SELECT revision FROM task_preview_feedback_collections WHERE task_id = ? + `), taskID); err != nil { + return nil, fmt.Errorf("read task preview feedback collection: %w", err) + } + rows, err := q.QueryContext(ctx, db.Rebind(` + SELECT f.id, f.task_id, f.kind, f.comment, f.source_kind, + COALESCE(f.source_session_id, ''), f.source_label, COALESCE(f.source_path, ''), + f.page_route, f.page_title, COALESCE(f.selected_text, ''), + COALESCE(f.text_anchor_json, ''), COALESCE(f.element_snapshot_json, ''), + COALESCE(f.capture_rect_json, ''), COALESCE(f.screenshot_attachment_id, ''), + f.version, f.created_at, f.updated_at, + COALESCE(a.id, ''), COALESCE(a.owner_id, ''), COALESCE(a.workspace_id, ''), + COALESCE(a.task_id, ''), COALESCE(a.session_id, ''), COALESCE(a.message_id, ''), + COALESCE(a.queue_id, ''), COALESCE(a.name, ''), COALESCE(a.mime_type, ''), + COALESCE(a.kind, ''), COALESCE(a.delivery_mode, ''), COALESCE(a.size_bytes, 0), + COALESCE(a.storage_key, ''), COALESCE(a.state, ''), a.expires_at, a.created_at, a.updated_at + FROM task_preview_feedback f + LEFT JOIN task_message_attachments a ON a.id = f.screenshot_attachment_id + WHERE f.task_id = ? ORDER BY f.created_at, f.id + `), taskID) + if err != nil { + return nil, fmt.Errorf("list task preview feedback for delivery: %w", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + item := &models.TaskPreviewFeedback{} + attachment := &models.TaskMessageAttachment{} + var textAnchor, elementSnapshot, captureRect string + var attachmentExpires, attachmentCreated, attachmentUpdated sql.NullTime + if err := rows.Scan( + &item.ID, &item.TaskID, &item.Kind, &item.Comment, &item.SourceKind, + &item.SourceSessionID, &item.SourceLabel, &item.SourcePath, + &item.PageRoute, &item.PageTitle, &item.SelectedText, + &textAnchor, &elementSnapshot, &captureRect, &item.ScreenshotAttachmentID, + &item.Version, &item.CreatedAt, &item.UpdatedAt, + &attachment.ID, &attachment.OwnerID, &attachment.WorkspaceID, + &attachment.TaskID, &attachment.SessionID, &attachment.MessageID, + &attachment.QueueID, &attachment.Name, &attachment.MimeType, + &attachment.Kind, &attachment.DeliveryMode, &attachment.SizeBytes, + &attachment.StorageKey, &attachment.State, &attachmentExpires, + &attachmentCreated, &attachmentUpdated, + ); err != nil { + return nil, fmt.Errorf("scan task preview feedback for delivery: %w", err) + } + item.TextAnchor = rawJSON(textAnchor) + item.ElementSnapshot = rawJSON(elementSnapshot) + item.CaptureRect = rawJSON(captureRect) + if item.ScreenshotAttachmentID != "" { + if attachment.ID == "" { + return nil, models.ErrAttachmentNotFound + } + attachment.ExpiresAt = attachmentExpires.Time + attachment.CreatedAt = attachmentCreated.Time + attachment.UpdatedAt = attachmentUpdated.Time + item.ScreenshotAttachment = attachment + } + snapshot.Items = append(snapshot.Items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate task preview feedback for delivery: %w", err) + } + return snapshot, nil +} + +func rawJSON(value string) json.RawMessage { + if value == "" { + return nil + } + return json.RawMessage(value) +} diff --git a/apps/backend/internal/task/repository/repoerrors/errors.go b/apps/backend/internal/task/repository/repoerrors/errors.go index 9fe01d2aa6..62a93bf9a5 100644 --- a/apps/backend/internal/task/repository/repoerrors/errors.go +++ b/apps/backend/internal/task/repository/repoerrors/errors.go @@ -38,6 +38,10 @@ var ErrTaskPlanNotFound = errors.New("task plan not found") // stale plan identity, row version, or caller-generated comment identity. var ErrTaskPlanCommentsChanged = errors.New("task plan comments changed") +// ErrTaskPreviewFeedbackChanged reports an optimistic-version conflict in a +// task's pending rendered-page feedback collection. +var ErrTaskPreviewFeedbackChanged = errors.New("task preview feedback changed") + // ErrPrimarySessionChanged reports that a guarded delivery no longer targets // the task's current primary session. var ErrPrimarySessionChanged = errors.New("primary session changed") diff --git a/apps/backend/internal/task/repository/sqlite/attachment.go b/apps/backend/internal/task/repository/sqlite/attachment.go index 1fde5be931..696b61dc1c 100644 --- a/apps/backend/internal/task/repository/sqlite/attachment.go +++ b/apps/backend/internal/task/repository/sqlite/attachment.go @@ -273,6 +273,10 @@ func addQueuedAttachmentToClaim( (attachment.QueueID != "" && attachment.QueueID != queueID) { return models.ErrAttachmentClaimConflict } + if attachment.SizeBytes < 0 || attachment.SizeBytes > models.MaxMessageAttachmentBytes { + return models.ErrAttachmentTooLarge + } + selection.selectedSize += attachment.SizeBytes return nil } @@ -407,6 +411,10 @@ func addDirectAttachmentToClaim( (attachment.MessageID != "" && attachment.MessageID != messageID) { return models.ErrAttachmentClaimConflict } + if attachment.SizeBytes < 0 || attachment.SizeBytes > models.MaxMessageAttachmentBytes { + return models.ErrAttachmentTooLarge + } + selection.selectedSize += attachment.SizeBytes return nil } diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index 08db9d24ea..16bf98778c 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -36,6 +36,7 @@ func (r *Repository) initSchemaContext(ctx context.Context) error { r.initStepEntriesSchema, r.initTaskUsageEventsSchema, r.initAttachmentsSchema, + r.initPreviewFeedbackSchema, r.initTaskResourceCleanupSchema, r.initControlServerRecordSchema, r.initGitSchema, @@ -943,6 +944,64 @@ func (r *Repository) initAttachmentsSchema() error { return nil } +func (r *Repository) initPreviewFeedbackSchema() error { + _, err := r.db.ExecContext(r.migrationContext(), ` + CREATE TABLE IF NOT EXISTS task_preview_feedback_collections ( + task_id TEXT PRIMARY KEY, + revision INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS task_preview_feedback ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('text', 'element', 'screenshot')), + comment TEXT NOT NULL, + source_kind TEXT NOT NULL CHECK (source_kind IN ('browser', 'html_file')), + source_session_id TEXT, + source_label TEXT NOT NULL, + source_path TEXT, + page_route TEXT NOT NULL, + page_title TEXT NOT NULL, + selected_text TEXT, + text_anchor_json TEXT, + element_snapshot_json TEXT, + capture_rect_json TEXT, + screenshot_attachment_id TEXT UNIQUE, + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY (screenshot_attachment_id) REFERENCES task_message_attachments(id), + CHECK ( + (kind = 'text' AND selected_text IS NOT NULL AND text_anchor_json IS NOT NULL + AND element_snapshot_json IS NULL AND screenshot_attachment_id IS NULL) + OR (kind = 'element' AND selected_text IS NULL AND text_anchor_json IS NULL + AND element_snapshot_json IS NOT NULL AND screenshot_attachment_id IS NULL) + OR (kind = 'screenshot' AND selected_text IS NULL AND text_anchor_json IS NULL + AND element_snapshot_json IS NULL AND capture_rect_json IS NOT NULL + AND screenshot_attachment_id IS NOT NULL) + ) + ); + CREATE INDEX IF NOT EXISTS idx_task_preview_feedback_order + ON task_preview_feedback(task_id, created_at, id); + CREATE TABLE IF NOT EXISTS task_preview_feedback_admissions ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_task_preview_feedback_admissions_task + ON task_preview_feedback_admissions(task_id); + `) + if err != nil { + return fmt.Errorf("init preview feedback schema: %w", err) + } + return nil +} + func (r *Repository) initSessionSchema() error { if err := r.initSessionWorktreeSchema(); err != nil { return err diff --git a/apps/backend/internal/task/repository/sqlite/message_preview_feedback.go b/apps/backend/internal/task/repository/sqlite/message_preview_feedback.go new file mode 100644 index 0000000000..23c8a3f63b --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/message_preview_feedback.go @@ -0,0 +1,465 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/admission" + "github.com/kandev/kandev/internal/task/repository/plancommenttx" + "github.com/kandev/kandev/internal/task/repository/previewfeedbacktx" +) + +// ValidateMessageTaskFeedback repeats exact route, plan-comment, preview, and +// final prompt validation without accepting a message. +func (r *Repository) ValidateMessageTaskFeedback( + ctx context.Context, + taskID, sessionID, content string, + planRefs []models.TaskPlanCommentRef, + previewRefs []models.TaskPreviewFeedbackRef, + requirePrimary bool, + expectedState models.TaskSessionState, +) error { + tx, release, err := r.beginPlanCommentTx(ctx, taskID) + if err != nil { + return err + } + defer release() + defer func() { _ = tx.Rollback() }() + if err := r.guardActivePlanCommentTaskTx(ctx, tx, taskID); err != nil { + return err + } + if len(planRefs) > 0 { + resolved, err := plancommenttx.ResolveDirect( + ctx, tx, r.db, taskID, sessionID, content, planRefs, requirePrimary, expectedState, + ) + if err != nil { + return err + } + content = resolved.Content + } else if err := plancommenttx.ValidateTargetSession( + ctx, tx, r.db, taskID, sessionID, requirePrimary, expectedState, + ); err != nil { + return err + } + _, err = previewfeedbacktx.Resolve(ctx, tx, r.db, taskID, content, previewRefs) + return err +} + +// CreateMessageWithTaskFeedback atomically persists a user message and +// consumes its exact plan-comment and preview-feedback snapshots. +func (r *Repository) CreateMessageWithTaskFeedback( + ctx context.Context, + message *models.Message, + planRefs []models.TaskPlanCommentRef, + previewRefs []models.TaskPreviewFeedbackRef, + requirePrimary bool, + expectedState models.TaskSessionState, + claim *messagequeue.QueueAttachmentClaim, +) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, error) { + return r.createMessageWithTaskFeedback( + ctx, message, nil, planRefs, previewRefs, requirePrimary, expectedState, claim, + ) +} + +// CreateMessageWithTaskFeedbackWithInitialTaskBrief includes first-prompt +// selection in the same context-consumption transaction. +func (r *Repository) CreateMessageWithTaskFeedbackWithInitialTaskBrief( + ctx context.Context, + message *models.Message, + candidate *admission.InitialTaskBriefCandidate, + planRefs []models.TaskPlanCommentRef, + previewRefs []models.TaskPreviewFeedbackRef, + requirePrimary bool, + expectedState models.TaskSessionState, + claim *messagequeue.QueueAttachmentClaim, +) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, error) { + return r.createMessageWithTaskFeedback( + ctx, message, candidate, planRefs, previewRefs, requirePrimary, expectedState, claim, + ) +} + +func (r *Repository) createMessageWithTaskFeedback( + ctx context.Context, + message *models.Message, + candidate *admission.InitialTaskBriefCandidate, + planRefs []models.TaskPlanCommentRef, + previewRefs []models.TaskPreviewFeedbackRef, + requirePrimary bool, + expectedState models.TaskSessionState, + claim *messagequeue.QueueAttachmentClaim, +) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, error) { + if message == nil || len(previewRefs) == 0 { + return nil, nil, fmt.Errorf("preview feedback and user message are required") + } + messageType, requestsInput, err := normalizeTaskFeedbackMessage(message) + if err != nil { + return nil, nil, err + } + original := *message + originalCandidateSelected := taskFeedbackCandidateSelected(candidate) + tx, release, err := r.beginPlanCommentTx(ctx, message.TaskID) + if err != nil { + return nil, nil, fmt.Errorf("begin task-feedback message creation: %w", err) + } + defer release() + defer func() { + _ = tx.Rollback() + if err != nil { + *message = original + if candidate != nil { + candidate.Selected = originalCandidateSelected + } + } + }() + if err = r.guardActivePlanCommentTaskTx(ctx, tx, message.TaskID); err != nil { + return nil, nil, err + } + if err = lockSessionTurnWrites(ctx, tx, r.db.DriverName(), message.TaskSessionID); err != nil { + return nil, nil, err + } + + content, err := r.applyTaskFeedbackInitialBrief(ctx, tx, message, candidate) + if err != nil { + return nil, nil, err + } + planResolution, previewResolution, err := r.resolveDirectTaskFeedback( + ctx, tx, message, content, planRefs, previewRefs, requirePrimary, expectedState, + ) + if err != nil { + return nil, nil, err + } + if err = r.persistDirectTaskFeedbackMessage( + ctx, tx, message, previewResolution, claim, messageType, requestsInput, + ); err != nil { + return nil, nil, err + } + planSnapshot, previewSnapshot, err := consumeDirectTaskFeedback( + ctx, tx, r.db, planResolution, previewResolution, + ) + if err != nil { + return nil, nil, err + } + if err = tx.Commit(); err != nil { + return nil, nil, fmt.Errorf("commit task-feedback message creation: %w", err) + } + return planSnapshot, previewSnapshot, nil +} + +// CreateMessageWithTaskFeedbackAndQueue persists the transcript row, durable +// queue receipt, attachment claims, and exact feedback consumption together. +func (r *Repository) CreateMessageWithTaskFeedbackAndQueue( + ctx context.Context, + message *models.Message, + queued *messagequeue.QueuedMessage, + planRefs []models.TaskPlanCommentRef, + previewRefs []models.TaskPreviewFeedbackRef, + requirePrimary bool, + expectedState models.TaskSessionState, + claim *messagequeue.QueueAttachmentClaim, + maxPerSession int, +) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, error) { + return r.createMessageWithTaskFeedbackAndQueue( + ctx, message, queued, nil, planRefs, previewRefs, requirePrimary, + expectedState, claim, maxPerSession, + ) +} + +// CreateMessageWithTaskFeedbackAndQueueWithInitialTaskBrief includes first-prompt +// selection in the same queued context-consumption transaction. +func (r *Repository) CreateMessageWithTaskFeedbackAndQueueWithInitialTaskBrief( + ctx context.Context, + message *models.Message, + queued *messagequeue.QueuedMessage, + candidate *admission.InitialTaskBriefCandidate, + planRefs []models.TaskPlanCommentRef, + previewRefs []models.TaskPreviewFeedbackRef, + requirePrimary bool, + expectedState models.TaskSessionState, + claim *messagequeue.QueueAttachmentClaim, + maxPerSession int, +) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, error) { + return r.createMessageWithTaskFeedbackAndQueue( + ctx, message, queued, candidate, planRefs, previewRefs, requirePrimary, + expectedState, claim, maxPerSession, + ) +} + +func (r *Repository) createMessageWithTaskFeedbackAndQueue( + ctx context.Context, + message *models.Message, + queued *messagequeue.QueuedMessage, + candidate *admission.InitialTaskBriefCandidate, + planRefs []models.TaskPlanCommentRef, + previewRefs []models.TaskPreviewFeedbackRef, + requirePrimary bool, + expectedState models.TaskSessionState, + claim *messagequeue.QueueAttachmentClaim, + maxPerSession int, +) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, error) { + if err := validateQueuedTaskFeedbackMessage(message, queued, previewRefs); err != nil { + return nil, nil, err + } + messageType, requestsInput, err := normalizeTaskFeedbackMessage(message) + if err != nil || queued.QueuedBy != messagequeue.QueuedByUser { + return nil, nil, fmt.Errorf("queued task feedback requires a user message") + } + originalMessage, originalQueued := *message, *queued + originalCandidateSelected := taskFeedbackCandidateSelected(candidate) + tx, release, err := r.beginPlanCommentTx(ctx, message.TaskID) + if err != nil { + return nil, nil, fmt.Errorf("begin queued task-feedback message creation: %w", err) + } + defer release() + defer func() { + _ = tx.Rollback() + if err != nil { + *message, *queued = originalMessage, originalQueued + if candidate != nil { + candidate.Selected = originalCandidateSelected + } + } + }() + if err = r.guardActivePlanCommentTaskTx(ctx, tx, message.TaskID); err != nil { + return nil, nil, err + } + if err = lockSessionTurnWrites(ctx, tx, r.db.DriverName(), message.TaskSessionID); err != nil { + return nil, nil, err + } + content, err := r.applyTaskFeedbackInitialBrief(ctx, tx, message, candidate) + if err != nil { + return nil, nil, err + } + planResolution, previewResolution, err := r.resolveDirectTaskFeedback( + ctx, tx, message, content, planRefs, previewRefs, requirePrimary, expectedState, + ) + if err != nil { + return nil, nil, err + } + if err = r.persistQueuedTaskFeedbackMessage( + ctx, tx, message, queued, previewResolution, claim, + messageType, requestsInput, maxPerSession, + ); err != nil { + return nil, nil, err + } + planSnapshot, previewSnapshot, err := consumeDirectTaskFeedback( + ctx, tx, r.db, planResolution, previewResolution, + ) + if err != nil { + return nil, nil, err + } + if err = tx.Commit(); err != nil { + return nil, nil, fmt.Errorf("commit queued task-feedback message creation: %w", err) + } + return planSnapshot, previewSnapshot, nil +} + +func (r *Repository) persistDirectTaskFeedbackMessage( + ctx context.Context, + tx *sqlx.Tx, + message *models.Message, + previewResolution *previewfeedbacktx.Resolution, + claim *messagequeue.QueueAttachmentClaim, + messageType string, + requestsInput int, +) error { + message.Content = previewResolution.Content + if err := r.claimDirectMessageAttachmentsTx( + ctx, tx, claim, message.TaskID, message.TaskSessionID, message.ID, + ); err != nil { + return err + } + if err := r.transferPreviewScreenshotsToMessage( + ctx, tx, previewResolution, message.TaskSessionID, message.ID, + ); err != nil { + return err + } + metadataJSON, err := marshalTaskFeedbackMetadata(message.Metadata) + if err != nil { + return fmt.Errorf("serialize task-feedback message metadata: %w", err) + } + normalizedTime := dialect.NormalizedMicrosecond(r.db.DriverName(), "created_at") + if err := r.assignUserMessageBoundary( + ctx, tx, message, r.db.DriverName(), normalizedTime, + ); err != nil { + return err + } + return r.insertMessageRow(ctx, tx, message, requestsInput, messageType, string(metadataJSON)) +} + +func (r *Repository) persistQueuedTaskFeedbackMessage( + ctx context.Context, + tx *sqlx.Tx, + message *models.Message, + queued *messagequeue.QueuedMessage, + previewResolution *previewfeedbacktx.Resolution, + claim *messagequeue.QueueAttachmentClaim, + messageType string, + requestsInput, maxPerSession int, +) error { + if err := r.persistDirectTaskFeedbackMessage( + ctx, tx, message, previewResolution, claim, messageType, requestsInput, + ); err != nil { + return err + } + queued.Content = previewResolution.Content + return messagequeue.InsertTaskOwnedInTransaction(ctx, tx, r.db, queued, maxPerSession) +} + +func normalizeTaskFeedbackMessage(message *models.Message) (string, int, error) { + if message.ID == "" { + message.ID = uuid.New().String() + } + if message.AuthorType == "" { + message.AuthorType = models.MessageAuthorUser + } + if message.AuthorType != models.MessageAuthorUser { + return "", 0, fmt.Errorf("task feedback requires a user message") + } + messageType := string(message.Type) + if messageType == "" { + messageType = string(models.MessageTypeMessage) + } + requestsInput := 0 + if message.RequestsInput { + requestsInput = 1 + } + return messageType, requestsInput, nil +} + +func validateQueuedTaskFeedbackMessage( + message *models.Message, + queued *messagequeue.QueuedMessage, + previewRefs []models.TaskPreviewFeedbackRef, +) error { + if message == nil || queued == nil || queued.ID == "" || len(previewRefs) == 0 { + return fmt.Errorf("preview feedback, user message, and queued message are required") + } + if message.TaskID != queued.TaskID || message.TaskSessionID != queued.SessionID { + return fmt.Errorf("queued task-feedback target does not match user message") + } + return nil +} + +func taskFeedbackCandidateSelected(candidate *admission.InitialTaskBriefCandidate) bool { + return candidate != nil && candidate.Selected +} + +func (r *Repository) applyTaskFeedbackInitialBrief( + ctx context.Context, + tx *sqlx.Tx, + message *models.Message, + candidate *admission.InitialTaskBriefCandidate, +) (string, error) { + content := message.Content + if candidate == nil { + return content, nil + } + if err := r.validateInitialTaskBriefCandidate(ctx, tx, message.TaskID, candidate); err != nil { + return "", err + } + if err := r.selectInitialTaskBriefCandidate( + ctx, tx, message.TaskSessionID, message, candidate, + ); err != nil { + return "", err + } + if candidate.Selected { + return message.Content, nil + } + return content, nil +} + +func (r *Repository) resolveDirectTaskFeedback( + ctx context.Context, + tx *sqlx.Tx, + message *models.Message, + content string, + planRefs []models.TaskPlanCommentRef, + previewRefs []models.TaskPreviewFeedbackRef, + requirePrimary bool, + expectedState models.TaskSessionState, +) (*plancommenttx.Resolution, *previewfeedbacktx.Resolution, error) { + var planResolution *plancommenttx.Resolution + var err error + if len(planRefs) > 0 { + planResolution, err = plancommenttx.ResolveDirect( + ctx, tx, r.db, message.TaskID, message.TaskSessionID, content, + planRefs, requirePrimary, expectedState, + ) + if err != nil { + return nil, nil, err + } + content = planResolution.Content + } else if err := plancommenttx.ValidateTargetSession( + ctx, tx, r.db, message.TaskID, message.TaskSessionID, requirePrimary, expectedState, + ); err != nil { + return nil, nil, err + } + previewResolution, err := previewfeedbacktx.Resolve( + ctx, tx, r.db, message.TaskID, content, previewRefs, + ) + return planResolution, previewResolution, err +} + +func marshalTaskFeedbackMetadata(metadata map[string]interface{}) ([]byte, error) { + value, err := json.Marshal(metadata) + if err == nil && string(value) == jsonNull { + return []byte("{}"), nil + } + return value, err +} + +func consumeDirectTaskFeedback( + ctx context.Context, + tx *sqlx.Tx, + db *sqlx.DB, + planResolution *plancommenttx.Resolution, + previewResolution *previewfeedbacktx.Resolution, +) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, error) { + var planSnapshot *models.TaskPlanCommentSnapshot + var err error + if planResolution != nil { + planSnapshot, err = plancommenttx.Consume(ctx, tx, db, planResolution) + if err != nil { + return nil, nil, err + } + } + previewSnapshot, err := previewfeedbacktx.Consume(ctx, tx, db, previewResolution) + return planSnapshot, previewSnapshot, err +} + +func (r *Repository) transferPreviewScreenshotsToMessage( + ctx context.Context, + tx interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + }, + resolved *previewfeedbacktx.Resolution, + sessionID, messageID string, +) error { + for _, item := range resolved.Items { + if item.Kind != models.TaskPreviewFeedbackScreenshot { + continue + } + result, err := tx.ExecContext(ctx, r.db.Rebind(` + UPDATE task_message_attachments + SET session_id = ?, message_id = ?, updated_at = ? + WHERE id = ? AND task_id = ? AND state = ? + AND session_id = '' AND message_id = '' AND queue_id = '' + `), sessionID, messageID, time.Now().UTC(), item.ScreenshotAttachmentID, + resolved.TaskID, models.AttachmentStateClaimed) + if err != nil { + return fmt.Errorf("transfer preview screenshot to message: %w", err) + } + if changed, _ := result.RowsAffected(); changed != 1 { + return models.ErrAttachmentClaimConflict + } + } + return nil +} diff --git a/apps/backend/internal/task/repository/sqlite/message_preview_feedback_test.go b/apps/backend/internal/task/repository/sqlite/message_preview_feedback_test.go new file mode 100644 index 0000000000..ad3a30175a --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/message_preview_feedback_test.go @@ -0,0 +1,248 @@ +//go:build cgo + +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/previewfeedback" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +type taskFeedbackMessageRepository interface { + CreateMessageWithTaskFeedback( + context.Context, + *models.Message, + []models.TaskPlanCommentRef, + []models.TaskPreviewFeedbackRef, + bool, + models.TaskSessionState, + *messagequeue.QueueAttachmentClaim, + ) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, error) +} + +func TestCreateMessageWithTaskFeedbackConsumesMixedSnapshotsAtomically(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedMessagePlanComment(t, ctx, repo, "mixed-preview") + preview := seedMessagePreviewFeedback(t, ctx, repo, "mixed-preview") + writes, ok := any(repo).(taskFeedbackMessageRepository) + if !ok { + t.Fatal("Repository does not implement atomic task-feedback message creation") + } + message := planCommentMessage("mixed-preview", "message-mixed-preview") + + planSnapshot, previewSnapshot, err := writes.CreateMessageWithTaskFeedback( + ctx, + message, + []models.TaskPlanCommentRef{{ID: "comment-mixed-preview", Version: 1}}, + []models.TaskPreviewFeedbackRef{{ID: preview.ID, Version: 1}}, + true, + "", + nil, + ) + if err != nil { + t.Fatalf("CreateMessageWithTaskFeedback: %v", err) + } + if planSnapshot == nil || len(planSnapshot.Comments) != 0 || + previewSnapshot == nil || previewSnapshot.Revision != 2 || len(previewSnapshot.Items) != 0 { + t.Fatalf("snapshots plan=%#v preview=%#v", planSnapshot, previewSnapshot) + } + for _, want := range []string{ + "### Plan Comments", "stored mixed-preview", "typed content", + "### Web Preview Feedback", "Generated total", "Runtime value", + "containing_element", "node_path", "scroll_y", "viewport_width", + } { + if !strings.Contains(message.Content, want) { + t.Fatalf("message content missing %q:\n%s", want, message.Content) + } + } + pending, err := repo.ListTaskPreviewFeedback(ctx, message.TaskID) + if err != nil || len(pending.Items) != 0 || pending.Revision != 2 { + t.Fatalf("pending preview snapshot=%#v err=%v", pending, err) + } +} + +func TestCreateMessageWithTaskFeedbackStalePreviewRollsBackPlanAndMessage(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedMessagePlanComment(t, ctx, repo, "stale-preview") + preview := seedMessagePreviewFeedback(t, ctx, repo, "stale-preview") + message := planCommentMessage("stale-preview", "message-stale-preview") + + _, previewSnapshot, err := repo.CreateMessageWithTaskFeedback( + ctx, + message, + []models.TaskPlanCommentRef{{ID: "comment-stale-preview", Version: 1}}, + []models.TaskPreviewFeedbackRef{{ID: preview.ID, Version: 9}}, + false, + "", + nil, + ) + if !errors.Is(err, repoerrors.ErrTaskPreviewFeedbackChanged) || + previewSnapshot != nil { + t.Fatalf("stale preview snapshot=%#v err=%v", previewSnapshot, err) + } + if _, err := repo.GetMessageWithPromptIndex(ctx, message.ID); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("message persisted after stale preview: %v", err) + } + planPending, err := repo.ListTaskPlanComments(ctx, message.TaskID) + if err != nil || len(planPending.Comments) != 1 { + t.Fatalf("plan comments changed on rollback: %#v err=%v", planPending, err) + } + previewPending, err := repo.ListTaskPreviewFeedback(ctx, message.TaskID) + if err != nil || len(previewPending.Items) != 1 || previewPending.Revision != 1 { + t.Fatalf("preview feedback changed on rollback: %#v err=%v", previewPending, err) + } +} + +func TestCreateMessageWithTaskFeedbackTransfersScreenshotToAcceptedMessage(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedMessagePlanComment(t, ctx, repo, "screenshot-preview") + seedWorkspace(t, repo, "workspace-screenshot-preview") + if _, err := repo.db.ExecContext(ctx, repo.db.Rebind( + `UPDATE tasks SET workspace_id = ? WHERE id = ?`, + ), "workspace-screenshot-preview", "task-message-comments-screenshot-preview"); err != nil { + t.Fatal(err) + } + attachment := &models.TaskMessageAttachment{ + ID: "attachment-screenshot-preview", OwnerID: "owner-screenshot-preview", + WorkspaceID: "workspace-screenshot-preview", Name: "checkout.png", MimeType: "image/png", + Kind: "image", DeliveryMode: "prompt", SizeBytes: 512, StorageKey: "attachment-screenshot-preview", + State: models.AttachmentStateStaged, ExpiresAt: time.Now().UTC().Add(time.Hour), + } + if err := repo.CreateMessageAttachment(ctx, attachment); err != nil { + t.Fatal(err) + } + item := &models.TaskPreviewFeedback{ + ID: "preview-screenshot-transfer", TaskID: "task-message-comments-screenshot-preview", + Kind: models.TaskPreviewFeedbackScreenshot, Comment: "Form is clipped", + SourceKind: models.TaskPreviewFeedbackBrowser, SourceLabel: "Local app", + PageRoute: "/checkout", CaptureRect: json.RawMessage(`{"x":10,"y":20,"width":320,"height":180}`), + ScreenshotAttachmentID: attachment.ID, + } + if _, err := repo.CreateTaskPreviewFeedback( + ctx, item, attachment.OwnerID, attachment.WorkspaceID, + ); err != nil { + t.Fatal(err) + } + message := planCommentMessage("screenshot-preview", "message-screenshot-preview") + message.Content = "typed content" + _, _, err := repo.CreateMessageWithTaskFeedback( + ctx, message, nil, []models.TaskPreviewFeedbackRef{{ID: item.ID, Version: 1}}, + true, "", &messagequeue.QueueAttachmentClaim{ + OwnerID: attachment.OwnerID, WorkspaceID: attachment.WorkspaceID, IDs: []string{attachment.ID}, + }, + ) + if err != nil { + t.Fatalf("CreateMessageWithTaskFeedback: %v", err) + } + transferred, err := repo.GetMessageAttachment(ctx, attachment.ID) + if err != nil { + t.Fatal(err) + } + if transferred.State != models.AttachmentStateClaimed || + transferred.TaskID != item.TaskID || transferred.SessionID != message.TaskSessionID || + transferred.MessageID != message.ID { + t.Fatalf("transferred attachment = %#v", transferred) + } +} + +func TestCreateMessageWithTaskFeedbackRejectsCombinedScreenshotAndOrdinaryAttachmentLimit(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedMessagePlanComment(t, ctx, repo, "screenshot-limit") + seedWorkspace(t, repo, "workspace-screenshot-limit") + taskID := "task-message-comments-screenshot-limit" + if _, err := repo.db.ExecContext(ctx, repo.db.Rebind( + `UPDATE tasks SET workspace_id = ? WHERE id = ?`, + ), "workspace-screenshot-limit", taskID); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + screenshot := &models.TaskMessageAttachment{ + ID: "attachment-screenshot-limit", OwnerID: "owner-screenshot-limit", + WorkspaceID: "workspace-screenshot-limit", Name: "screen.png", MimeType: "image/png", + Kind: "image", DeliveryMode: "prompt", SizeBytes: previewfeedback.MaxScreenshotBytes, + StorageKey: "attachment-screenshot-limit", State: models.AttachmentStateStaged, + ExpiresAt: now.Add(time.Hour), + } + ordinary := &models.TaskMessageAttachment{ + ID: "attachment-ordinary-limit", OwnerID: screenshot.OwnerID, + WorkspaceID: screenshot.WorkspaceID, Name: "video.bin", MimeType: "application/octet-stream", + Kind: "resource", DeliveryMode: "path", + SizeBytes: models.MaxMessageAttachmentBytes - previewfeedback.MaxScreenshotBytes + 1, + StorageKey: "attachment-ordinary-limit", State: models.AttachmentStateStaged, + ExpiresAt: now.Add(time.Hour), + } + for _, attachment := range []*models.TaskMessageAttachment{screenshot, ordinary} { + if err := repo.CreateMessageAttachment(ctx, attachment); err != nil { + t.Fatal(err) + } + } + item := &models.TaskPreviewFeedback{ + ID: "preview-screenshot-limit", TaskID: taskID, + Kind: models.TaskPreviewFeedbackScreenshot, Comment: "Capture is clipped", + SourceKind: models.TaskPreviewFeedbackBrowser, SourceLabel: "Local app", PageRoute: "/", + CaptureRect: json.RawMessage(`{"x":0,"y":0,"width":100,"height":100}`), + ScreenshotAttachmentID: screenshot.ID, + } + if _, err := repo.CreateTaskPreviewFeedback( + ctx, item, screenshot.OwnerID, screenshot.WorkspaceID, + ); err != nil { + t.Fatal(err) + } + message := planCommentMessage("screenshot-limit", "message-screenshot-limit") + _, _, err := repo.CreateMessageWithTaskFeedback( + ctx, message, nil, []models.TaskPreviewFeedbackRef{{ID: item.ID, Version: 1}}, + true, "", &messagequeue.QueueAttachmentClaim{ + OwnerID: screenshot.OwnerID, WorkspaceID: screenshot.WorkspaceID, + IDs: []string{screenshot.ID, ordinary.ID}, + }, + ) + if !errors.Is(err, models.ErrAttachmentTotalTooLarge) { + t.Fatalf("CreateMessageWithTaskFeedback error = %v", err) + } + if _, err := repo.GetMessageWithPromptIndex(ctx, message.ID); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("message persisted after rejected combined limit: %v", err) + } + pending, err := repo.ListTaskPreviewFeedback(ctx, taskID) + if err != nil || len(pending.Items) != 1 { + t.Fatalf("preview feedback changed after rejected combined limit: %#v err=%v", pending, err) + } +} + +func seedMessagePreviewFeedback( + t *testing.T, + ctx context.Context, + repo *Repository, + suffix string, +) *models.TaskPreviewFeedback { + t.Helper() + item := &models.TaskPreviewFeedback{ + ID: "preview-" + suffix, TaskID: "task-message-comments-" + suffix, + Kind: models.TaskPreviewFeedbackText, Comment: "Generated total", + SourceKind: models.TaskPreviewFeedbackBrowser, SourceLabel: "Local app", + PageRoute: "/totals", PageTitle: "Totals", SelectedText: "Runtime value", + TextAnchor: json.RawMessage(`{ + "start":{"selector":"#total","node_path":[0],"offset":0}, + "end":{"selector":"#total","node_path":[0],"offset":13}, + "rects":[{"x":10,"y":20,"width":90,"height":20}], + "scroll_y":300,"viewport_width":1024,"viewport_height":768, + "containing_element":{"tag":"span","outer_html":"Runtime value"} + }`), + } + if _, err := repo.CreateTaskPreviewFeedback(ctx, item, "", ""); err != nil { + t.Fatal(err) + } + return item +} diff --git a/apps/backend/internal/task/repository/sqlite/preview_feedback.go b/apps/backend/internal/task/repository/sqlite/preview_feedback.go new file mode 100644 index 0000000000..a0dc321eb1 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/preview_feedback.go @@ -0,0 +1,530 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/plancomments" + "github.com/kandev/kandev/internal/task/previewfeedback" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +const previewFeedbackSelectCols = ` + id, task_id, kind, comment, source_kind, + COALESCE(source_session_id, ''), source_label, COALESCE(source_path, ''), + page_route, page_title, COALESCE(selected_text, ''), + COALESCE(text_anchor_json, ''), COALESCE(element_snapshot_json, ''), + COALESCE(capture_rect_json, ''), COALESCE(screenshot_attachment_id, ''), + version, created_at, updated_at` + +// ListTaskPreviewFeedback returns one authoritative task-owned snapshot. +func (r *Repository) ListTaskPreviewFeedback( + ctx context.Context, + taskID string, +) (*models.TaskPreviewFeedbackSnapshot, error) { + tx, release, err := r.beginPlanCommentTx(ctx, taskID) + if err != nil { + return nil, err + } + defer release() + defer func() { _ = tx.Rollback() }() + if err := r.ensurePreviewFeedbackCollection(ctx, tx, taskID); err != nil { + return nil, err + } + return r.commitPreviewFeedbackRead(ctx, tx, taskID) +} + +// CreateTaskPreviewFeedback inserts a caller-identified capture and claims its optional screenshot. +func (r *Repository) CreateTaskPreviewFeedback( + ctx context.Context, + item *models.TaskPreviewFeedback, + ownerID, workspaceID string, +) (*models.TaskPreviewFeedbackSnapshot, error) { + if err := previewfeedback.ValidateItem(item); err != nil { + return nil, err + } + fingerprint, err := previewFeedbackFingerprint(item) + if err != nil { + return nil, err + } + tx, release, err := r.beginPlanCommentTx(ctx, item.TaskID) + if err != nil { + return nil, err + } + defer release() + defer func() { _ = tx.Rollback() }() + if err := r.ensurePreviewFeedbackCollection(ctx, tx, item.TaskID); err != nil { + return nil, err + } + storedFingerprint, err := r.previewFeedbackAdmission(ctx, tx, item.ID) + if err != nil { + return nil, err + } + if storedFingerprint != "" { + if storedFingerprint == fingerprint { + return r.commitPreviewFeedbackRead(ctx, tx, item.TaskID) + } + return r.commitPreviewFeedbackConflict(ctx, tx, item.TaskID) + } + + now := time.Now().UTC() + if err := r.claimPreviewScreenshot(ctx, tx, item, ownerID, workspaceID, now); err != nil { + return nil, err + } + item.Version = 1 + item.CreatedAt = now + item.UpdatedAt = now + if _, err := tx.ExecContext(ctx, r.db.Rebind(` + INSERT INTO task_preview_feedback ( + id, task_id, kind, comment, source_kind, source_session_id, source_label, + source_path, page_route, page_title, selected_text, text_anchor_json, + element_snapshot_json, capture_rect_json, screenshot_attachment_id, + version, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), item.ID, item.TaskID, item.Kind, item.Comment, item.SourceKind, + nullableString(item.SourceSessionID), item.SourceLabel, nullableString(item.SourcePath), + item.PageRoute, item.PageTitle, nullableString(item.SelectedText), + previewNullableJSON(item.TextAnchor), previewNullableJSON(item.ElementSnapshot), + previewNullableJSON(item.CaptureRect), nullableString(item.ScreenshotAttachmentID), + item.Version, item.CreatedAt, item.UpdatedAt); err != nil { + return nil, fmt.Errorf("insert task preview feedback: %w", err) + } + if _, err := tx.ExecContext(ctx, r.db.Rebind(` + INSERT INTO task_preview_feedback_admissions + (id, task_id, request_fingerprint, created_at) VALUES (?, ?, ?, ?) + `), item.ID, item.TaskID, fingerprint, now); err != nil { + return nil, fmt.Errorf("insert task preview feedback admission: %w", err) + } + if err := r.validatePreviewFeedbackCollection(ctx, tx, item.TaskID); err != nil { + return nil, err + } + return r.incrementAndCommitPreviewFeedback(ctx, tx, item.TaskID) +} + +// UpdateTaskPreviewFeedback edits only the user comment; captured page evidence stays immutable. +func (r *Repository) UpdateTaskPreviewFeedback( + ctx context.Context, + taskID, itemID, comment string, + expectedVersion int64, +) (*models.TaskPreviewFeedbackSnapshot, error) { + if err := previewfeedback.ValidateComment(comment); err != nil { + return nil, err + } + tx, release, err := r.beginPlanCommentTx(ctx, taskID) + if err != nil { + return nil, err + } + defer release() + defer func() { _ = tx.Rollback() }() + if err := r.ensurePreviewFeedbackCollection(ctx, tx, taskID); err != nil { + return nil, err + } + result, err := tx.ExecContext(ctx, r.db.Rebind(` + UPDATE task_preview_feedback + SET comment = ?, version = version + 1, updated_at = ? + WHERE id = ? AND task_id = ? AND version = ? + `), comment, time.Now().UTC(), itemID, taskID, expectedVersion) + if err != nil { + return nil, fmt.Errorf("update task preview feedback: %w", err) + } + if changed, _ := result.RowsAffected(); changed != 1 { + return r.commitPreviewFeedbackConflict(ctx, tx, taskID) + } + if err := r.validatePreviewFeedbackCollection(ctx, tx, taskID); err != nil { + return nil, err + } + return r.incrementAndCommitPreviewFeedback(ctx, tx, taskID) +} + +// DeleteTaskPreviewFeedback removes one exact item and expires its task-owned screenshot claim. +func (r *Repository) DeleteTaskPreviewFeedback( + ctx context.Context, + taskID, itemID string, + expectedVersion int64, +) (*models.TaskPreviewFeedbackSnapshot, []*models.TaskMessageAttachment, error) { + tx, release, err := r.beginPlanCommentTx(ctx, taskID) + if err != nil { + return nil, nil, err + } + defer release() + defer func() { _ = tx.Rollback() }() + if err := r.ensurePreviewFeedbackCollection(ctx, tx, taskID); err != nil { + return nil, nil, err + } + attachment, err := r.previewScreenshotForItem(ctx, tx, taskID, itemID, expectedVersion) + if err != nil { + return nil, nil, err + } + result, err := tx.ExecContext(ctx, r.db.Rebind(` + DELETE FROM task_preview_feedback WHERE id = ? AND task_id = ? AND version = ? + `), itemID, taskID, expectedVersion) + if err != nil { + return nil, nil, fmt.Errorf("delete task preview feedback: %w", err) + } + if changed, _ := result.RowsAffected(); changed != 1 { + snapshot, conflictErr := r.commitPreviewFeedbackConflict(ctx, tx, taskID) + return snapshot, nil, conflictErr + } + released, err := r.expirePreviewScreenshots(ctx, tx, attachment) + if err != nil { + return nil, nil, err + } + snapshot, err := r.incrementAndCommitPreviewFeedback(ctx, tx, taskID) + return snapshot, released, err +} + +// ClearTaskPreviewFeedback removes the exact rendered collection revision. +func (r *Repository) ClearTaskPreviewFeedback( + ctx context.Context, + taskID string, + expectedRevision int64, +) (*models.TaskPreviewFeedbackSnapshot, []*models.TaskMessageAttachment, error) { + tx, release, err := r.beginPlanCommentTx(ctx, taskID) + if err != nil { + return nil, nil, err + } + defer release() + defer func() { _ = tx.Rollback() }() + if err := r.ensurePreviewFeedbackCollection(ctx, tx, taskID); err != nil { + return nil, nil, err + } + var current int64 + if err := tx.GetContext(ctx, ¤t, r.db.Rebind(` + SELECT revision FROM task_preview_feedback_collections WHERE task_id = ? + `), taskID); err != nil { + return nil, nil, fmt.Errorf("read task preview feedback revision: %w", err) + } + if current != expectedRevision { + snapshot, conflictErr := r.commitPreviewFeedbackConflict(ctx, tx, taskID) + return snapshot, nil, conflictErr + } + attachments, err := r.listPreviewScreenshots(ctx, tx, taskID) + if err != nil { + return nil, nil, err + } + if _, err := tx.ExecContext(ctx, r.db.Rebind(` + DELETE FROM task_preview_feedback WHERE task_id = ? + `), taskID); err != nil { + return nil, nil, fmt.Errorf("clear task preview feedback: %w", err) + } + released, err := r.expirePreviewScreenshots(ctx, tx, attachments...) + if err != nil { + return nil, nil, err + } + snapshot, err := r.incrementAndCommitPreviewFeedback(ctx, tx, taskID) + return snapshot, released, err +} + +func (r *Repository) ensurePreviewFeedbackCollection(ctx context.Context, tx *sqlx.Tx, taskID string) error { + now := time.Now().UTC() + _, err := tx.ExecContext(ctx, r.db.Rebind(` + INSERT INTO task_preview_feedback_collections (task_id, revision, created_at, updated_at) + VALUES (?, 0, ?, ?) ON CONFLICT (task_id) DO NOTHING + `), taskID, now, now) + if err != nil { + return fmt.Errorf("ensure task preview feedback collection: %w", err) + } + return nil +} + +func (r *Repository) previewFeedbackAdmission(ctx context.Context, tx *sqlx.Tx, itemID string) (string, error) { + var fingerprint string + err := tx.GetContext(ctx, &fingerprint, r.db.Rebind(` + SELECT request_fingerprint FROM task_preview_feedback_admissions WHERE id = ? + `), itemID) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("read task preview feedback admission: %w", err) + } + return fingerprint, nil +} + +func previewFeedbackFingerprint(item *models.TaskPreviewFeedback) (string, error) { + return plancomments.Fingerprint(struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Kind models.TaskPreviewFeedbackKind `json:"kind"` + Comment string `json:"comment"` + SourceKind models.TaskPreviewFeedbackSourceKind `json:"source_kind"` + SourceSessionID string `json:"source_session_id"` + SourceLabel string `json:"source_label"` + SourcePath string `json:"source_path"` + PageRoute string `json:"page_route"` + PageTitle string `json:"page_title"` + SelectedText string `json:"selected_text"` + TextAnchor json.RawMessage `json:"text_anchor"` + ElementSnapshot json.RawMessage `json:"element_snapshot"` + CaptureRect json.RawMessage `json:"capture_rect"` + ScreenshotAttachmentID string `json:"screenshot_attachment_id"` + }{ + item.ID, item.TaskID, item.Kind, item.Comment, item.SourceKind, + item.SourceSessionID, item.SourceLabel, item.SourcePath, item.PageRoute, + item.PageTitle, item.SelectedText, item.TextAnchor, item.ElementSnapshot, + item.CaptureRect, item.ScreenshotAttachmentID, + }) +} + +func previewNullableJSON(value json.RawMessage) interface{} { + if len(value) == 0 { + return nil + } + return string(value) +} + +func (r *Repository) claimPreviewScreenshot( + ctx context.Context, + tx *sqlx.Tx, + item *models.TaskPreviewFeedback, + ownerID, workspaceID string, + now time.Time, +) error { + if item.ScreenshotAttachmentID == "" { + return nil + } + result, err := tx.ExecContext(ctx, r.db.Rebind(` + UPDATE task_message_attachments + SET task_id = ?, session_id = '', state = ?, updated_at = ? + WHERE id = ? AND owner_id = ? AND workspace_id = ? AND state = ? + AND mime_type = 'image/png' AND kind = 'image' AND delivery_mode = 'prompt' + AND size_bytes > 0 AND size_bytes <= ? + `), item.TaskID, models.AttachmentStateClaimed, now, item.ScreenshotAttachmentID, + ownerID, workspaceID, models.AttachmentStateStaged, previewfeedback.MaxScreenshotBytes) + if err != nil { + return fmt.Errorf("claim preview screenshot: %w", err) + } + if changed, _ := result.RowsAffected(); changed != 1 { + return models.ErrAttachmentClaimConflict + } + return nil +} + +func (r *Repository) previewScreenshotForItem( + ctx context.Context, + tx *sqlx.Tx, + taskID, itemID string, + expectedVersion int64, +) (*models.TaskMessageAttachment, error) { + var attachmentID sql.NullString + err := tx.GetContext(ctx, &attachmentID, r.db.Rebind(` + SELECT screenshot_attachment_id FROM task_preview_feedback + WHERE id = ? AND task_id = ? AND version = ? + `), itemID, taskID, expectedVersion) + if errors.Is(err, sql.ErrNoRows) || !attachmentID.Valid { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read preview screenshot reference: %w", err) + } + return r.getAttachmentInTx(ctx, tx, attachmentID.String) +} + +func (r *Repository) listPreviewScreenshots( + ctx context.Context, + tx *sqlx.Tx, + taskID string, +) ([]*models.TaskMessageAttachment, error) { + rows, err := tx.QueryxContext(ctx, r.db.Rebind(` + SELECT a.* FROM task_message_attachments a + JOIN task_preview_feedback f ON f.screenshot_attachment_id = a.id + WHERE f.task_id = ? + `), taskID) + if err != nil { + return nil, fmt.Errorf("list preview screenshots: %w", err) + } + defer func() { _ = rows.Close() }() + var attachments []*models.TaskMessageAttachment + for rows.Next() { + attachment := &models.TaskMessageAttachment{} + if err := rows.StructScan(attachment); err != nil { + return nil, fmt.Errorf("scan preview screenshot: %w", err) + } + attachments = append(attachments, attachment) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate preview screenshots: %w", err) + } + return attachments, nil +} + +func (r *Repository) getAttachmentInTx( + ctx context.Context, + tx *sqlx.Tx, + id string, +) (*models.TaskMessageAttachment, error) { + attachment := &models.TaskMessageAttachment{} + err := tx.GetContext(ctx, attachment, r.db.Rebind(` + SELECT * FROM task_message_attachments WHERE id = ? + `), id) + if err != nil { + return nil, fmt.Errorf("read preview screenshot: %w", err) + } + return attachment, nil +} + +func (r *Repository) expirePreviewScreenshots( + ctx context.Context, + tx *sqlx.Tx, + attachments ...*models.TaskMessageAttachment, +) ([]*models.TaskMessageAttachment, error) { + now := time.Now().UTC() + var released []*models.TaskMessageAttachment + for _, attachment := range attachments { + if attachment == nil { + continue + } + if _, err := tx.ExecContext(ctx, r.db.Rebind(` + UPDATE task_message_attachments SET state = ?, expires_at = ?, updated_at = ? + WHERE id = ? AND task_id = ? AND state = ? + `), models.AttachmentStateExpired, now, now, attachment.ID, + attachment.TaskID, models.AttachmentStateClaimed); err != nil { + return nil, fmt.Errorf("expire preview screenshot: %w", err) + } + attachment.State = models.AttachmentStateExpired + attachment.ExpiresAt = now + attachment.UpdatedAt = now + released = append(released, attachment) + } + return released, nil +} + +func (r *Repository) incrementAndCommitPreviewFeedback( + ctx context.Context, + tx *sqlx.Tx, + taskID string, +) (*models.TaskPreviewFeedbackSnapshot, error) { + now := time.Now().UTC() + result, err := tx.ExecContext(ctx, r.db.Rebind(` + UPDATE task_preview_feedback_collections + SET revision = revision + 1, updated_at = ? WHERE task_id = ? + `), now, taskID) + if err != nil { + return nil, fmt.Errorf("increment task preview feedback revision: %w", err) + } + if changed, _ := result.RowsAffected(); changed != 1 { + return nil, repoerrors.ErrTaskNotFound + } + return r.commitPreviewFeedbackRead(ctx, tx, taskID) +} + +func (r *Repository) validatePreviewFeedbackCollection( + ctx context.Context, + q sqlx.QueryerContext, + taskID string, +) error { + snapshot, err := r.readPreviewFeedbackSnapshot(ctx, q, taskID) + if err != nil { + return err + } + return previewfeedback.ValidateCollection(snapshot.Items) +} + +func (r *Repository) commitPreviewFeedbackConflict( + ctx context.Context, + tx *sqlx.Tx, + taskID string, +) (*models.TaskPreviewFeedbackSnapshot, error) { + snapshot, err := r.commitPreviewFeedbackRead(ctx, tx, taskID) + if err != nil { + return nil, err + } + return snapshot, repoerrors.ErrTaskPreviewFeedbackChanged +} + +func (r *Repository) commitPreviewFeedbackRead( + ctx context.Context, + tx *sqlx.Tx, + taskID string, +) (*models.TaskPreviewFeedbackSnapshot, error) { + snapshot, err := r.readPreviewFeedbackSnapshot(ctx, tx, taskID) + if err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit task preview feedback transaction: %w", err) + } + return snapshot, nil +} + +func (r *Repository) readPreviewFeedbackSnapshot( + ctx context.Context, + q sqlx.QueryerContext, + taskID string, +) (*models.TaskPreviewFeedbackSnapshot, error) { + snapshot := &models.TaskPreviewFeedbackSnapshot{TaskID: taskID, Items: make([]*models.TaskPreviewFeedback, 0)} + if err := sqlx.GetContext(ctx, q, &snapshot.Revision, r.db.Rebind(` + SELECT revision FROM task_preview_feedback_collections WHERE task_id = ? + `), taskID); err != nil { + return nil, fmt.Errorf("read task preview feedback collection: %w", err) + } + rows, err := q.QueryContext(ctx, r.db.Rebind(` + SELECT `+previewFeedbackSelectCols+` + FROM task_preview_feedback WHERE task_id = ? ORDER BY created_at, id + `), taskID) + if err != nil { + return nil, fmt.Errorf("list task preview feedback: %w", err) + } + items := make([]*models.TaskPreviewFeedback, 0) + for rows.Next() { + item := &models.TaskPreviewFeedback{} + var textAnchor, elementSnapshot, captureRect string + if err := rows.Scan( + &item.ID, &item.TaskID, &item.Kind, &item.Comment, &item.SourceKind, + &item.SourceSessionID, &item.SourceLabel, &item.SourcePath, + &item.PageRoute, &item.PageTitle, &item.SelectedText, + &textAnchor, &elementSnapshot, &captureRect, &item.ScreenshotAttachmentID, + &item.Version, &item.CreatedAt, &item.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("scan task preview feedback: %w", err) + } + item.TextAnchor = previewRawJSON(textAnchor) + item.ElementSnapshot = previewRawJSON(elementSnapshot) + item.CaptureRect = previewRawJSON(captureRect) + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate task preview feedback: %w", err) + } + if err := rows.Close(); err != nil { + return nil, fmt.Errorf("close task preview feedback rows: %w", err) + } + for _, item := range items { + if item.ScreenshotAttachmentID == "" { + continue + } + item.ScreenshotAttachment, err = r.getAttachmentQuery(ctx, q, item.ScreenshotAttachmentID) + if err != nil { + return nil, err + } + } + snapshot.Items = items + return snapshot, nil +} + +func (r *Repository) getAttachmentQuery( + ctx context.Context, + q sqlx.QueryerContext, + id string, +) (*models.TaskMessageAttachment, error) { + attachment := &models.TaskMessageAttachment{} + if err := sqlx.GetContext(ctx, q, attachment, r.db.Rebind(` + SELECT * FROM task_message_attachments WHERE id = ? + `), id); err != nil { + return nil, fmt.Errorf("read task preview screenshot descriptor: %w", err) + } + return attachment, nil +} + +func previewRawJSON(value string) json.RawMessage { + if value == "" { + return nil + } + return json.RawMessage(value) +} diff --git a/apps/backend/internal/task/repository/sqlite/preview_feedback_postgres_test.go b/apps/backend/internal/task/repository/sqlite/preview_feedback_postgres_test.go new file mode 100644 index 0000000000..1d1f7efcec --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/preview_feedback_postgres_test.go @@ -0,0 +1,58 @@ +package sqlite + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" +) + +func TestPostgresPreviewFeedbackLoadsScreenshotAttachmentAfterRowsClose(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + workspaceID := "workspace-preview-feedback-pg" + taskID := "task-preview-feedback-pg" + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: workspaceID, Name: workspaceID}); err != nil { + t.Fatalf("seed workspace: %v", err) + } + if err := repo.CreateTask(ctx, &models.Task{ID: taskID, WorkspaceID: workspaceID, Title: taskID}); err != nil { + t.Fatalf("seed task: %v", err) + } + attachment := &models.TaskMessageAttachment{ + ID: "attachment-preview-feedback-pg", OwnerID: "owner-preview-feedback-pg", + WorkspaceID: workspaceID, Name: "capture.png", MimeType: "image/png", Kind: "image", + DeliveryMode: "prompt", SizeBytes: 128, StorageKey: "attachment-preview-feedback-pg", + State: models.AttachmentStateStaged, ExpiresAt: time.Now().UTC().Add(time.Hour), + } + if err := repo.CreateMessageAttachment(ctx, attachment); err != nil { + t.Fatalf("create attachment: %v", err) + } + item := &models.TaskPreviewFeedback{ + ID: "feedback-preview-feedback-pg", TaskID: taskID, + Kind: models.TaskPreviewFeedbackScreenshot, Comment: "The screenshot is useful", + SourceKind: models.TaskPreviewFeedbackBrowser, SourceLabel: "Local app", + PageRoute: "/checkout", PageTitle: "Checkout", + CaptureRect: json.RawMessage(`{"x":1,"y":2,"width":320,"height":180}`), + ScreenshotAttachmentID: attachment.ID, + } + if _, err := repo.CreateTaskPreviewFeedback(ctx, item, attachment.OwnerID, workspaceID); err != nil { + t.Fatalf("create preview feedback: %v", err) + } + snapshot, err := repo.ListTaskPreviewFeedback(ctx, taskID) + if err != nil { + t.Fatalf("list preview feedback: %v", err) + } + if len(snapshot.Items) != 1 || snapshot.Items[0].ScreenshotAttachment == nil { + t.Fatalf("snapshot = %#v, want screenshot attachment descriptor", snapshot) + } + if snapshot.Items[0].ScreenshotAttachment.ID != attachment.ID { + t.Fatalf("screenshot attachment ID = %q, want %q", snapshot.Items[0].ScreenshotAttachment.ID, attachment.ID) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/preview_feedback_test.go b/apps/backend/internal/task/repository/sqlite/preview_feedback_test.go new file mode 100644 index 0000000000..7996966950 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/preview_feedback_test.go @@ -0,0 +1,214 @@ +//go:build cgo + +package sqlite + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +type previewFeedbackRepositoryContract interface { + ListTaskPreviewFeedback(context.Context, string) (*models.TaskPreviewFeedbackSnapshot, error) + CreateTaskPreviewFeedback(context.Context, *models.TaskPreviewFeedback, string, string) (*models.TaskPreviewFeedbackSnapshot, error) + UpdateTaskPreviewFeedback(context.Context, string, string, string, int64) (*models.TaskPreviewFeedbackSnapshot, error) + DeleteTaskPreviewFeedback(context.Context, string, string, int64) (*models.TaskPreviewFeedbackSnapshot, []*models.TaskMessageAttachment, error) + ClearTaskPreviewFeedback(context.Context, string, int64) (*models.TaskPreviewFeedbackSnapshot, []*models.TaskMessageAttachment, error) +} + +func TestPreviewFeedbackScreenshotClaimAndClearAreAtomic(t *testing.T) { + repo := newRepoForEntityTests(t) + contract := requirePreviewFeedbackRepository(t, repo) + ctx := context.Background() + seedWorkspace(t, repo, "workspace-preview-feedback") + seedAttachmentTask(t, repo, "task-preview-screenshot", "workspace-preview-feedback") + attachment := &models.TaskMessageAttachment{ + ID: "preview-screenshot", OwnerID: "owner-preview", WorkspaceID: "workspace-preview-feedback", + Name: "capture.png", MimeType: "image/png", Kind: "image", DeliveryMode: "prompt", + SizeBytes: 128, StorageKey: "preview-screenshot", State: models.AttachmentStateStaged, + ExpiresAt: time.Now().UTC().Add(time.Hour), + } + if err := repo.CreateMessageAttachment(ctx, attachment); err != nil { + t.Fatal(err) + } + item := &models.TaskPreviewFeedback{ + ID: "screenshot-feedback", TaskID: "task-preview-screenshot", + Kind: models.TaskPreviewFeedbackScreenshot, Comment: "The form clips here", + SourceKind: models.TaskPreviewFeedbackBrowser, SourceLabel: "Checkout", + PageRoute: "/checkout", PageTitle: "Checkout", + CaptureRect: json.RawMessage(`{"x":1,"y":2,"width":320,"height":180}`), + ScreenshotAttachmentID: attachment.ID, + } + created, err := contract.CreateTaskPreviewFeedback( + ctx, item, attachment.OwnerID, attachment.WorkspaceID, + ) + if err != nil || created.Revision != 1 || created.Items[0].ScreenshotAttachment == nil { + t.Fatalf("create screenshot feedback = %#v, %v", created, err) + } + claimed, err := repo.GetMessageAttachment(ctx, attachment.ID) + if err != nil || claimed.State != models.AttachmentStateClaimed || + claimed.TaskID != item.TaskID || claimed.SessionID != "" { + t.Fatalf("claimed attachment = %#v, %v", claimed, err) + } + + stale, released, err := contract.ClearTaskPreviewFeedback(ctx, item.TaskID, 0) + if !errors.Is(err, repoerrors.ErrTaskPreviewFeedbackChanged) || stale.Revision != 1 || len(released) != 0 { + t.Fatalf("stale clear = %#v, released=%#v, err=%v", stale, released, err) + } + cleared, released, err := contract.ClearTaskPreviewFeedback(ctx, item.TaskID, 1) + if err != nil || cleared.Revision != 2 || len(cleared.Items) != 0 || len(released) != 1 { + t.Fatalf("clear = %#v, released=%#v, err=%v", cleared, released, err) + } + if released[0].State != models.AttachmentStateExpired { + t.Fatalf("released attachment state = %q", released[0].State) + } +} + +func TestPreviewFeedbackScreenshotClaimRequiresPromptDelivery(t *testing.T) { + repo := newRepoForEntityTests(t) + contract := requirePreviewFeedbackRepository(t, repo) + ctx := context.Background() + seedWorkspace(t, repo, "workspace-preview-feedback-mode") + seedAttachmentTask(t, repo, "task-preview-screenshot-mode", "workspace-preview-feedback-mode") + attachment := &models.TaskMessageAttachment{ + ID: "preview-screenshot-path", OwnerID: "owner-preview", WorkspaceID: "workspace-preview-feedback-mode", + Name: "capture.png", MimeType: "image/png", Kind: "image", DeliveryMode: "path", + SizeBytes: 128, StorageKey: "preview-screenshot-path", State: models.AttachmentStateStaged, + ExpiresAt: time.Now().UTC().Add(time.Hour), + } + if err := repo.CreateMessageAttachment(ctx, attachment); err != nil { + t.Fatal(err) + } + item := &models.TaskPreviewFeedback{ + ID: "screenshot-feedback-path", TaskID: "task-preview-screenshot-mode", + Kind: models.TaskPreviewFeedbackScreenshot, Comment: "The form clips here", + SourceKind: models.TaskPreviewFeedbackBrowser, SourceLabel: "Checkout", + PageRoute: "/checkout", PageTitle: "Checkout", + CaptureRect: json.RawMessage(`{"x":1,"y":2,"width":320,"height":180}`), + ScreenshotAttachmentID: attachment.ID, + } + + if _, err := contract.CreateTaskPreviewFeedback( + ctx, item, attachment.OwnerID, attachment.WorkspaceID, + ); !errors.Is(err, models.ErrAttachmentClaimConflict) { + t.Fatalf("CreateTaskPreviewFeedback error = %v, want attachment conflict", err) + } + stored, err := repo.GetMessageAttachment(ctx, attachment.ID) + if err != nil || stored.State != models.AttachmentStateStaged { + t.Fatalf("attachment after rejected claim = %#v, %v", stored, err) + } +} + +func requirePreviewFeedbackRepository(t *testing.T, repo *Repository) previewFeedbackRepositoryContract { + t.Helper() + contract, ok := any(repo).(previewFeedbackRepositoryContract) + if !ok { + t.Fatal("Repository does not implement task preview feedback persistence") + } + return contract +} + +func TestPreviewFeedbackSchemaExistsOnFreshDatabase(t *testing.T) { + repo := newRepoForEntityTests(t) + + for _, table := range []string{ + "task_preview_feedback_collections", + "task_preview_feedback", + "task_preview_feedback_admissions", + } { + var count int + if err := repo.db.Get(&count, ` + SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name = ? + `, table); err != nil { + t.Fatalf("inspect %s: %v", table, err) + } + if count != 1 { + t.Fatalf("%s table count = %d, want 1", table, count) + } + } +} + +func TestPreviewFeedbackRepositoryCRUDUsesOptimisticVersions(t *testing.T) { + repo := newRepoForEntityTests(t) + contract := requirePreviewFeedbackRepository(t, repo) + ctx := context.Background() + seedTaskForDocs(t, repo, "task-preview-feedback") + + initial, err := contract.ListTaskPreviewFeedback(ctx, "task-preview-feedback") + if err != nil { + t.Fatalf("ListTaskPreviewFeedback: %v", err) + } + if initial.Revision != 0 || len(initial.Items) != 0 { + t.Fatalf("initial snapshot = %#v", initial) + } + + item := &models.TaskPreviewFeedback{ + ID: "feedback-1", TaskID: "task-preview-feedback", + Kind: models.TaskPreviewFeedbackText, Comment: "Increase contrast", + SourceKind: models.TaskPreviewFeedbackBrowser, SourceLabel: "Local app", + PageRoute: "/products?plan=pro", PageTitle: "Products", + SelectedText: "Choose a plan", TextAnchor: json.RawMessage(`{"start":{"path":[0],"offset":0}}`), + CaptureRect: json.RawMessage(`{"x":20,"y":40,"width":120,"height":24}`), + } + created, err := contract.CreateTaskPreviewFeedback(ctx, item, "", "") + if err != nil { + t.Fatalf("CreateTaskPreviewFeedback: %v", err) + } + if created.Revision != 1 || len(created.Items) != 1 || created.Items[0].Version != 1 { + t.Fatalf("created snapshot = %#v", created) + } + + replayed, err := contract.CreateTaskPreviewFeedback(ctx, item, "", "") + if err != nil || replayed.Revision != 1 { + t.Fatalf("idempotent replay = %#v, %v", replayed, err) + } + + updated, err := contract.UpdateTaskPreviewFeedback(ctx, item.TaskID, item.ID, "Use the stronger color", 1) + if err != nil { + t.Fatalf("UpdateTaskPreviewFeedback: %v", err) + } + if updated.Revision != 2 || updated.Items[0].Version != 2 || updated.Items[0].Comment != "Use the stronger color" { + t.Fatalf("updated snapshot = %#v", updated) + } + + stale, _, err := contract.DeleteTaskPreviewFeedback(ctx, item.TaskID, item.ID, 1) + if err == nil || stale.Revision != 2 || len(stale.Items) != 1 { + t.Fatalf("stale delete = %#v, %v", stale, err) + } + + deleted, released, err := contract.DeleteTaskPreviewFeedback(ctx, item.TaskID, item.ID, 2) + if err != nil || deleted.Revision != 3 || len(deleted.Items) != 0 || len(released) != 0 { + t.Fatalf("deleted snapshot = %#v, released=%#v, err=%v", deleted, released, err) + } +} + +func TestPreviewFeedbackRepositoryRejectsInvalidCapture(t *testing.T) { + repo := newRepoForEntityTests(t) + contract := requirePreviewFeedbackRepository(t, repo) + seedTaskForDocs(t, repo, "task-invalid-preview-feedback") + + item := &models.TaskPreviewFeedback{ + ID: "invalid-feedback", TaskID: "task-invalid-preview-feedback", + Kind: models.TaskPreviewFeedbackText, Comment: " ", + SourceKind: models.TaskPreviewFeedbackBrowser, SourceLabel: "Local app", + PageRoute: "/", SelectedText: "Selected", + TextAnchor: json.RawMessage(`{"start":{"path":[0],"offset":0}}`), + } + if snapshot, err := contract.CreateTaskPreviewFeedback(context.Background(), item, "", ""); err == nil { + t.Fatalf("invalid capture was stored: %#v", snapshot) + } + + var count int + if err := repo.db.Get(&count, `SELECT COUNT(*) FROM task_preview_feedback`); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("stored invalid preview feedback rows = %d, want 0", count) + } +} diff --git a/apps/backend/internal/task/service/attachment_service.go b/apps/backend/internal/task/service/attachment_service.go index fed2f1ce6d..15e2483180 100644 --- a/apps/backend/internal/task/service/attachment_service.go +++ b/apps/backend/internal/task/service/attachment_service.go @@ -1,11 +1,11 @@ package service import ( + "bytes" "context" "errors" "fmt" - "github.com/google/uuid" - "go.uber.org/zap" + "image/png" "io" "os" "path/filepath" @@ -13,10 +13,13 @@ import ( "sync" "time" + "github.com/google/uuid" "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/previewfeedback" "github.com/kandev/kandev/internal/task/repository" + "go.uber.org/zap" ) const ( @@ -27,6 +30,7 @@ const ( MaxAttachmentCount = models.MaxMessageAttachmentCount attachmentDeliveryModePrompt = "prompt" attachmentDeliveryModePath = "path" + previewScreenshotMimeType = "image/png" ) var ( @@ -265,6 +269,90 @@ func (s *AttachmentService) Open(ctx context.Context, ownerID, id string) (*mode return attachment, file, nil } +// ValidatePreviewScreenshot verifies task-feedback screenshot bytes before the +// repository changes their staged claim. Already claimed task-feedback bytes +// are accepted so a lost create response can be retried idempotently. +func (s *AttachmentService) ValidatePreviewScreenshot( + ctx context.Context, + ownerID, workspaceID, taskID, id string, +) error { + attachment, file, err := s.Open(ctx, ownerID, id) + if err != nil { + return err + } + defer func() { _ = file.Close() }() + + if err := validatePreviewScreenshotMetadata(attachment, ownerID, workspaceID, taskID); err != nil { + return err + } + return validatePreviewScreenshotFile(file, attachment.SizeBytes) +} + +func validatePreviewScreenshotMetadata( + attachment *models.TaskMessageAttachment, + ownerID, workspaceID, taskID string, +) error { + if !validPreviewScreenshotClaim(attachment, ownerID, workspaceID, taskID) { + return ErrAttachmentClaimConflict + } + if attachment.MimeType != previewScreenshotMimeType || attachment.Kind != "image" || + attachment.DeliveryMode != attachmentDeliveryModePrompt { + return fmt.Errorf("%w: screenshot attachment metadata", previewfeedback.ErrCaptureInvalid) + } + if attachment.SizeBytes <= 0 || attachment.SizeBytes > previewfeedback.MaxScreenshotBytes { + return previewfeedback.ErrCaptureTooLarge + } + return nil +} + +func validatePreviewScreenshotFile(file *os.File, expectedSize int64) error { + stat, err := file.Stat() + if err != nil { + return fmt.Errorf("inspect preview screenshot: %w", err) + } + if stat.Size() != expectedSize || stat.Size() > previewfeedback.MaxScreenshotBytes { + return fmt.Errorf("%w: screenshot byte count", previewfeedback.ErrCaptureInvalid) + } + + var signature [8]byte + if _, err := io.ReadFull(file, signature[:]); err != nil || + !bytes.Equal(signature[:], []byte("\x89PNG\r\n\x1a\n")) { + return fmt.Errorf("%w: invalid PNG signature", previewfeedback.ErrCaptureInvalid) + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("seek preview screenshot: %w", err) + } + config, err := png.DecodeConfig(io.LimitReader(file, previewfeedback.MaxScreenshotBytes+1)) + if err != nil || config.Width <= 0 || config.Height <= 0 { + return fmt.Errorf("%w: invalid PNG metadata", previewfeedback.ErrCaptureInvalid) + } + if uint64(config.Width)*uint64(config.Height) > uint64(previewfeedback.MaxScreenshotPixels) { + return previewfeedback.ErrCaptureTooLarge + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("seek preview screenshot for decode: %w", err) + } + if _, err := png.Decode(io.LimitReader(file, previewfeedback.MaxScreenshotBytes+1)); err != nil { + return fmt.Errorf("%w: invalid PNG data", previewfeedback.ErrCaptureInvalid) + } + return nil +} + +func validPreviewScreenshotClaim( + attachment *models.TaskMessageAttachment, + ownerID, workspaceID, taskID string, +) bool { + if attachment == nil || attachment.OwnerID != ownerID || attachment.WorkspaceID != workspaceID { + return false + } + if attachment.State == models.AttachmentStateStaged { + return attachment.TaskID == "" && attachment.SessionID == "" && + attachment.MessageID == "" && attachment.QueueID == "" + } + return attachment.State == models.AttachmentStateClaimed && attachment.TaskID == taskID && + attachment.SessionID == "" && attachment.MessageID == "" && attachment.QueueID == "" +} + // OpenClaimed opens a descriptor for the internal lifecycle delivery path. // It deliberately authorizes by the claimed task/session binding rather than // accepting a caller-supplied owner, and never exposes the storage key. diff --git a/apps/backend/internal/task/service/attachment_service_preview_test.go b/apps/backend/internal/task/service/attachment_service_preview_test.go new file mode 100644 index 0000000000..7fde2536f3 --- /dev/null +++ b/apps/backend/internal/task/service/attachment_service_preview_test.go @@ -0,0 +1,150 @@ +package service + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "hash/crc32" + "image" + "image/png" + "testing" + + "github.com/kandev/kandev/internal/task/previewfeedback" +) + +func encodePreviewPNG(t *testing.T, bounds image.Rectangle) []byte { + t.Helper() + var encoded bytes.Buffer + if err := png.Encode(&encoded, image.NewRGBA(bounds)); err != nil { + t.Fatalf("encode PNG: %v", err) + } + return encoded.Bytes() +} + +func previewPNGWithDimensions(t *testing.T, width, height uint32) []byte { + t.Helper() + content := encodePreviewPNG(t, image.Rect(0, 0, 1, 1)) + binary.BigEndian.PutUint32(content[16:20], width) + binary.BigEndian.PutUint32(content[20:24], height) + binary.BigEndian.PutUint32(content[29:33], crc32.ChecksumIEEE(content[12:29])) + return content +} + +func TestValidatePreviewScreenshotAcceptsBoundedPNG(t *testing.T) { + svc, _, _, _ := newAttachmentTestService(t) + content := encodePreviewPNG(t, image.Rect(0, 0, 320, 180)) + attachment, err := svc.Stage( + context.Background(), "user-a", "ws-att", "capture.png", "image/png", "image", "prompt", + bytes.NewReader(content), + ) + if err != nil { + t.Fatalf("Stage: %v", err) + } + + if err := svc.ValidatePreviewScreenshot( + context.Background(), "user-a", "ws-att", "task-1", attachment.ID, + ); err != nil { + t.Fatalf("ValidatePreviewScreenshot: %v", err) + } +} + +func TestValidatePreviewScreenshotRejectsInvalidPNGBytes(t *testing.T) { + svc, _, _, _ := newAttachmentTestService(t) + attachment, err := svc.Stage( + context.Background(), "user-a", "ws-att", "capture.png", "image/png", "image", "prompt", + bytes.NewBufferString("not a PNG"), + ) + if err != nil { + t.Fatalf("Stage: %v", err) + } + + err = svc.ValidatePreviewScreenshot( + context.Background(), "user-a", "ws-att", "task-1", attachment.ID, + ) + if !errors.Is(err, previewfeedback.ErrCaptureInvalid) { + t.Fatalf("ValidatePreviewScreenshot error = %v, want ErrCaptureInvalid", err) + } +} + +func TestValidatePreviewScreenshotRejectsTruncatedPNGData(t *testing.T) { + svc, _, _, _ := newAttachmentTestService(t) + content := encodePreviewPNG(t, image.Rect(0, 0, 20, 20)) + // DecodeConfig can read the dimensions from the PNG header even when the + // image data is missing. Full decoding must still reject the attachment. + truncated := content[:33] + attachment, err := svc.Stage( + context.Background(), "user-a", "ws-att", "capture.png", "image/png", "image", "prompt", + bytes.NewReader(truncated), + ) + if err != nil { + t.Fatalf("Stage: %v", err) + } + + err = svc.ValidatePreviewScreenshot( + context.Background(), "user-a", "ws-att", "task-1", attachment.ID, + ) + if !errors.Is(err, previewfeedback.ErrCaptureInvalid) { + t.Fatalf("ValidatePreviewScreenshot error = %v, want ErrCaptureInvalid", err) + } +} + +func TestValidatePreviewScreenshotRejectsOversizedPixelMetadata(t *testing.T) { + svc, _, _, _ := newAttachmentTestService(t) + content := previewPNGWithDimensions(t, 4001, 4000) + attachment, err := svc.Stage( + context.Background(), "user-a", "ws-att", "capture.png", "image/png", "image", "prompt", + bytes.NewReader(content), + ) + if err != nil { + t.Fatalf("Stage: %v", err) + } + + err = svc.ValidatePreviewScreenshot( + context.Background(), "user-a", "ws-att", "task-1", attachment.ID, + ) + if !errors.Is(err, previewfeedback.ErrCaptureTooLarge) { + t.Fatalf("ValidatePreviewScreenshot error = %v, want ErrCaptureTooLarge", err) + } +} + +func TestValidatePreviewScreenshotAcceptsIdempotentTaskClaim(t *testing.T) { + svc, _, _, _ := newAttachmentTestService(t) + content := encodePreviewPNG(t, image.Rect(0, 0, 20, 20)) + attachment, err := svc.Stage( + context.Background(), "user-a", "ws-att", "capture.png", "image/png", "image", "prompt", + bytes.NewReader(content), + ) + if err != nil { + t.Fatalf("Stage: %v", err) + } + if err := svc.Claim(context.Background(), "user-a", "ws-att", "task-1", "", []string{attachment.ID}); err != nil { + t.Fatalf("Claim: %v", err) + } + svc.SetTaskAuthorizer(func(context.Context, string) error { return nil }) + + if err := svc.ValidatePreviewScreenshot( + context.Background(), "user-a", "ws-att", "task-1", attachment.ID, + ); err != nil { + t.Fatalf("ValidatePreviewScreenshot claimed retry: %v", err) + } +} + +func TestValidatePreviewScreenshotRejectsWrongAttachmentMetadata(t *testing.T) { + svc, _, _, _ := newAttachmentTestService(t) + content := encodePreviewPNG(t, image.Rect(0, 0, 20, 20)) + attachment, err := svc.Stage( + context.Background(), "user-a", "ws-att", "capture.png", "image/png", "image", "path", + bytes.NewReader(content), + ) + if err != nil { + t.Fatalf("Stage: %v", err) + } + + err = svc.ValidatePreviewScreenshot( + context.Background(), "user-a", "ws-att", "task-1", attachment.ID, + ) + if !errors.Is(err, previewfeedback.ErrCaptureInvalid) { + t.Fatalf("ValidatePreviewScreenshot error = %v, want ErrCaptureInvalid", err) + } +} diff --git a/apps/backend/internal/task/service/plan_service.go b/apps/backend/internal/task/service/plan_service.go index 92765d7a2c..651b153212 100644 --- a/apps/backend/internal/task/service/plan_service.go +++ b/apps/backend/internal/task/service/plan_service.go @@ -68,6 +68,12 @@ type PlanService struct { // workflowStepGetter resolves the task's current workflow step for the // write-time stamp. Nil is safe: stamped fields stay empty. workflowStepGetter PlanWorkflowStepGetter + previewCleaner interface { + DeleteDescriptors(context.Context, []*models.TaskMessageAttachment) error + } + previewScreenshotValidator interface { + ValidatePreviewScreenshot(context.Context, string, string, string, string) error + } } // NewPlanService creates a new task plan service. The concrete repository @@ -101,6 +107,22 @@ func (s *PlanService) SetWorkflowStepGetter(getter PlanWorkflowStepGetter) { s.workflowStepGetter = getter } +// SetPreviewAttachmentCleaner removes screenshot bytes after their task-owned +// feedback claim has been durably released. +func (s *PlanService) SetPreviewAttachmentCleaner(cleaner interface { + DeleteDescriptors(context.Context, []*models.TaskMessageAttachment) error +}) { + s.previewCleaner = cleaner +} + +// SetPreviewScreenshotValidator wires the private-byte validation that runs +// before a staged screenshot is claimed by task feedback. +func (s *PlanService) SetPreviewScreenshotValidator(validator interface { + ValidatePreviewScreenshot(context.Context, string, string, string, string) error +}) { + s.previewScreenshotValidator = validator +} + func (s *PlanService) authorize(ctx context.Context, taskID string) error { if s.authorizeTask == nil { return nil diff --git a/apps/backend/internal/task/service/preview_feedback_service.go b/apps/backend/internal/task/service/preview_feedback_service.go new file mode 100644 index 0000000000..0700a20a04 --- /dev/null +++ b/apps/backend/internal/task/service/preview_feedback_service.go @@ -0,0 +1,259 @@ +package service + +import ( + "context" + "errors" + "strings" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/previewfeedback" + "github.com/kandev/kandev/internal/task/repository" + "go.uber.org/zap" +) + +var ( + ErrPreviewFeedbackIDRequired = errors.New("preview feedback id is required") + ErrPreviewFeedbackIDInvalid = errors.New("preview feedback id must be a UUID") + ErrPreviewFeedbackVersionRequired = errors.New("preview feedback expected_version must be positive") + ErrPreviewFeedbackRevisionInvalid = errors.New("preview feedback expected_revision must not be negative") + ErrTaskPreviewFeedbackChanged = errors.New("task preview feedback changed") +) + +func (s *PlanService) previewFeedbackRepo() (repository.PreviewFeedbackRepository, error) { + repo, ok := s.repo.(repository.PreviewFeedbackRepository) + if !ok { + return nil, errors.New("task preview feedback persistence is unavailable") + } + return repo, nil +} + +func (s *PlanService) ListPreviewFeedback( + ctx context.Context, + taskID string, +) (*models.TaskPreviewFeedbackSnapshot, error) { + if strings.TrimSpace(taskID) == "" { + return nil, ErrTaskIDRequired + } + if err := s.authorize(ctx, taskID); err != nil { + return nil, err + } + repo, err := s.previewFeedbackRepo() + if err != nil { + return nil, err + } + snapshot, err := repo.ListTaskPreviewFeedback(ctx, taskID) + return snapshot, mapPreviewFeedbackError(err) +} + +func (s *PlanService) CreatePreviewFeedback( + ctx context.Context, + item *models.TaskPreviewFeedback, +) (*models.TaskPreviewFeedbackSnapshot, error) { + if err := validatePreviewFeedbackIdentity(item); err != nil { + return nil, err + } + if err := previewfeedback.ValidateItem(item); err != nil { + return nil, err + } + if err := s.authorize(ctx, item.TaskID); err != nil { + return nil, err + } + repo, err := s.previewFeedbackRepo() + if err != nil { + return nil, err + } + ownerID, workspaceID, err := s.previewScreenshotScope(ctx, item) + if err != nil { + return nil, err + } + if item.ScreenshotAttachmentID != "" { + if s.previewScreenshotValidator == nil { + return nil, errors.New("preview screenshot validation is unavailable") + } + if err := s.previewScreenshotValidator.ValidatePreviewScreenshot( + ctx, ownerID, workspaceID, item.TaskID, item.ScreenshotAttachmentID, + ); err != nil { + return nil, err + } + } + snapshot, err := repo.CreateTaskPreviewFeedback(ctx, item, ownerID, workspaceID) + if err != nil { + return snapshot, mapPreviewFeedbackError(err) + } + s.publishPreviewFeedbackSnapshot(ctx, "create", snapshot) + return snapshot, nil +} + +func (s *PlanService) UpdatePreviewFeedback( + ctx context.Context, + taskID, itemID, comment string, + expectedVersion int64, +) (*models.TaskPreviewFeedbackSnapshot, error) { + if err := validatePreviewFeedbackMutation(taskID, itemID, expectedVersion); err != nil { + return nil, err + } + if err := previewfeedback.ValidateComment(comment); err != nil { + return nil, err + } + if err := s.authorize(ctx, taskID); err != nil { + return nil, err + } + repo, err := s.previewFeedbackRepo() + if err != nil { + return nil, err + } + snapshot, err := repo.UpdateTaskPreviewFeedback(ctx, taskID, itemID, comment, expectedVersion) + if err != nil { + return snapshot, mapPreviewFeedbackError(err) + } + s.publishPreviewFeedbackSnapshot(ctx, "update", snapshot) + return snapshot, nil +} + +func (s *PlanService) DeletePreviewFeedback( + ctx context.Context, + taskID, itemID string, + expectedVersion int64, +) (*models.TaskPreviewFeedbackSnapshot, error) { + if err := validatePreviewFeedbackMutation(taskID, itemID, expectedVersion); err != nil { + return nil, err + } + if err := s.authorize(ctx, taskID); err != nil { + return nil, err + } + repo, err := s.previewFeedbackRepo() + if err != nil { + return nil, err + } + snapshot, released, err := repo.DeleteTaskPreviewFeedback(ctx, taskID, itemID, expectedVersion) + if err != nil { + return snapshot, mapPreviewFeedbackError(err) + } + s.publishPreviewFeedbackSnapshot(ctx, "delete", snapshot) + s.cleanupPreviewScreenshots(ctx, released) + return snapshot, nil +} + +func (s *PlanService) ClearPreviewFeedback( + ctx context.Context, + taskID string, + expectedRevision int64, +) (*models.TaskPreviewFeedbackSnapshot, error) { + if strings.TrimSpace(taskID) == "" { + return nil, ErrTaskIDRequired + } + if expectedRevision < 0 { + return nil, ErrPreviewFeedbackRevisionInvalid + } + if err := s.authorize(ctx, taskID); err != nil { + return nil, err + } + repo, err := s.previewFeedbackRepo() + if err != nil { + return nil, err + } + snapshot, released, err := repo.ClearTaskPreviewFeedback(ctx, taskID, expectedRevision) + if err != nil { + return snapshot, mapPreviewFeedbackError(err) + } + s.publishPreviewFeedbackSnapshot(ctx, "clear", snapshot) + s.cleanupPreviewScreenshots(ctx, released) + return snapshot, nil +} + +func validatePreviewFeedbackIdentity(item *models.TaskPreviewFeedback) error { + if item == nil || strings.TrimSpace(item.TaskID) == "" { + return ErrTaskIDRequired + } + if strings.TrimSpace(item.ID) == "" { + return ErrPreviewFeedbackIDRequired + } + if uuid.Validate(item.ID) != nil { + return ErrPreviewFeedbackIDInvalid + } + return nil +} + +func validatePreviewFeedbackMutation(taskID, itemID string, expectedVersion int64) error { + if strings.TrimSpace(taskID) == "" { + return ErrTaskIDRequired + } + if strings.TrimSpace(itemID) == "" { + return ErrPreviewFeedbackIDRequired + } + if uuid.Validate(itemID) != nil { + return ErrPreviewFeedbackIDInvalid + } + if expectedVersion <= 0 { + return ErrPreviewFeedbackVersionRequired + } + return nil +} + +func (s *PlanService) previewScreenshotScope( + ctx context.Context, + item *models.TaskPreviewFeedback, +) (string, string, error) { + if item.ScreenshotAttachmentID == "" { + return "", "", nil + } + identity, ok := authn.IdentityFromContext(ctx) + if !ok || strings.TrimSpace(identity.UserID) == "" { + return "", "", models.ErrAttachmentForbidden + } + task, err := s.repo.GetTask(ctx, item.TaskID) + if err != nil { + return "", "", err + } + return identity.UserID, task.WorkspaceID, nil +} + +func mapPreviewFeedbackError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, repository.ErrTaskPreviewFeedbackChanged) { + return ErrTaskPreviewFeedbackChanged + } + if errors.Is(err, repository.ErrTaskNotFound) { + return repository.ErrTaskNotFound + } + return err +} + +func (s *PlanService) publishPreviewFeedbackSnapshot( + ctx context.Context, + mutation string, + snapshot *models.TaskPreviewFeedbackSnapshot, +) { + if s.eventBus == nil { + return + } + if err := s.eventBus.Publish(ctx, events.TaskPreviewFeedbackChanged, + bus.NewEvent(events.TaskPreviewFeedbackChanged, "preview-feedback-service", snapshot)); err != nil { + s.logger.Error("publish preview feedback snapshot", + zap.String("task_id", snapshot.TaskID), + zap.Int64("revision", snapshot.Revision), + zap.String("mutation", mutation), + zap.Int("item_count", len(snapshot.Items)), + zap.Error(err), + ) + } +} + +func (s *PlanService) cleanupPreviewScreenshots( + ctx context.Context, + attachments []*models.TaskMessageAttachment, +) { + if s.previewCleaner == nil || len(attachments) == 0 { + return + } + if err := s.previewCleaner.DeleteDescriptors(ctx, attachments); err != nil { + s.logger.Warn("cleanup preview feedback screenshots", + zap.Int("attachment_count", len(attachments)), zap.Error(err)) + } +} diff --git a/apps/backend/internal/task/service/preview_feedback_service_test.go b/apps/backend/internal/task/service/preview_feedback_service_test.go new file mode 100644 index 0000000000..da66d14941 --- /dev/null +++ b/apps/backend/internal/task/service/preview_feedback_service_test.go @@ -0,0 +1,120 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +type previewFeedbackServiceContract interface { + ListPreviewFeedback(context.Context, string) (*models.TaskPreviewFeedbackSnapshot, error) + CreatePreviewFeedback(context.Context, *models.TaskPreviewFeedback) (*models.TaskPreviewFeedbackSnapshot, error) + UpdatePreviewFeedback(context.Context, string, string, string, int64) (*models.TaskPreviewFeedbackSnapshot, error) + DeletePreviewFeedback(context.Context, string, string, int64) (*models.TaskPreviewFeedbackSnapshot, error) + ClearPreviewFeedback(context.Context, string, int64) (*models.TaskPreviewFeedbackSnapshot, error) +} + +type recordingPreviewScreenshotValidator struct { + ownerID, workspaceID, taskID, attachmentID string + err error +} + +func (v *recordingPreviewScreenshotValidator) ValidatePreviewScreenshot( + _ context.Context, + ownerID, workspaceID, taskID, attachmentID string, +) error { + v.ownerID = ownerID + v.workspaceID = workspaceID + v.taskID = taskID + v.attachmentID = attachmentID + return v.err +} + +func requirePreviewFeedbackService(t *testing.T, svc *PlanService) previewFeedbackServiceContract { + t.Helper() + contract, ok := any(svc).(previewFeedbackServiceContract) + if !ok { + t.Fatal("PlanService does not implement task preview feedback operations") + } + return contract +} + +func TestPreviewFeedbackServiceAuthorizesPublishesAndMapsConflicts(t *testing.T) { + svc, eventBus, repo := createTestPlanService(t) + ctx := context.Background() + seedTask(t, ctx, repo, "task-preview-feedback-service") + eventBus.ClearEvents() + + var authorized []string + svc.SetTaskAuthorizer(func(_ context.Context, taskID string) error { + authorized = append(authorized, taskID) + if taskID == "task-preview-denied" { + return errors.New("denied") + } + return nil + }) + feedback := requirePreviewFeedbackService(t, svc) + created, err := feedback.CreatePreviewFeedback(ctx, &models.TaskPreviewFeedback{ + ID: "7b014fea-f9ea-4365-a8c6-17ad1b0b0c4f", TaskID: "task-preview-feedback-service", + Kind: models.TaskPreviewFeedbackText, Comment: "Increase contrast", + SourceKind: models.TaskPreviewFeedbackBrowser, SourceLabel: "Local app", + PageRoute: "/products", PageTitle: "Products", SelectedText: "Choose a plan", + TextAnchor: json.RawMessage(`{"start":{"path":[0],"offset":0}}`), + }) + if err != nil || created.Revision != 1 || len(created.Items) != 1 { + t.Fatalf("CreatePreviewFeedback = %#v, %v", created, err) + } + if len(authorized) != 1 || authorized[0] != "task-preview-feedback-service" { + t.Fatalf("authorized task IDs = %v", authorized) + } + published := eventBus.GetPublishedEvents() + if len(published) != 1 || published[0].Type != "task.preview_feedback.changed" { + t.Fatalf("published events = %#v", published) + } + + eventBus.ClearEvents() + stale, err := feedback.UpdatePreviewFeedback( + ctx, "task-preview-feedback-service", created.Items[0].ID, "Stale", 9, + ) + if err == nil || stale.Revision != 1 { + t.Fatalf("stale update = %#v, %v", stale, err) + } + if len(eventBus.GetPublishedEvents()) != 0 { + t.Fatal("failed mutation published an event") + } + if _, err := feedback.ListPreviewFeedback(ctx, "task-preview-denied"); err == nil { + t.Fatal("denied list succeeded") + } +} + +func TestPreviewFeedbackServiceValidatesScreenshotBeforeClaim(t *testing.T) { + svc, _, repo := createTestPlanService(t) + seedTask(t, context.Background(), repo, "task-preview-screenshot-validation") + validationErr := errors.New("invalid screenshot bytes") + validator := &recordingPreviewScreenshotValidator{err: validationErr} + svc.SetPreviewScreenshotValidator(validator) + + _, err := svc.CreatePreviewFeedback(ctxAs("owner-preview"), &models.TaskPreviewFeedback{ + ID: "db6b016a-717b-4ae8-b9a0-f4eb8eb723bf", TaskID: "task-preview-screenshot-validation", + Kind: models.TaskPreviewFeedbackScreenshot, Comment: "The form clips here", + SourceKind: models.TaskPreviewFeedbackBrowser, SourceLabel: "Checkout", + PageRoute: "/checkout", PageTitle: "Checkout", + CaptureRect: json.RawMessage(`{"x":1,"y":2,"width":320,"height":180}`), + ScreenshotAttachmentID: "staged-screenshot", + }) + if !errors.Is(err, validationErr) { + t.Fatalf("CreatePreviewFeedback error = %v, want validator error", err) + } + if validator.ownerID != "owner-preview" || validator.workspaceID != "ws-plan" || + validator.taskID != "task-preview-screenshot-validation" || + validator.attachmentID != "staged-screenshot" { + t.Fatalf("validator scope = %#v", validator) + } + snapshot, listErr := svc.ListPreviewFeedback(context.Background(), "task-preview-screenshot-validation") + if listErr != nil || len(snapshot.Items) != 0 { + t.Fatalf("snapshot after rejected screenshot = %#v, %v", snapshot, listErr) + } +} diff --git a/apps/backend/internal/task/service/service_attachments.go b/apps/backend/internal/task/service/service_attachments.go index d7462de52f..e767bc4b21 100644 --- a/apps/backend/internal/task/service/service_attachments.go +++ b/apps/backend/internal/task/service/service_attachments.go @@ -7,9 +7,94 @@ import ( "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/orchestrator/messagequeue" "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/previewfeedbacktx" v1 "github.com/kandev/kandev/pkg/api/v1" ) +// PreviewFeedbackAttachments returns server-owned screenshot descriptors for +// the exact visible snapshot submitted by the composer. +func (s *Service) PreviewFeedbackAttachments( + ctx context.Context, + taskID string, + refs []models.TaskPreviewFeedbackRef, +) ([]v1.MessageAttachment, error) { + if len(refs) == 0 { + return nil, nil + } + if _, err := s.GetTask(ctx, taskID); err != nil { + return nil, err + } + repo, ok := s.messages.(interface { + ListTaskPreviewFeedback(context.Context, string) (*models.TaskPreviewFeedbackSnapshot, error) + }) + if !ok { + return nil, errors.New("preview feedback repository is unavailable") + } + snapshot, err := repo.ListTaskPreviewFeedback(ctx, taskID) + if err != nil { + return nil, err + } + wanted, err := wantedPreviewFeedbackVersions(refs, snapshot) + if err != nil { + return nil, err + } + attachments, matched, err := collectPreviewFeedbackAttachments(snapshot, wanted) + if err != nil { + return nil, err + } + if matched != len(refs) { + return nil, &previewfeedbacktx.FeedbackChangedError{Snapshot: snapshot} + } + return attachments, nil +} + +func wantedPreviewFeedbackVersions( + refs []models.TaskPreviewFeedbackRef, + snapshot *models.TaskPreviewFeedbackSnapshot, +) (map[string]int64, error) { + wanted := make(map[string]int64, len(refs)) + for _, ref := range refs { + if ref.ID == "" || ref.Version <= 0 { + return nil, &previewfeedbacktx.FeedbackChangedError{Snapshot: snapshot} + } + if _, duplicate := wanted[ref.ID]; duplicate { + return nil, &previewfeedbacktx.FeedbackChangedError{Snapshot: snapshot} + } + wanted[ref.ID] = ref.Version + } + return wanted, nil +} + +func collectPreviewFeedbackAttachments( + snapshot *models.TaskPreviewFeedbackSnapshot, + wanted map[string]int64, +) ([]v1.MessageAttachment, int, error) { + attachments := make([]v1.MessageAttachment, 0) + matched := 0 + for _, item := range snapshot.Items { + version, selected := wanted[item.ID] + if !selected { + continue + } + if item.Version != version { + return nil, 0, &previewfeedbacktx.FeedbackChangedError{Snapshot: snapshot} + } + matched++ + if item.Kind != models.TaskPreviewFeedbackScreenshot { + continue + } + attachment := item.ScreenshotAttachment + if attachment == nil || attachment.ID != item.ScreenshotAttachmentID { + return nil, 0, models.ErrAttachmentClaimConflict + } + attachments = append(attachments, v1.MessageAttachment{ + Type: attachment.Kind, AttachmentID: attachment.ID, MimeType: attachment.MimeType, + Name: attachment.Name, SizeBytes: attachment.SizeBytes, DeliveryMode: attachment.DeliveryMode, + }) + } + return attachments, matched, nil +} + // ClaimMessageAttachments binds staged descriptors to a task/session after // the normal task and session authorization checks have completed. func (s *Service) ClaimMessageAttachments(ctx context.Context, taskID, sessionID string, attachments []v1.MessageAttachment) error { diff --git a/apps/backend/internal/task/service/service_message_preview_feedback_test.go b/apps/backend/internal/task/service/service_message_preview_feedback_test.go new file mode 100644 index 0000000000..50eb303195 --- /dev/null +++ b/apps/backend/internal/task/service/service_message_preview_feedback_test.go @@ -0,0 +1,75 @@ +package service + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +func TestServiceCreateMessageWithPreviewFeedbackConsumesAndReplays(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + sessionID, turnID := seedServicePlanComment(t, ctx, repo, "preview") + preview := seedServicePreviewFeedback(t, ctx, repo, "preview") + eventBus.ClearEvents() + request := &CreateMessageRequest{ + TaskSessionID: sessionID, TaskID: "task-123", TurnID: turnID, + Content: "typed body", AuthorType: "user", + PreviewFeedbackRefs: []models.TaskPreviewFeedbackRef{{ID: preview.ID, Version: 1}}, + RequirePrimarySession: true, + } + + first, err := svc.CreateMessageIdempotent(ctx, "message-service-preview-feedback", request) + if err != nil { + t.Fatalf("CreateMessageIdempotent: %v", err) + } + for _, want := range []string{"typed body", "### Web Preview Feedback", "Runtime total", "node_path"} { + if !strings.Contains(first.Content, want) { + t.Fatalf("stored content missing %q:\n%s", want, first.Content) + } + } + eventTypes := eventTypesForPlanCommentTest(eventBus) + if len(eventTypes) != 3 || eventTypes[0] != events.MessageAdded || + eventTypes[1] != events.SessionPendingActionChanged || + eventTypes[2] != events.TaskPreviewFeedbackChanged { + t.Fatalf("published event types = %v", eventTypes) + } + + replayed, err := svc.CreateMessageIdempotent(ctx, "message-service-preview-feedback", request) + if err != nil || replayed.ID != first.ID || replayed.Content != first.Content { + t.Fatalf("replay=%#v err=%v", replayed, err) + } + pending, err := repo.ListTaskPreviewFeedback(ctx, "task-123") + if err != nil || len(pending.Items) != 0 || pending.Revision != 2 { + t.Fatalf("pending snapshot=%#v err=%v", pending, err) + } +} + +func seedServicePreviewFeedback( + t *testing.T, + ctx context.Context, + repo *sqliterepo.Repository, + suffix string, +) *models.TaskPreviewFeedback { + t.Helper() + item := &models.TaskPreviewFeedback{ + ID: "preview-service-" + suffix, TaskID: "task-123", + Kind: models.TaskPreviewFeedbackText, Comment: "Runtime total", + SourceKind: models.TaskPreviewFeedbackBrowser, SourceLabel: "Local app", + PageRoute: "/totals", SelectedText: "$42", + TextAnchor: json.RawMessage(`{ + "start":{"node_path":[0],"offset":0}, + "end":{"node_path":[0],"offset":3}, + "containing_element":{"tag":"strong","outer_html":"$42"} + }`), + } + if _, err := repo.CreateTaskPreviewFeedback(ctx, item, "", ""); err != nil { + t.Fatal(err) + } + return item +} diff --git a/apps/backend/internal/task/service/service_messages.go b/apps/backend/internal/task/service/service_messages.go index 3b0b67850e..594978ae28 100644 --- a/apps/backend/internal/task/service/service_messages.go +++ b/apps/backend/internal/task/service/service_messages.go @@ -17,6 +17,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator/messagequeue" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/plancomments" + "github.com/kandev/kandev/internal/task/previewfeedback" "github.com/kandev/kandev/internal/task/repository/admission" "github.com/kandev/kandev/internal/task/repository/plancommenttx" "github.com/kandev/kandev/internal/task/repository/repoerrors" @@ -43,6 +44,37 @@ type planCommentMessageWriter interface { ) (*models.TaskPlanCommentSnapshot, error) } +type taskFeedbackMessageWriter interface { + CreateMessageWithTaskFeedback( + context.Context, + *models.Message, + []models.TaskPlanCommentRef, + []models.TaskPreviewFeedbackRef, + bool, + models.TaskSessionState, + *messagequeue.QueueAttachmentClaim, + ) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, error) +} + +type initialTaskBriefTaskFeedbackMessageWriter interface { + CreateMessageWithTaskFeedbackWithInitialTaskBrief( + context.Context, + *models.Message, + *admission.InitialTaskBriefCandidate, + []models.TaskPlanCommentRef, + []models.TaskPreviewFeedbackRef, + bool, + models.TaskSessionState, + *messagequeue.QueueAttachmentClaim, + ) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, error) +} + +type messageFeedbackSnapshots struct { + plan *models.TaskPlanCommentSnapshot + preview *models.TaskPreviewFeedbackSnapshot + receipt *models.ConversationMutationReceipt +} + type queuedPlanCommentMessageWriter interface { CreateMessageWithPlanCommentsAndQueue( context.Context, @@ -56,6 +88,35 @@ type queuedPlanCommentMessageWriter interface { ) (*models.TaskPlanCommentSnapshot, error) } +type queuedTaskFeedbackMessageWriter interface { + CreateMessageWithTaskFeedbackAndQueue( + context.Context, + *models.Message, + *messagequeue.QueuedMessage, + []models.TaskPlanCommentRef, + []models.TaskPreviewFeedbackRef, + bool, + models.TaskSessionState, + *messagequeue.QueueAttachmentClaim, + int, + ) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, error) +} + +type initialTaskBriefQueuedTaskFeedbackMessageWriter interface { + CreateMessageWithTaskFeedbackAndQueueWithInitialTaskBrief( + context.Context, + *models.Message, + *messagequeue.QueuedMessage, + *admission.InitialTaskBriefCandidate, + []models.TaskPlanCommentRef, + []models.TaskPreviewFeedbackRef, + bool, + models.TaskSessionState, + *messagequeue.QueueAttachmentClaim, + int, + ) (*models.TaskPlanCommentSnapshot, *models.TaskPreviewFeedbackSnapshot, error) +} + type initialTaskBriefMessageWriter interface { CreateMessageWithInitialTaskBrief(context.Context, *models.Message, *admission.InitialTaskBriefCandidate) error } @@ -177,7 +238,7 @@ func (s *Service) acquireMessageCreateAdmission( messageID string, req *CreateMessageRequest, ) (context.Context, func(), error) { - if req != nil && len(req.PlanCommentRefs) > 0 { + if req != nil && (len(req.PlanCommentRefs) > 0 || len(req.PreviewFeedbackRefs) > 0) { return s.AcquirePlanCommentAndMessageAdmission(ctx, req.TaskID, messageID) } return s.AcquireMessageAdmission(ctx, messageID) @@ -261,15 +322,15 @@ func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) // as an explicit import and reject same-microsecond creates. } - snapshot, receipt, err := s.persistMessage(ctx, message, req) + snapshots, err := s.persistMessage(ctx, message, req) if err != nil { s.logger.Error("failed to create message", zap.Error(err)) return nil, err } // Publish message.added event - _ = s.publishMessageEvent(ctx, events.MessageAdded, message, receipt) - s.publishMessagePlanCommentSnapshot(ctx, snapshot) + _ = s.publishMessageEvent(ctx, events.MessageAdded, message, snapshots.receipt) + s.publishMessageFeedbackSnapshots(ctx, snapshots) s.logger.Info("message created", zap.String("message_id", message.ID), @@ -343,6 +404,46 @@ type planCommentMessageValidator interface { ) error } +type taskFeedbackMessageValidator interface { + ValidateMessageTaskFeedback( + context.Context, + string, + string, + string, + []models.TaskPlanCommentRef, + []models.TaskPreviewFeedbackRef, + bool, + models.TaskSessionState, + ) error +} + +// ValidateTaskFeedbackMessage rejects stale preview references before stateful +// workflow hooks. Persistence repeats this under the final transaction. +func (s *Service) ValidateTaskFeedbackMessage( + ctx context.Context, + taskID, sessionID, content string, + planRefs []models.TaskPlanCommentRef, + previewRefs []models.TaskPreviewFeedbackRef, + requirePrimary bool, + expectedState models.TaskSessionState, +) error { + if len(previewRefs) == 0 { + return s.ValidatePlanCommentMessage( + ctx, taskID, sessionID, content, planRefs, requirePrimary, expectedState, + ) + } + validator, ok := s.messages.(taskFeedbackMessageValidator) + if !ok { + return errors.New("task feedback message validation is unavailable") + } + if len(planRefs) > 0 { + content = plancomments.WithPlaceholder(content) + } + return validator.ValidateMessageTaskFeedback( + ctx, taskID, sessionID, content, planRefs, previewRefs, requirePrimary, expectedState, + ) +} + // ValidatePlanCommentMessage rejects stale references before callers execute // stateful turn-start hooks. Persistence repeats the same validation under its // atomic message/comment boundary to close races. @@ -407,22 +508,42 @@ func (s *Service) CreateQueuedMessageIdempotent( queued.Content = message.Content queued.QueuedBy = messagequeue.QueuedByUser - writer, ok := s.messages.(queuedPlanCommentMessageWriter) - if !ok { - return nil, errors.New("queued plan comment message admission is unavailable") - } - var snapshot *models.TaskPlanCommentSnapshot + var snapshots messageFeedbackSnapshots if req.InitialTaskBrief != nil { - initialWriter, initialOK := s.messages.(initialTaskBriefQueuedPlanCommentMessageWriter) - if !initialOK { - return nil, errors.New("queued initial task brief admission is unavailable") + if len(req.PreviewFeedbackRefs) > 0 { + writer, ok := s.messages.(initialTaskBriefQueuedTaskFeedbackMessageWriter) + if !ok { + return nil, errors.New("queued preview feedback initial task brief admission is unavailable") + } + snapshots.plan, snapshots.preview, err = writer.CreateMessageWithTaskFeedbackAndQueueWithInitialTaskBrief( + ctx, message, queued, req.InitialTaskBrief, req.PlanCommentRefs, req.PreviewFeedbackRefs, + req.RequirePrimarySession, req.ExpectedSessionState, req.AttachmentClaim, maxPerSession, + ) + } else { + initialWriter, initialOK := s.messages.(initialTaskBriefQueuedPlanCommentMessageWriter) + if !initialOK { + return nil, errors.New("queued initial task brief admission is unavailable") + } + snapshots.plan, err = initialWriter.CreateMessageWithPlanCommentsAndQueueWithInitialTaskBrief( + ctx, message, queued, req.InitialTaskBrief, req.PlanCommentRefs, + req.RequirePrimarySession, req.ExpectedSessionState, req.AttachmentClaim, maxPerSession, + ) } - snapshot, err = initialWriter.CreateMessageWithPlanCommentsAndQueueWithInitialTaskBrief( - ctx, message, queued, req.InitialTaskBrief, req.PlanCommentRefs, + } else if len(req.PreviewFeedbackRefs) > 0 { + writer, ok := s.messages.(queuedTaskFeedbackMessageWriter) + if !ok { + return nil, errors.New("queued task feedback message admission is unavailable") + } + snapshots.plan, snapshots.preview, err = writer.CreateMessageWithTaskFeedbackAndQueue( + ctx, message, queued, req.PlanCommentRefs, req.PreviewFeedbackRefs, req.RequirePrimarySession, req.ExpectedSessionState, req.AttachmentClaim, maxPerSession, ) } else { - snapshot, err = writer.CreateMessageWithPlanCommentsAndQueue( + writer, ok := s.messages.(queuedPlanCommentMessageWriter) + if !ok { + return nil, errors.New("queued plan comment message admission is unavailable") + } + snapshots.plan, err = writer.CreateMessageWithPlanCommentsAndQueue( ctx, message, queued, req.PlanCommentRefs, req.RequirePrimarySession, req.ExpectedSessionState, req.AttachmentClaim, maxPerSession, ) @@ -436,7 +557,7 @@ func (s *Service) CreateQueuedMessageIdempotent( } _ = s.publishMessageEvent(ctx, events.MessageAdded, message) - s.publishMessagePlanCommentSnapshot(ctx, snapshot) + s.publishMessageFeedbackSnapshots(ctx, &snapshots) s.logger.Info("queued message created with ID", zap.String("message_id", message.ID), zap.String("session_id", message.TaskSessionID), @@ -452,8 +573,8 @@ func validateQueuedPlanCommentMessage( if id == "" { return errors.New("message id is required for idempotent creation") } - if req == nil || len(req.PlanCommentRefs) == 0 { - return errors.New("plan comment refs are required for queued message creation") + if req == nil || len(req.PlanCommentRefs) == 0 && len(req.PreviewFeedbackRefs) == 0 { + return errors.New("task feedback refs are required for queued message creation") } if queued == nil { return errors.New("queued message is required") @@ -519,7 +640,7 @@ func (s *Service) CreateMessageWithID(ctx context.Context, id string, req *Creat return nil, err } - snapshot, receipt, err := s.createMessageWithRequestRetry( + snapshots, err := s.createMessageWithRequestRetry( ctx, message, req, messageCreateMaxRetries, messageCreateRetryDelay, ) if err != nil { @@ -527,8 +648,8 @@ func (s *Service) CreateMessageWithID(ctx context.Context, id string, req *Creat } // Publish message.added event - _ = s.publishMessageEvent(ctx, events.MessageAdded, message, receipt) - s.publishMessagePlanCommentSnapshot(ctx, snapshot) + _ = s.publishMessageEvent(ctx, events.MessageAdded, message, snapshots.receipt) + s.publishMessageFeedbackSnapshots(ctx, snapshots) s.logger.Info("message created with ID", zap.String("message_id", message.ID), @@ -660,20 +781,19 @@ func (s *Service) createMessageWithRequestRetry( req *CreateMessageRequest, maxRetries int, retryDelay time.Duration, -) (*models.TaskPlanCommentSnapshot, *models.ConversationMutationReceipt, error) { +) (*messageFeedbackSnapshots, error) { var err error for attempt := 0; attempt < maxRetries; attempt++ { - var snapshot *models.TaskPlanCommentSnapshot - var receipt *models.ConversationMutationReceipt - snapshot, receipt, err = s.persistMessage(ctx, message, req) + var snapshots *messageFeedbackSnapshots + snapshots, err = s.persistMessage(ctx, message, req) if err == nil { - return snapshot, receipt, nil + return snapshots, nil } if ctx.Err() != nil { - return nil, nil, ctx.Err() + return nil, ctx.Err() } if errors.Is(err, repoerrors.ErrInitialTaskBriefStale) { - return nil, nil, err + return nil, err } if attempt < maxRetries-1 { s.logger.Debug("failed to create message, retrying", @@ -688,75 +808,100 @@ func (s *Service) createMessageWithRequestRetry( zap.String("id", message.ID), zap.Int("retries", maxRetries), zap.Error(err)) - return nil, nil, err + return nil, err } func (s *Service) persistMessage( ctx context.Context, message *models.Message, req *CreateMessageRequest, -) (*models.TaskPlanCommentSnapshot, *models.ConversationMutationReceipt, error) { - if len(req.PlanCommentRefs) == 0 { +) (*messageFeedbackSnapshots, error) { + if len(req.PlanCommentRefs) == 0 && len(req.PreviewFeedbackRefs) == 0 { if req.InitialTaskBrief != nil { writer, ok := s.messages.(initialTaskBriefMessageWriter) if !ok { - return nil, nil, errors.New("initial task brief admission is unavailable") + return nil, errors.New("initial task brief admission is unavailable") } - return nil, nil, writer.CreateMessageWithInitialTaskBrief(ctx, message, req.InitialTaskBrief) + return &messageFeedbackSnapshots{}, writer.CreateMessageWithInitialTaskBrief(ctx, message, req.InitialTaskBrief) } if writer, ok := s.messages.(conversationMessageReceiptWriter); ok { receipt, err := writer.CreateMessageWithConversationReceipt(ctx, message) - return nil, receipt, err + return &messageFeedbackSnapshots{receipt: receipt}, err } - return nil, nil, s.messages.CreateMessage(ctx, message) + return &messageFeedbackSnapshots{}, s.messages.CreateMessage(ctx, message) + } + if len(req.PreviewFeedbackRefs) > 0 { + if req.InitialTaskBrief != nil { + writer, ok := s.messages.(initialTaskBriefTaskFeedbackMessageWriter) + if !ok { + return nil, errors.New("preview feedback initial task brief admission is unavailable") + } + planSnapshot, previewSnapshot, err := writer.CreateMessageWithTaskFeedbackWithInitialTaskBrief( + ctx, message, req.InitialTaskBrief, req.PlanCommentRefs, req.PreviewFeedbackRefs, + req.RequirePrimarySession, req.ExpectedSessionState, req.AttachmentClaim, + ) + return &messageFeedbackSnapshots{plan: planSnapshot, preview: previewSnapshot}, err + } + writer, ok := s.messages.(taskFeedbackMessageWriter) + if !ok { + return nil, errors.New("task feedback message admission is unavailable") + } + planSnapshot, previewSnapshot, err := writer.CreateMessageWithTaskFeedback( + ctx, message, req.PlanCommentRefs, req.PreviewFeedbackRefs, + req.RequirePrimarySession, req.ExpectedSessionState, req.AttachmentClaim, + ) + return &messageFeedbackSnapshots{plan: planSnapshot, preview: previewSnapshot}, err } if req.InitialTaskBrief != nil { writer, ok := s.messages.(initialTaskBriefPlanCommentMessageWriter) if !ok { - return nil, nil, errors.New("plan comment initial task brief admission is unavailable") + return nil, errors.New("plan comment initial task brief admission is unavailable") } snapshot, err := writer.CreateMessageWithPlanCommentsWithInitialTaskBrief( ctx, message, req.InitialTaskBrief, req.PlanCommentRefs, req.RequirePrimarySession, req.ExpectedSessionState, req.AttachmentClaim, ) - return snapshot, nil, err + return &messageFeedbackSnapshots{plan: snapshot}, err } writer, ok := s.messages.(planCommentMessageWriter) if !ok { - return nil, nil, errors.New("plan comment message admission is unavailable") + return nil, errors.New("plan comment message admission is unavailable") } snapshot, err := writer.CreateMessageWithPlanComments( ctx, message, req.PlanCommentRefs, req.RequirePrimarySession, req.ExpectedSessionState, req.AttachmentClaim, ) - return snapshot, nil, err + return &messageFeedbackSnapshots{plan: snapshot}, err } type planCommentMessageReplayIdentity struct { - TaskSessionID string `json:"session_id"` - TaskID string `json:"task_id"` - TurnID string `json:"turn_id"` - Content string `json:"content"` - AuthorID string `json:"author_id"` - MessageType string `json:"message_type"` - Metadata map[string]interface{} `json:"metadata"` - Refs []models.TaskPlanCommentRef `json:"refs"` - RequirePrimary bool `json:"require_primary"` + TaskSessionID string `json:"session_id"` + TaskID string `json:"task_id"` + TurnID string `json:"turn_id"` + Content string `json:"content"` + AuthorID string `json:"author_id"` + MessageType string `json:"message_type"` + Metadata map[string]interface{} `json:"metadata"` + Refs []models.TaskPlanCommentRef `json:"refs"` + PreviewRefs []models.TaskPreviewFeedbackRef `json:"preview_refs"` + RequirePrimary bool `json:"require_primary"` } func preparePlanCommentMessageRequest(req *CreateMessageRequest) error { - if req == nil || len(req.PlanCommentRefs) == 0 { + if req == nil || len(req.PlanCommentRefs) == 0 && len(req.PreviewFeedbackRefs) == 0 { return nil } - metadata := make(map[string]interface{}, len(req.Metadata)+2) + metadata := make(map[string]interface{}, len(req.Metadata)+4) for key, value := range req.Metadata { - if key != plancomments.MetadataRefs && key != plancomments.MetadataRequestFingerprint { + if key != plancomments.MetadataRefs && key != plancomments.MetadataRequestFingerprint && + key != previewfeedback.MetadataRefs && key != previewfeedback.MetadataRequestFingerprint { metadata[key] = value } } identity := planCommentMessageReplayIdentity{ TaskSessionID: req.TaskSessionID, TaskID: req.TaskID, TurnID: req.TurnID, Content: req.Content, AuthorID: req.AuthorID, MessageType: req.Type, - Metadata: metadata, Refs: req.PlanCommentRefs, RequirePrimary: req.RequirePrimarySession, + Metadata: metadata, Refs: req.PlanCommentRefs, PreviewRefs: req.PreviewFeedbackRefs, + RequirePrimary: req.RequirePrimarySession, } fingerprint, err := plancomments.Fingerprint(identity) if err != nil { @@ -764,6 +909,8 @@ func preparePlanCommentMessageRequest(req *CreateMessageRequest) error { } metadata[plancomments.MetadataRefs] = req.PlanCommentRefs metadata[plancomments.MetadataRequestFingerprint] = fingerprint + metadata[previewfeedback.MetadataRefs] = req.PreviewFeedbackRefs + metadata[previewfeedback.MetadataRequestFingerprint] = fingerprint req.Metadata = metadata return nil } @@ -773,7 +920,7 @@ func matchesPlanCommentMessageReplay(existing *models.Message, req *CreateMessag got, _ := existing.Metadata[plancomments.MetadataClientMessageFingerprint].(string) return got == want } - if len(req.PlanCommentRefs) == 0 { + if len(req.PlanCommentRefs) == 0 && len(req.PreviewFeedbackRefs) == 0 { return true } want, _ := req.Metadata[plancomments.MetadataRequestFingerprint].(string) @@ -781,6 +928,35 @@ func matchesPlanCommentMessageReplay(existing *models.Message, req *CreateMessag return want != "" && got == want } +func (s *Service) publishMessageFeedbackSnapshots( + ctx context.Context, + snapshots *messageFeedbackSnapshots, +) { + if snapshots == nil { + return + } + s.publishMessagePlanCommentSnapshot(ctx, snapshots.plan) + s.publishMessagePreviewFeedbackSnapshot(ctx, snapshots.preview) +} + +func (s *Service) publishMessagePreviewFeedbackSnapshot( + ctx context.Context, + snapshot *models.TaskPreviewFeedbackSnapshot, +) { + if snapshot == nil || s.eventBus == nil { + return + } + if err := s.eventBus.Publish(ctx, events.TaskPreviewFeedbackChanged, + bus.NewEvent(events.TaskPreviewFeedbackChanged, "task-service", snapshot)); err != nil { + s.logger.Error("publish consumed preview feedback", + zap.String("task_id", snapshot.TaskID), + zap.Int64("revision", snapshot.Revision), + zap.Int("item_count", len(snapshot.Items)), + zap.Error(err), + ) + } +} + func (s *Service) publishMessagePlanCommentSnapshot( ctx context.Context, snapshot *models.TaskPlanCommentSnapshot, @@ -1116,13 +1292,13 @@ func (s *Service) upsertAgentPlanMessageFallback( if err != nil && !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("check existing agent plan message: %w", err) } - _, receipt, err := s.createMessageWithRequestRetry( + snapshots, err := s.createMessageWithRequestRetry( admissionCtx, message, req, messageCreateMaxRetries, messageCreateRetryDelay, ) if err != nil { return err } - return s.publishAgentPlanMessage(admissionCtx, message, receipt, true) + return s.publishAgentPlanMessage(admissionCtx, message, snapshots.receipt, true) } func (s *Service) publishAgentPlanMessage( diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 646914d193..9d783b3904 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -387,6 +387,7 @@ type CreateMessageRequest struct { Type string `json:"type,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` PlanCommentRefs []models.TaskPlanCommentRef `json:"plan_comment_refs,omitempty"` + PreviewFeedbackRefs []models.TaskPreviewFeedbackRef `json:"preview_feedback_refs,omitempty"` RequirePrimarySession bool `json:"require_primary_session,omitempty"` ExpectedSessionState models.TaskSessionState `json:"-"` AttachmentClaim *messagequeue.QueueAttachmentClaim `json:"-"` diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 8908c79d84..ee3f56e7c8 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -72,28 +72,33 @@ const ( ActionEnvironmentDelete = "environment.delete" // Task actions - ActionTaskList = "task.list" - ActionTaskCreate = "task.create" - ActionTaskGet = "task.get" - ActionTaskUpdate = "task.update" - ActionTaskRepoUpdate = "task.repository.update" - ActionTaskDelete = "task.delete" - ActionTaskMove = "task.move" - ActionTaskState = "task.state" - ActionTaskArchive = "task.archive" - ActionTaskRunner = "task.runner" - ActionTaskPlanCreate = "task.plan.create" - ActionTaskPlanGet = "task.plan.get" - ActionTaskPlanUpdate = "task.plan.update" - ActionTaskPlanDelete = "task.plan.delete" - ActionTaskPlanRevisionsList = "task.plan.revisions.list" - ActionTaskPlanRevisionGet = "task.plan.revision.get" - ActionTaskPlanRevert = "task.plan.revert" - ActionTaskPlanImplement = "task.plan.implementation_started" - ActionTaskPlanCommentsList = "task.plan.comments.list" - ActionTaskPlanCommentCreate = "task.plan.comments.create" - ActionTaskPlanCommentUpdate = "task.plan.comments.update" - ActionTaskPlanCommentDelete = "task.plan.comments.delete" + ActionTaskList = "task.list" + ActionTaskCreate = "task.create" + ActionTaskGet = "task.get" + ActionTaskUpdate = "task.update" + ActionTaskRepoUpdate = "task.repository.update" + ActionTaskDelete = "task.delete" + ActionTaskMove = "task.move" + ActionTaskState = "task.state" + ActionTaskArchive = "task.archive" + ActionTaskRunner = "task.runner" + ActionTaskPlanCreate = "task.plan.create" + ActionTaskPlanGet = "task.plan.get" + ActionTaskPlanUpdate = "task.plan.update" + ActionTaskPlanDelete = "task.plan.delete" + ActionTaskPlanRevisionsList = "task.plan.revisions.list" + ActionTaskPlanRevisionGet = "task.plan.revision.get" + ActionTaskPlanRevert = "task.plan.revert" + ActionTaskPlanImplement = "task.plan.implementation_started" + ActionTaskPlanCommentsList = "task.plan.comments.list" + ActionTaskPlanCommentCreate = "task.plan.comments.create" + ActionTaskPlanCommentUpdate = "task.plan.comments.update" + ActionTaskPlanCommentDelete = "task.plan.comments.delete" + ActionTaskPreviewFeedbackList = "task.preview_feedback.list" + ActionTaskPreviewFeedbackCreate = "task.preview_feedback.create" + ActionTaskPreviewFeedbackUpdate = "task.preview_feedback.update" + ActionTaskPreviewFeedbackDelete = "task.preview_feedback.delete" + ActionTaskPreviewFeedbackClear = "task.preview_feedback.clear" // Plugin-backed canvas lifecycle notifications. These are owner-scoped // workspace/task events and contain no application content or capabilities. @@ -215,6 +220,7 @@ const ( ActionTaskPlanRevisionCreated = "task.plan.revision.created" ActionTaskPlanReverted = "task.plan.reverted" ActionTaskPlanCommentsChanged = "task.plan.comments.changed" + ActionTaskPreviewFeedbackChanged = "task.preview_feedback.changed" ActionTaskWalkthroughGet = "task.walkthrough.get" ActionTaskWalkthroughDelete = "task.walkthrough.delete" ActionTaskWalkthroughCreated = "task.walkthrough.created" @@ -697,15 +703,16 @@ const ( // Error codes const ( - ErrorCodeBadRequest = "BAD_REQUEST" - ErrorCodeNotFound = "NOT_FOUND" - ErrorCodeInternalError = "INTERNAL_ERROR" - ErrorCodeUnauthorized = "UNAUTHORIZED" - ErrorCodeForbidden = "FORBIDDEN" - ErrorCodeValidation = "VALIDATION_ERROR" - ErrorCodeConflict = "CONFLICT" - ErrorCodeUnavailable = "UNAVAILABLE" - ErrorCodeUnknownAction = "UNKNOWN_ACTION" - ErrorCodePlanCommentsChanged = "plan_comments_changed" - ErrorCodePrimarySessionChanged = "primary_session_changed" + ErrorCodeBadRequest = "BAD_REQUEST" + ErrorCodeNotFound = "NOT_FOUND" + ErrorCodeInternalError = "INTERNAL_ERROR" + ErrorCodeUnauthorized = "UNAUTHORIZED" + ErrorCodeForbidden = "FORBIDDEN" + ErrorCodeValidation = "VALIDATION_ERROR" + ErrorCodeConflict = "CONFLICT" + ErrorCodeUnavailable = "UNAVAILABLE" + ErrorCodeUnknownAction = "UNKNOWN_ACTION" + ErrorCodePlanCommentsChanged = "plan_comments_changed" + ErrorCodePreviewFeedbackChanged = "preview_feedback_changed" + ErrorCodePrimarySessionChanged = "primary_session_changed" ) diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index b165d7dec6..b8cec56296 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -453,6 +453,9 @@ importers: embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.3) + html2canvas-pro: + specifier: 2.4.3 + version: 2.4.3 i18next: specifier: ^26.3.6 version: 26.3.6(typescript@5.9.3) @@ -3292,6 +3295,10 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + baseline-browser-mapping@2.11.21: resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} engines: {node: '>=6.0.0'} @@ -3457,6 +3464,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -4015,6 +4025,10 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + html2canvas-pro@2.4.3: + resolution: {integrity: sha512-ov+EmxDc2abD2uKYpZdFWHkQkl3pu2fH0N98khT1Ojfqr3DJOuGyFIFTSfSyaWxddK9jSJu8sM7aUATxM26mDg==} + engines: {node: '>=16.0.0'} + i18next@26.3.6: resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==} peerDependencies: @@ -5130,6 +5144,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -5280,6 +5297,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + uuid@11.1.1: resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true @@ -8274,6 +8294,8 @@ snapshots: balanced-match@4.0.4: {} + base64-arraybuffer@1.0.2: {} + baseline-browser-mapping@2.11.21: {} brace-expansion@1.1.18: @@ -8433,6 +8455,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + csstype@3.2.3: {} cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): @@ -9083,6 +9109,11 @@ snapshots: html-void-elements@3.0.0: {} + html2canvas-pro@2.4.3: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + i18next@26.3.6(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -10505,6 +10536,10 @@ snapshots: tapable@2.3.0: {} + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -10655,6 +10690,10 @@ snapshots: dependencies: react: 19.2.3 + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + uuid@11.1.1: {} vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): diff --git a/apps/web/components/task/browser-panel.test.tsx b/apps/web/components/task/browser-panel.test.tsx new file mode 100644 index 0000000000..66209348ee --- /dev/null +++ b/apps/web/components/task/browser-panel.test.tsx @@ -0,0 +1,97 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BrowserPanel } from "./browser-panel"; + +const usePreviewCapture = vi.hoisted(() => vi.fn()); +vi.mock("@/hooks/use-preview-capture", () => ({ usePreviewCapture })); +vi.mock("@/hooks/use-preview-console-forwarder", () => ({ + usePreviewConsoleForwarder: vi.fn(), +})); +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + tasks: { activeTaskId: "task-1", activeSessionId: "session-1" }, + processes: { devProcessBySessionId: {}, outputsByProcessId: {} }, + }), +})); + +const capture = { + items: [], + snapshot: { task_id: "task-1", revision: 0, items: [] }, + mode: null, + draft: null, + draftComment: "", + setDraftComment: vi.fn(), + candidateLabel: null, + captureError: null, + isRasterizing: false, + isUploading: false, + isMutating: false, + mutationError: null, + startCapture: vi.fn(), + cancelCapture: vi.fn(), + discardDraft: vi.fn(), + saveDraft: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + clear: vi.fn(), + handleIframeLoad: vi.fn(), +}; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("BrowserPanel preview feedback", () => { + it("offers task-backed annotation for a localhost page through the injected proxy", () => { + usePreviewCapture.mockReturnValue(capture); + render(); + + expect(screen.getByRole("button", { name: "Annotate (0)" })).toBeTruthy(); + expect(usePreviewCapture).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: "task-1", + enabled: true, + source: { + kind: "browser", + sessionId: "session-1", + label: "http://localhost:3000", + }, + }), + ); + }); + + it("keeps task-backed annotation visible when secondary actions overflow", async () => { + class ResizeObserverMock { + constructor(private readonly callback: ResizeObserverCallback) {} + + observe(target: Element) { + Object.defineProperty(target, "getBoundingClientRect", { + configurable: true, + value: () => ({ width: 240 }), + }); + this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver); + } + + disconnect() {} + } + + vi.stubGlobal("ResizeObserver", ResizeObserverMock); + usePreviewCapture.mockReturnValue(capture); + render(); + + await waitFor(() => expect(screen.getByTestId("panel-header-overflow")).toBeTruthy()); + expect(screen.getByRole("button", { name: "Annotate (0)" })).toBeTruthy(); + }); + + it("allows the annotation popover to extend beyond the panel header", () => { + usePreviewCapture.mockReturnValue(capture); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Annotate (0)" })); + const actionSlot = screen.getByTestId("preview-feedback-popover").parentElement?.parentElement; + expect(actionSlot?.className).toContain("overflow-visible"); + expect(actionSlot?.className).not.toContain("overflow-hidden"); + }); +}); diff --git a/apps/web/components/task/browser-panel.tsx b/apps/web/components/task/browser-panel.tsx index 28e6d1a96d..b03aaaa930 100644 --- a/apps/web/components/task/browser-panel.tsx +++ b/apps/web/components/task/browser-panel.tsx @@ -1,7 +1,7 @@ "use client"; import { memo, useState, useEffect, useMemo, useRef } from "react"; -import { IconRefresh, IconExternalLink, IconClick } from "@tabler/icons-react"; +import { IconRefresh, IconExternalLink } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { DropdownMenuItem } from "@kandev/ui/dropdown-menu"; import { Input } from "@kandev/ui/input"; @@ -13,11 +13,11 @@ import { } from "./panel-primitives"; import { useAppStore } from "@/components/state-provider"; import { detectPreviewUrlFromOutput, rewritePreviewUrlForProxy } from "@/lib/preview-url-detector"; -import { InspectButton } from "./inspector/inspect-button"; -import { AnnotationsPanel } from "./inspector/annotations-panel"; -import { useInspectMode } from "@/hooks/use-inspect-mode"; +import { PreviewFeedbackControls } from "./inspector/preview-feedback-controls"; +import { usePreviewCapture } from "@/hooks/use-preview-capture"; import { usePreviewConsoleForwarder } from "@/hooks/use-preview-console-forwarder"; import { openExternalLink } from "@/lib/desktop/external-links"; +import { previewSourceLabel } from "@/lib/preview-feedback-source"; import { useTranslation } from "react-i18next"; function BrowserPanelContent({ @@ -68,7 +68,7 @@ type BrowserPanelProps = { params: Record; }; -function useBrowserPanelUrl(initialUrl: string, useProxy: boolean) { +function useBrowserPanelUrl(initialUrl: string) { const [userUrl, setUserUrl] = useState(initialUrl); const [urlDraft, setUrlDraft] = useState(initialUrl); const [refreshKey, setRefreshKey] = useState(0); @@ -98,12 +98,9 @@ function useBrowserPanelUrl(initialUrl: string, useProxy: boolean) { return rewritePreviewUrlForProxy(directUrl, activeSessionId); }, [directUrl, activeSessionId]); - // Default to the direct URL so the page renders normally; clicking Inspect - // switches to the proxied src so the inspector script can be injected. The - // gateway port-proxy rewrites root-absolute asset references and patches the - // network-facing browser APIs at runtime, so the proxied page works for SPA - // routers and dynamic asset URLs too. - const iframeSrc = useProxy && proxiedUrl ? proxiedUrl : directUrl; + // Eligible local pages stay behind the proxy so task markers and the capture + // bridge survive route changes even while selection mode is inactive. + const iframeSrc = proxiedUrl ?? directUrl; // Key the loading-spinner gate to the underlying URL (and the refresh key), // NOT to `iframeSrc`. Toggling Inspect mode flips `iframeSrc` between the @@ -150,20 +147,29 @@ function useBrowserPanelUrl(initialUrl: string, useProxy: boolean) { export const BrowserPanel = memo(function BrowserPanel({ params }: BrowserPanelProps) { const iframeRef = useRef(null); - const inspect = useInspectMode(iframeRef); usePreviewConsoleForwarder(iframeRef); - // Inspect mode = "load this page through the proxy so the inspector script - // can be injected". Toggling Inspect remounts the iframe with a different src. - const url = useBrowserPanelUrl((params.url as string) || "", inspect.isInspectMode); + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const url = useBrowserPanelUrl((params.url as string) || ""); const showInspect = url.canProxy; + const capture = usePreviewCapture({ + taskId: activeTaskId, + iframeRef, + enabled: showInspect && !!activeTaskId, + source: { + kind: "browser", + sessionId: activeSessionId ?? undefined, + label: previewSourceLabel(url.directUrl), + }, + }); + return ( - - - @@ -172,7 +178,7 @@ export const BrowserPanel = memo(function BrowserPanel({ params }: BrowserPanelP iframeSrc={url.iframeSrc} refreshKey={url.refreshKey} iframeRef={iframeRef} - onIframeLoad={inspect.handleIframeLoad} + onIframeLoad={capture.handleIframeLoad} /> @@ -181,12 +187,14 @@ export const BrowserPanel = memo(function BrowserPanel({ params }: BrowserPanelP function BrowserPanelHeader({ url, - inspect, + capture, showInspect, + enabled, }: { url: ReturnType; - inspect: ReturnType; + capture: ReturnType; showInspect: boolean; + enabled: boolean; }) { const { t } = useTranslation(); const directActions = ( @@ -213,13 +221,7 @@ function BrowserPanelHeader({ > - {showInspect && ( - - )} + {showInspect && } ); const overflowActions = ( @@ -240,12 +242,6 @@ function BrowserPanelHeader({ {t("task:refresh")} - {showInspect && ( - - - {inspect.isInspectMode ? t("task:exitInspectMode") : t("task:enterInspectMode")} - - )} ); @@ -268,6 +264,10 @@ function BrowserPanelHeader({ /> } right={directActions} + rightClassName="overflow-visible" + rightWhenOverflow={ + showInspect ? : undefined + } overflow={overflowActions} overflowAt={420} /> diff --git a/apps/web/components/task/chat-context-items.test.ts b/apps/web/components/task/chat-context-items.test.ts index 5dd5559ffd..f4f0185b51 100644 --- a/apps/web/components/task/chat-context-items.test.ts +++ b/apps/web/components/task/chat-context-items.test.ts @@ -91,6 +91,54 @@ describe("buildContextItems task plan comments", () => { }); }); +describe("buildContextItems task preview feedback", () => { + it("opens the task-scoped collection from the context chip", () => { + const open = vi.fn(); + const items = buildContextItems({ + planContextEnabled: false, + contextFiles: [], + resolvedSessionId: "session-secondary", + removeContextFile: vi.fn(), + unpinFile: vi.fn(), + addPlan: vi.fn(), + promptsMap: new Map(), + pendingCommentsByFile: {}, + handleRemoveCommentFile: vi.fn(), + handleRemoveComment: vi.fn(), + planComments: [], + handleClearPlanComments: vi.fn(), + previewFeedback: [ + { + id: "preview-1", + version: 2, + comment: "Increase contrast", + kind: "element", + source_label: "Checkout preview", + page_route: "/cart", + }, + ], + pendingPRFeedback: [], + handleRemovePRFeedback: vi.fn(), + handleClearPRFeedback: vi.fn(), + walkthroughComments: [], + handleRemoveWalkthroughComment: vi.fn(), + handleClearWalkthroughComments: vi.fn(), + messageComments: [], + handleClearMessageComments: vi.fn(), + taskId: "task-1", + onOpenPreviewFeedback: open, + } as never); + + const item = items.find((candidate) => candidate.kind === "preview-feedback"); + expect(item).toMatchObject({ + kind: "preview-feedback", + label: "1 preview feedback item", + onOpen: open, + }); + expect(item?.onRemove).toBeUndefined(); + }); +}); + describe("buildContextItems file and directory context", () => { function buildItems(contextFiles: never[]) { return buildContextItems({ diff --git a/apps/web/components/task/chat-context-items.ts b/apps/web/components/task/chat-context-items.ts index 461ab91fa5..4673187804 100644 --- a/apps/web/components/task/chat-context-items.ts +++ b/apps/web/components/task/chat-context-items.ts @@ -9,6 +9,7 @@ import type { WalkthroughComment, AgentMessageComment, } from "@/lib/state/slices/comments"; +import type { TaskPreviewFeedback } from "@/lib/types/http"; const PLAN_CONTEXT_PATH = "plan:context"; @@ -27,6 +28,7 @@ export type BuildContextItemsParams = { onOpenFileAtLine?: (filePath: string, repositoryName?: string) => void; planComments: PlanComment[]; handleClearPlanComments: () => void; + previewFeedback?: TaskPreviewFeedback[]; pendingPRFeedback: PRFeedbackComment[]; handleRemovePRFeedback: (commentId: string) => void; handleClearPRFeedback: () => void; @@ -36,6 +38,7 @@ export type BuildContextItemsParams = { messageComments: AgentMessageComment[]; handleClearMessageComments: () => void; taskId: string | null; + onOpenPreviewFeedback?: () => void; }; type FileItemHelpers = { @@ -219,10 +222,11 @@ const KIND_ORDER: Record = { prompt: 2, comment: 3, "plan-comment": 4, - "walkthrough-comment": 5, - "agent-message-comment": 6, - image: 7, - "pr-feedback": 8, + "preview-feedback": 5, + "walkthrough-comment": 6, + "agent-message-comment": 7, + image: 8, + "pr-feedback": 9, }; export function contextItemSortFn(a: ContextItem, b: ContextItem): number { @@ -255,5 +259,16 @@ export function buildContextItems(params: BuildContextItemsParams): ContextItem[ }); } + const previewFeedback = params.previewFeedback ?? []; + if (previewFeedback.length > 0) { + items.push({ + kind: "preview-feedback", + id: "preview-feedback", + label: t("task:previewFeedbackCount", { count: previewFeedback.length }), + items: previewFeedback, + onOpen: params.onOpenPreviewFeedback, + }); + } + return items.sort(contextItemSortFn); } diff --git a/apps/web/components/task/chat/chat-input-area-preview-feedback.test.ts b/apps/web/components/task/chat/chat-input-area-preview-feedback.test.ts new file mode 100644 index 0000000000..4d07f66cd7 --- /dev/null +++ b/apps/web/components/task/chat/chat-input-area-preview-feedback.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { shouldShowPreviewFeedbackFallback } from "./chat-input-area"; + +describe("shouldShowPreviewFeedbackFallback", () => { + it.each([ + ["without a selected session", { resolvedSessionId: null }], + ["with a stopped session", { resolvedSessionId: "session-1", isCompleted: true }], + [ + "with a launch error", + { resolvedSessionId: "session-1", isFailed: true, launchErrorOwned: true }, + ], + ])("returns true %s", (_label, state) => { + expect( + shouldShowPreviewFeedbackFallback({ + taskId: "task-1", + itemCount: 1, + isFailed: false, + isCompleted: false, + executorUnavailable: false, + ...state, + }), + ).toBe(true); + }); + + it("returns false while an active session can accept input", () => { + expect( + shouldShowPreviewFeedbackFallback({ + taskId: "task-1", + resolvedSessionId: "session-1", + itemCount: 1, + isFailed: false, + isCompleted: false, + executorUnavailable: false, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/components/task/chat/chat-input-area.test.tsx b/apps/web/components/task/chat/chat-input-area.test.tsx index 4ccc704f54..48da686b01 100644 --- a/apps/web/components/task/chat/chat-input-area.test.tsx +++ b/apps/web/components/task/chat/chat-input-area.test.tsx @@ -166,6 +166,7 @@ function panelState(overrides = {}) { isAgentBusy: false, activeDocument: null, planComments: [], + previewFeedback: [], pendingPRFeedback: [], walkthroughComments: [], messageComments: [], @@ -456,7 +457,9 @@ describe("useSubmitHandler task plan comments", () => { }); }, ); +}); +describe("useSubmitHandler task feedback references", () => { it("submits displayed IDs and versions without clearing the shared snapshot locally", async () => { const clearSessionPlanComments = vi.fn(); const comment = { @@ -490,6 +493,32 @@ describe("useSubmitHandler task plan comments", () => { }); expect(clearSessionPlanComments).not.toHaveBeenCalled(); }); + + it("freezes the displayed preview feedback versions for the shared send", async () => { + const { result } = renderHook(() => + useSubmitHandler( + panelState({ + previewFeedback: [ + { + id: "preview-1", + version: 6, + kind: "text", + comment: "Keep this runtime text", + }, + ], + }), + ), + ); + + await act(async () => { + await result.current.handleSubmit({ message: "" }); + }); + + expect(handleSendMessageMock).toHaveBeenCalledWith({ + message: "", + previewFeedbackRefs: [{ id: "preview-1", version: 6 }], + }); + }); }); describe("useSubmitHandler deterministic failures", () => { diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index 279136eaa5..2d151d9f93 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -34,9 +34,15 @@ import { resolveComposerWorkspaceId } from "./composer-workspace"; import { t } from "@/lib/i18n"; import { ChatStatusBar, ComposerCIStatus, resolveStatusRowTaskId } from "./chat-status-bar"; import { DynamicRouteRecovery } from "./dynamic-route-recovery"; -import { hasPendingClarification } from "./types"; +import { + hasPendingClarification, + shouldHideChatInputForLaunchError, + shouldRenderStoppedSessionBanner, +} from "./types"; import { toTaskPlanCommentRefs } from "@/lib/plan-comment-refs"; +import { toTaskPreviewFeedbackRefs } from "@/lib/preview-feedback-refs"; import { PlanCommentMigrationNotice } from "@/components/task/plan-comment-migration-notice"; +import { PreviewFeedbackCollectionSurface } from "@/components/task/inspector/preview-feedback-collection"; import { ComposerCollapseButton, ComposerDisclosureRegion, @@ -176,6 +182,7 @@ function usePanelMessageHandler(panelState: ChatPanelState) { pendingClarification, activeDocument, planComments, + previewFeedback, contextFiles, prompts, } = panelState; @@ -188,11 +195,37 @@ function usePanelMessageHandler(panelState: ChatPanelState) { hasPendingClarification: !!pendingClarification, activeDocument, planComments, + previewFeedback, contextFiles, prompts, }); } +function completeChatSubmission(payload: ChatSubmitPayload, panelState: ChatPanelState) { + const { + resolvedSessionId, + pendingPRFeedback, + walkthroughComments, + messageComments, + markCommentsSent, + handleClearPRFeedback, + handleClearWalkthroughComments, + clearEphemeral, + addContextFile, + planModeEnabled, + } = panelState; + if (payload.reviewComments?.length) markCommentsSent(payload.reviewComments.map((c) => c.id)); + if (messageComments.length > 0) markCommentsSent(messageComments.map((c) => c.id)); + if (pendingPRFeedback.length > 0) handleClearPRFeedback(); + if (walkthroughComments.length > 0) handleClearWalkthroughComments(); + if (!resolvedSessionId) return true; + clearEphemeral(resolvedSessionId); + if (planModeEnabled) { + addContextFile(resolvedSessionId, { path: PLAN_CONTEXT_PATH, name: "Plan" }); + } + return true; +} + async function submitChatPayload({ payload, panelState, @@ -207,17 +240,11 @@ async function submitChatPayload({ handleSendMessage: (payload: ChatSubmitPayload) => Promise; }) { const { - resolvedSessionId, planComments, + previewFeedback, pendingPRFeedback, walkthroughComments, messageComments, - markCommentsSent, - handleClearPRFeedback, - handleClearWalkthroughComments, - clearEphemeral, - addContextFile, - planModeEnabled, pendingClarification, } = panelState; const finalMessage = buildSubmitMessage({ @@ -229,10 +256,12 @@ async function submitChatPayload({ messageComments, }); const planCommentRefs = toTaskPlanCommentRefs(planComments); + const previewFeedbackRefs = toTaskPreviewFeedbackRefs(previewFeedback ?? []); const outbound = { ...payload, message: finalMessage, ...(planCommentRefs.length > 0 ? { planCommentRefs } : {}), + ...(previewFeedbackRefs.length > 0 ? { previewFeedbackRefs } : {}), }; let submissionResult: void | boolean; if (onSend && !pendingClarification) { @@ -244,16 +273,7 @@ async function submitChatPayload({ submissionResult = await handleSendMessage(outbound); } if (submissionResult === false) return false; - if (payload.reviewComments?.length) markCommentsSent(payload.reviewComments.map((c) => c.id)); - if (messageComments.length > 0) markCommentsSent(messageComments.map((c) => c.id)); - if (pendingPRFeedback.length > 0) handleClearPRFeedback(); - if (walkthroughComments.length > 0) handleClearWalkthroughComments(); - if (!resolvedSessionId) return true; - clearEphemeral(resolvedSessionId); - if (planModeEnabled) { - addContextFile(resolvedSessionId, { path: PLAN_CONTEXT_PATH, name: "Plan" }); - } - return true; + return completeChatSubmission(payload, panelState); } /** Builds the composer's submit handler, tracking in-flight sends and @@ -455,6 +475,65 @@ function useChatInputDerived( return { planActions, executor, placeholder }; } +export function shouldShowPreviewFeedbackFallback(args: { + taskId: string | null; + resolvedSessionId: string | null; + itemCount: number; + isFailed: boolean; + isCompleted: boolean; + executorUnavailable: boolean; + launchErrorOwned?: boolean; +}) { + if (!args.taskId || args.itemCount === 0) return false; + return ( + !args.resolvedSessionId || + shouldHideChatInputForLaunchError({ + isFailed: args.isFailed, + launchErrorOwned: args.launchErrorOwned, + }) || + shouldRenderStoppedSessionBanner({ + isFailed: args.isFailed, + isCompleted: args.isCompleted, + executorUnavailable: args.executorUnavailable, + launchErrorOwned: args.launchErrorOwned, + }) + ); +} + +function PreviewFeedbackFallbackSurface({ + panelState, + taskId, + executorUnavailable, + launchErrorOwned, +}: { + panelState: ChatPanelState; + taskId: string | null; + executorUnavailable: boolean; + launchErrorOwned?: boolean; +}) { + const [fallbackOpen, setFallbackOpen] = useState(false); + const collection = panelState.previewFeedbackState; + const showTrigger = shouldShowPreviewFeedbackFallback({ + taskId, + resolvedSessionId: panelState.resolvedSessionId, + itemCount: panelState.previewFeedback?.length ?? 0, + isFailed: panelState.isFailed, + isCompleted: panelState.isCompleted, + executorUnavailable, + launchErrorOwned, + }); + if (!collection || !taskId || (panelState.previewFeedback?.length ?? 0) === 0) return null; + return ( + + ); +} + /** * The chat composer: input box, submit/cancel handling, plan-mode toggle, * clarification banner, and the {@link ChatStatusBar} above it. @@ -547,6 +626,12 @@ export function ChatInputArea(props: ChatInputAreaProps) { + ); } diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx index 35f58238fd..79c57d76aa 100644 --- a/apps/web/components/task/chat/chat-input-container.tsx +++ b/apps/web/components/task/chat/chat-input-container.tsx @@ -7,7 +7,7 @@ import type { ReviewComment } from "@/lib/state/slices/comments"; import type { TaskMentionData } from "@/hooks/use-inline-mention"; import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types"; import type { EntityReference } from "@/lib/types/entity-reference"; -import type { TaskPlanCommentRef } from "@/lib/types/http"; +import type { TaskPlanCommentRef, TaskPreviewFeedbackRef } from "@/lib/types/http"; import { useChatInputContainer } from "./use-chat-input-container"; import { SessionStoppedBanner } from "./session-stopped-banner"; import { useSessionRecoveryActions } from "@/hooks/domains/session/use-session-recovery-actions"; @@ -64,6 +64,7 @@ export type ChatSubmitPayload = { inlineTaskMentions?: TaskMentionData[]; entityReferences?: EntityReference[]; planCommentRefs?: TaskPlanCommentRef[]; + previewFeedbackRefs?: TaskPreviewFeedbackRef[]; }; type ChatInputContainerProps = { diff --git a/apps/web/components/task/chat/context-items/context-chip.test.tsx b/apps/web/components/task/chat/context-items/context-chip.test.tsx index 06a653784a..a427081664 100644 --- a/apps/web/components/task/chat/context-items/context-chip.test.tsx +++ b/apps/web/components/task/chat/context-items/context-chip.test.tsx @@ -1,14 +1,15 @@ import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const touchState = vi.hoisted(() => ({ enabled: true })); +const pointerState = vi.hoisted(() => ({ isFinePointer: false })); vi.mock("@/hooks/use-compact-task-chrome", () => ({ useTouchDrawer: () => touchState.enabled, })); vi.mock("@/hooks/use-responsive-breakpoint", () => ({ - useResponsiveBreakpoint: () => ({ isFinePointer: false }), + useResponsiveBreakpoint: () => pointerState, })); vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => (key === "common:open" ? "Open" : key) }), @@ -66,6 +67,13 @@ vi.mock("@kandev/ui/drawer", () => { import { ContextChip } from "./context-chip"; +beforeEach(() => { + touchState.enabled = true; + pointerState.isFinePointer = false; +}); + +afterEach(cleanup); + describe("ContextChip coarse-pointer actions", () => { it("opens the preview drawer and exposes the open action", () => { const onClick = vi.fn(); @@ -79,4 +87,37 @@ describe("ContextChip coarse-pointer actions", () => { expect(onClick).toHaveBeenCalledOnce(); }); + + it("opens a source-independent collection without a navigation action", () => { + render( + Shared feedback} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "3 preview feedback items" })); + + expect(screen.getByText("Shared feedback")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Open" })).toBeNull(); + }); +}); + +describe("ContextChip fine-pointer previews", () => { + it("opens an actionable collection on click when no navigation action exists", () => { + touchState.enabled = false; + pointerState.isFinePointer = true; + render( + Shared feedback} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "3 preview feedback items" })); + + expect(screen.getByText("Shared feedback")).toBeTruthy(); + }); }); diff --git a/apps/web/components/task/chat/context-items/context-chip.tsx b/apps/web/components/task/chat/context-items/context-chip.tsx index d5ee483f13..c241ea9a58 100644 --- a/apps/web/components/task/chat/context-items/context-chip.tsx +++ b/apps/web/components/task/chat/context-items/context-chip.tsx @@ -32,6 +32,7 @@ const ICON_BY_KIND: Record = { file: IconFile, comment: IconMessageDots, "plan-comment": IconMessageDots, + "preview-feedback": IconMessageDots, "walkthrough-comment": IconRoute, image: IconPhoto, "file-attachment": IconFile, @@ -75,6 +76,7 @@ export const ContextChip = memo(function ContextChip({ const { t } = useTranslation(); const Icon = ICON_BY_KIND[kind]; const labelSizingClass = isFinePointer ? "min-h-0" : "min-h-11"; + const isInteractive = !!preview || !!onClick; let iconNode: ReactNode; if (leadingIcon) { iconNode = leadingIcon; @@ -160,7 +162,7 @@ export const ContextChip = memo(function ContextChip({ data-testid={dataTestId} data-path={dataPath} data-is-directory={dataIsDirectory ? "true" : "false"} - className={`group flex items-center gap-1 px-2 py-0.5 text-xs text-muted-foreground bg-muted/50 rounded border border-border/50 ${onClick ? "cursor-pointer hover:bg-muted/80" : ""}`} + className={`group flex items-center gap-1 px-2 py-0.5 text-xs text-muted-foreground bg-muted/50 rounded border border-border/50 ${isInteractive ? "cursor-pointer hover:bg-muted/80" : ""}`} > {labelElement} {controls} @@ -227,6 +229,10 @@ function ControlledHoverChip({ { + if (!onClick) { + setOpen((current) => !current); + return; + } suppressRef.current = true; setOpen(false); setTimeout(() => { diff --git a/apps/web/components/task/chat/context-items/context-item-renderer.tsx b/apps/web/components/task/chat/context-items/context-item-renderer.tsx index c78a884782..a0426ec5e0 100644 --- a/apps/web/components/task/chat/context-items/context-item-renderer.tsx +++ b/apps/web/components/task/chat/context-items/context-item-renderer.tsx @@ -11,6 +11,7 @@ import { FileAttachmentItem } from "./file-attachment-item"; import { PRFeedbackItem } from "./pr-feedback-item"; import { WalkthroughCommentItem } from "./walkthrough-comment-item"; import { AgentMessageCommentItem } from "./agent-message-comment-item"; +import { PreviewFeedbackItem } from "./preview-feedback-item"; export function ContextItemRenderer({ item, @@ -30,6 +31,8 @@ export function ContextItemRenderer({ return ; case "plan-comment": return ; + case "preview-feedback": + return ; case "image": return ; case "file-attachment": diff --git a/apps/web/components/task/chat/context-items/index.ts b/apps/web/components/task/chat/context-items/index.ts index 43d0c45785..59094f7807 100644 --- a/apps/web/components/task/chat/context-items/index.ts +++ b/apps/web/components/task/chat/context-items/index.ts @@ -6,6 +6,7 @@ export { FileItem } from "./file-item"; export { PromptItem } from "./prompt-item"; export { CommentItem } from "./comment-item"; export { PlanCommentItem } from "./plan-comment-item"; +export { PreviewFeedbackItem } from "./preview-feedback-item"; export { PRFeedbackItem } from "./pr-feedback-item"; export { WalkthroughCommentItem } from "./walkthrough-comment-item"; export { ImageItem } from "./image-item"; diff --git a/apps/web/components/task/chat/context-items/preview-feedback-item.tsx b/apps/web/components/task/chat/context-items/preview-feedback-item.tsx new file mode 100644 index 0000000000..4b4606be29 --- /dev/null +++ b/apps/web/components/task/chat/context-items/preview-feedback-item.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { memo } from "react"; +import type { PreviewFeedbackContextItem } from "@/lib/types/context"; +import { ContextChip } from "./context-chip"; + +export const PreviewFeedbackItem = memo(function PreviewFeedbackItem({ + item, +}: { + item: PreviewFeedbackContextItem; +}) { + return ; +}); diff --git a/apps/web/components/task/chat/use-chat-context-items.ts b/apps/web/components/task/chat/use-chat-context-items.ts new file mode 100644 index 0000000000..e49918d01d --- /dev/null +++ b/apps/web/components/task/chat/use-chat-context-items.ts @@ -0,0 +1,106 @@ +import { useMemo } from "react"; +import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts"; +import { usePanelActions } from "@/hooks/use-panel-actions"; +import type { ContextFile } from "@/lib/state/context-files-store"; +import type { ContextItem } from "@/lib/types/context"; +import type { TaskPreviewFeedback } from "@/lib/types/http"; +import { buildContextItems } from "../chat-context-items"; +import type { CommentsState } from "./use-chat-panel-state"; + +type ChatContextItemsOptions = { + planContextEnabled: boolean; + contextFiles: ContextFile[]; + resolvedSessionId: string | null; + removeContextFile: (sid: string, path: string) => void; + unpinFile: (sid: string, path: string) => void; + comments: CommentsState; + previewFeedback: TaskPreviewFeedback[]; + taskId: string | null; + onOpenFile?: (path: string, repo?: string) => void; + onOpenFileAtLine?: (filePath: string, repositoryName?: string) => void; + onOpenPreviewFeedback: () => void; +}; + +export function useChatContextItems(opts: ChatContextItemsOptions) { + const { + planContextEnabled, + contextFiles, + resolvedSessionId, + removeContextFile, + unpinFile, + comments, + previewFeedback, + taskId, + onOpenFile, + onOpenFileAtLine, + onOpenPreviewFeedback, + } = opts; + const { addPlan } = usePanelActions(); + const { prompts } = useCustomPrompts(); + + const promptsMap = useMemo(() => { + const map = new Map(); + for (const p of prompts) map.set(p.id, { content: p.content }); + return map; + }, [prompts]); + + const contextItems = useMemo( + () => + buildContextItems({ + planContextEnabled, + contextFiles, + resolvedSessionId, + removeContextFile, + unpinFile, + addPlan, + promptsMap, + onOpenFile, + pendingCommentsByFile: comments.pendingCommentsByFile, + handleRemoveCommentFile: comments.handleRemoveCommentFile, + handleRemoveComment: comments.handleRemoveComment, + onOpenFileAtLine, + planComments: comments.planComments, + handleClearPlanComments: comments.clearSessionPlanComments, + previewFeedback, + pendingPRFeedback: comments.pendingPRFeedback, + handleRemovePRFeedback: comments.handleRemovePRFeedback, + handleClearPRFeedback: comments.handleClearPRFeedback, + walkthroughComments: comments.walkthroughComments, + handleRemoveWalkthroughComment: comments.handleRemoveWalkthroughComment, + handleClearWalkthroughComments: comments.handleClearWalkthroughComments, + messageComments: comments.messageComments, + handleClearMessageComments: comments.handleClearMessageComments, + taskId, + onOpenPreviewFeedback, + }), + [ + planContextEnabled, + contextFiles, + resolvedSessionId, + removeContextFile, + unpinFile, + addPlan, + promptsMap, + onOpenFile, + comments.pendingCommentsByFile, + comments.handleRemoveCommentFile, + comments.handleRemoveComment, + onOpenFileAtLine, + comments.planComments, + comments.clearSessionPlanComments, + previewFeedback, + comments.pendingPRFeedback, + comments.handleRemovePRFeedback, + comments.handleClearPRFeedback, + comments.walkthroughComments, + comments.handleRemoveWalkthroughComment, + comments.handleClearWalkthroughComments, + comments.messageComments, + comments.handleClearMessageComments, + taskId, + onOpenPreviewFeedback, + ], + ); + + return { contextItems, prompts }; +} diff --git a/apps/web/components/task/chat/use-chat-input-state.ts b/apps/web/components/task/chat/use-chat-input-state.ts index 853c3b85e7..b4d02e0bc5 100644 --- a/apps/web/components/task/chat/use-chat-input-state.ts +++ b/apps/web/components/task/chat/use-chat-input-state.ts @@ -162,7 +162,7 @@ type SubmitDraftArgs = { }; type DraftChatSubmitPayload = Required< - Omit + Omit >; function buildChatSubmitPayload(payload: DraftChatSubmitPayload): ChatSubmitPayload { diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts index 713126c441..37bd63797a 100644 --- a/apps/web/components/task/chat/use-chat-panel-state.ts +++ b/apps/web/components/task/chat/use-chat-panel-state.ts @@ -1,12 +1,10 @@ "use client"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useAppStore } from "@/components/state-provider"; import { useLayoutStore } from "@/lib/state/layout-store"; import { useDockviewStore } from "@/lib/state/dockview-store"; -import { usePanelActions } from "@/hooks/use-panel-actions"; import { useSessionMessages } from "@/hooks/domains/session/use-session-messages"; -import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts"; import { useSessionState } from "@/hooks/domains/session/use-session-state"; import { deriveSessionInputMode, @@ -25,13 +23,12 @@ import { usePendingWalkthroughComments, usePendingAgentMessageComments, } from "@/hooks/domains/comments/use-pending-comments"; -import { buildContextItems } from "../chat-context-items"; +import { useChatContextItems } from "./use-chat-context-items"; import { useAutoDisablePlanMode, useAutoDisableUnsupportedPlanMode, usePlanLayoutHandlers, } from "./use-plan-mode-helpers"; -import type { ContextItem } from "@/lib/types/context"; import type { ReviewComment } from "@/lib/state/slices/comments"; import type { AgentMessageComment, @@ -44,6 +41,7 @@ import type { BuiltInPreset } from "@/lib/state/layout-manager/presets"; import { readLastAgentError } from "@/lib/session-last-agent-error"; import { clarificationTurnIdForSession } from "@/lib/utils/pending-clarification"; import { usePlanCommentMigration } from "@/hooks/domains/comments/use-plan-comment-migration"; +import { usePreviewFeedback } from "@/hooks/domains/comments/use-preview-feedback"; const EMPTY_CONTEXT_FILES: ContextFile[] = []; const PLAN_CONTEXT_PATH = "plan:context"; @@ -351,96 +349,6 @@ export function useCommentsState( }; } -type ChatContextItemsOptions = { - planContextEnabled: boolean; - contextFiles: ContextFile[]; - resolvedSessionId: string | null; - removeContextFile: (sid: string, path: string) => void; - unpinFile: (sid: string, path: string) => void; - comments: CommentsState; - taskId: string | null; - onOpenFile?: (path: string, repo?: string) => void; - onOpenFileAtLine?: (filePath: string, repositoryName?: string) => void; -}; - -function useChatContextItems(opts: ChatContextItemsOptions) { - const { - planContextEnabled, - contextFiles, - resolvedSessionId, - removeContextFile, - unpinFile, - comments, - taskId, - onOpenFile, - onOpenFileAtLine, - } = opts; - const { addPlan } = usePanelActions(); - const { prompts } = useCustomPrompts(); - - const promptsMap = useMemo(() => { - const map = new Map(); - for (const p of prompts) map.set(p.id, { content: p.content }); - return map; - }, [prompts]); - - const contextItems = useMemo( - () => - buildContextItems({ - planContextEnabled, - contextFiles, - resolvedSessionId, - removeContextFile, - unpinFile, - addPlan, - promptsMap, - onOpenFile, - pendingCommentsByFile: comments.pendingCommentsByFile, - handleRemoveCommentFile: comments.handleRemoveCommentFile, - handleRemoveComment: comments.handleRemoveComment, - onOpenFileAtLine, - planComments: comments.planComments, - handleClearPlanComments: comments.clearSessionPlanComments, - pendingPRFeedback: comments.pendingPRFeedback, - handleRemovePRFeedback: comments.handleRemovePRFeedback, - handleClearPRFeedback: comments.handleClearPRFeedback, - walkthroughComments: comments.walkthroughComments, - handleRemoveWalkthroughComment: comments.handleRemoveWalkthroughComment, - handleClearWalkthroughComments: comments.handleClearWalkthroughComments, - messageComments: comments.messageComments, - handleClearMessageComments: comments.handleClearMessageComments, - taskId, - }), - [ - planContextEnabled, - contextFiles, - resolvedSessionId, - removeContextFile, - unpinFile, - addPlan, - promptsMap, - onOpenFile, - comments.pendingCommentsByFile, - comments.handleRemoveCommentFile, - comments.handleRemoveComment, - onOpenFileAtLine, - comments.planComments, - comments.clearSessionPlanComments, - comments.pendingPRFeedback, - comments.handleRemovePRFeedback, - comments.handleClearPRFeedback, - comments.walkthroughComments, - comments.handleRemoveWalkthroughComment, - comments.handleClearWalkthroughComments, - comments.messageComments, - comments.handleClearMessageComments, - taskId, - ], - ); - - return { contextItems, prompts }; -} - function useSessionData( resolvedSessionId: string | null, session: ReturnType["session"], @@ -605,7 +513,10 @@ export function useChatPanelState({ sessionState.taskDescription, ); const comments = useCommentsState(resolvedSessionId, taskId); + const previewFeedbackState = usePreviewFeedback(taskId); const planCommentMigration = usePlanCommentMigration(taskId); + const [previewFeedbackOpen, setPreviewFeedbackOpen] = useState(false); + const onOpenPreviewFeedback = useCallback(() => setPreviewFeedbackOpen(true), []); const planContextEnabled = useMemo( () => contextFiles.some((f) => f.path === PLAN_CONTEXT_PATH), @@ -619,9 +530,11 @@ export function useChatPanelState({ removeContextFile, unpinFile, comments, + previewFeedback: previewFeedbackState.items, taskId, onOpenFile, onOpenFileAtLine, + onOpenPreviewFeedback, }); const todoItems = useSessionTodoItems(resolvedSessionId, sessionData.todoItems); @@ -633,6 +546,10 @@ export function useChatPanelState({ ...contextFilesState, ...sessionData, ...comments, + previewFeedback: previewFeedbackState.items, + previewFeedbackState, + previewFeedbackOpen, + setPreviewFeedbackOpen, planCommentMigration, contextItems, planContextEnabled, diff --git a/apps/web/components/task/chat/use-composer-props.ts b/apps/web/components/task/chat/use-composer-props.ts index b57a293803..d38dfae0a7 100644 --- a/apps/web/components/task/chat/use-composer-props.ts +++ b/apps/web/components/task/chat/use-composer-props.ts @@ -57,6 +57,7 @@ export function useComposerProps(args: ComposerPropsArgs) { const supportsSteering = panelState.supportsSteering; const hasContextComments = panelState.planComments.length > 0 || + (panelState.previewFeedback?.length ?? 0) > 0 || panelState.pendingPRFeedback.length > 0 || panelState.walkthroughComments.length > 0 || panelState.messageComments.length > 0; diff --git a/apps/web/components/task/html-preview-content.test.tsx b/apps/web/components/task/html-preview-content.test.tsx index 3f9d28913a..e97b4e459f 100644 --- a/apps/web/components/task/html-preview-content.test.tsx +++ b/apps/web/components/task/html-preview-content.test.tsx @@ -1,8 +1,11 @@ import { cleanup, render, screen } from "@testing-library/react"; import { TooltipProvider } from "@kandev/ui/tooltip"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { HtmlPreviewContent } from "./html-preview-content"; +const usePreviewCapture = vi.hoisted(() => vi.fn()); +vi.mock("@/hooks/use-preview-capture", () => ({ usePreviewCapture })); + vi.mock("@/components/editors/external-vcs-file-link", () => ({ ExternalVcsFileLink: () => null, useExternalVcsFileStatus: () => null, @@ -12,6 +15,29 @@ afterEach(() => { cleanup(); }); +const capture = { + items: [], + snapshot: { task_id: "task-1", revision: 0, items: [] }, + mode: null, + draft: null, + draftComment: "", + setDraftComment: vi.fn(), + candidateLabel: null, + captureError: null, + isRasterizing: false, + isUploading: false, + isMutating: false, + mutationError: null, + startCapture: vi.fn(), + cancelCapture: vi.fn(), + discardDraft: vi.fn(), + saveDraft: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + clear: vi.fn(), + handleIframeLoad: vi.fn(), +}; + function renderPreview(onTogglePreview = vi.fn(), overrides: Record = {}) { return { onTogglePreview, @@ -20,6 +46,8 @@ function renderPreview(onTogglePreview = vi.fn(), overrides: Record { + beforeEach(() => usePreviewCapture.mockReturnValue(capture)); + it("renders a native iframe for the published preview URL", () => { const { onTogglePreview } = renderPreview(); @@ -80,4 +110,22 @@ describe("HtmlPreviewContent", () => { screen.getByRole("button", { name: /Open in browser panel/i }).click(); expect(onOpenInBrowser).toHaveBeenCalledOnce(); }); + + it("uses the same task-backed annotation controls as the Browser panel", () => { + renderPreview(); + + expect(screen.getByRole("button", { name: "Annotate (0)" })).toBeTruthy(); + expect(usePreviewCapture).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: "task-1", + enabled: true, + source: { + kind: "html_file", + sessionId: "session-1", + label: "reports/index.html", + path: "reports/index.html", + }, + }), + ); + }); }); diff --git a/apps/web/components/task/html-preview-content.tsx b/apps/web/components/task/html-preview-content.tsx index b99e61077a..fb7fe0ec2d 100644 --- a/apps/web/components/task/html-preview-content.tsx +++ b/apps/web/components/task/html-preview-content.tsx @@ -2,7 +2,7 @@ import { SymlinkIndicator } from "@/components/shared/symlink-indicator"; -import { memo } from "react"; +import { memo, useRef } from "react"; import { IconBrowser, IconCode, IconRefresh } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; @@ -18,6 +18,9 @@ import { } from "@/hooks/use-html-preview-publisher"; import { toRelativePath } from "@/lib/utils"; import { useTranslation } from "react-i18next"; +import { usePreviewCapture } from "@/hooks/use-preview-capture"; +import { PreviewFeedbackControls } from "./inspector/preview-feedback-controls"; +import type { PreviewFeedbackController } from "./inspector/preview-feedback-controls"; type HtmlPreviewContentProps = { path: string; @@ -52,13 +55,17 @@ function HtmlPreviewContentToolbar({ onRefresh, onOpenInBrowser, onTogglePreview, -}: Omit) { + capture, +}: Omit & { + capture: PreviewFeedbackController; +}) { const { t } = useTranslation(); const fileStatus = useExternalVcsFileStatus(path, sessionId, repositoryName); const trustWarning = t("task:htmlPreviewTrustedCode"); return ( {toRelativePath(path, worktreePath)} @@ -104,6 +111,7 @@ function HtmlPreviewContentToolbar({ )} + - - - -
    - {annotations.map((a) => ( -
  • - - {a.number} - -
    - {describeAnnotation(a)} - {a.comment && ( -

    - {a.comment} -

    - )} -
    - -
  • - ))} -
- - ); -} diff --git a/apps/web/components/task/inspector/inspect-button.tsx b/apps/web/components/task/inspector/inspect-button.tsx deleted file mode 100644 index 258012d871..0000000000 --- a/apps/web/components/task/inspector/inspect-button.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { IconClick } from "@tabler/icons-react"; -import { Button } from "@kandev/ui/button"; -import { useTranslation } from "react-i18next"; - -interface InspectButtonProps { - active: boolean; - disabled?: boolean; - count?: number; - onToggle: () => void; -} - -export function InspectButton({ active, disabled, count = 0, onToggle }: InspectButtonProps) { - const { t } = useTranslation(); - const title = active ? t("task:inspectingClickToPin") : t("task:inspectClickToPinOrDrag"); - return ( - - ); -} diff --git a/apps/web/components/task/inspector/preview-feedback-collection.test.tsx b/apps/web/components/task/inspector/preview-feedback-collection.test.tsx new file mode 100644 index 0000000000..0c98357f17 --- /dev/null +++ b/apps/web/components/task/inspector/preview-feedback-collection.test.tsx @@ -0,0 +1,265 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TaskPreviewFeedback, TaskPreviewFeedbackSnapshot } from "@/lib/types/http"; + +const touchState = vi.hoisted(() => ({ enabled: false })); + +vi.mock("@/hooks/use-compact-task-chrome", () => ({ + useTouchDrawer: () => touchState.enabled, +})); + +vi.mock("@/components/task/mobile/mobile-picker-sheet", () => ({ + MobilePickerSheet: ({ + open, + title, + headerAction, + children, + }: { + open: boolean; + title: string; + headerAction?: React.ReactNode; + children: React.ReactNode; + }) => + open ? ( +
+

{title}

+ {headerAction} + {children} +
+ ) : null, +})); + +vi.mock("@kandev/ui/popover", async () => { + const React = await import("react"); + const Context = React.createContext<{ + open: boolean; + onOpenChange: (open: boolean) => void; + } | null>(null); + return { + Popover: ({ + open, + onOpenChange, + children, + }: React.PropsWithChildren<{ + open: boolean; + onOpenChange: (open: boolean) => void; + }>) => {children}, + PopoverAnchor: ({ children }: React.PropsWithChildren) => <>{children}, + PopoverContent: ({ children, ...props }: React.HTMLAttributes) => { + const value = React.useContext(Context)!; + return value.open ?
{children}
: null; + }, + }; +}); + +vi.mock("@kandev/ui/drawer", () => ({ + DrawerClose: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock("@/lib/api/domains/attachment-api", () => ({ + attachmentContentUrl: (attachmentId: string) => `/api/v1/attachments/${attachmentId}/content`, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, values?: { count?: number }) => { + const editCommentLabel = "Edit comment"; + const copy: Record = { + "task:previewPendingFeedback": "Pending feedback", + "task:previewClearAll": "Clear all", + "task:previewConfirmClearDescription": + "Remove all pending preview feedback from this task?", + "task:previewCancel": "Cancel", + "task:previewDeleteFeedback": "Delete feedback", + "task:previewEditFeedback": "Edit feedback", + "task:previewEditComment": editCommentLabel, + "task:previewSaveChanges": "Save changes", + "task:previewPendingEmpty": "No pending preview feedback.", + "task:previewFeedbackTitle": "Preview feedback", + "task:previewScreenshotAlt": "Screenshot preview", + "task:previewLoadFailed": "The pending preview feedback could not be loaded. Try again.", + "task:previewMutationFailed": "The feedback could not be saved. Try again.", + "common:close": "Close", + }; + if (key === "task:previewFeedbackCount") + return `${values?.count ?? 0} preview feedback items`; + return copy[key] ?? key; + }, + }), +})); + +const editCommentLabel = "Edit comment"; + +import { + PreviewFeedbackCollection, + PreviewFeedbackCollectionSurface, + type PreviewFeedbackCollectionController, +} from "./preview-feedback-collection"; + +const item: TaskPreviewFeedback = { + id: "feedback-1", + task_id: "task-1", + kind: "element", + comment: "Align this with the price", + source_kind: "browser", + source_session_id: "session-1", + source_label: "Local app", + page_route: "/products", + page_title: "Products", + element_snapshot: { + tag: "button", + id: "checkout", + classes: ["primary"], + outer_html: '', + }, + version: 2, + created_at: "2026-09-15T00:00:00Z", + updated_at: "2026-09-15T00:00:00Z", +}; + +const snapshot: TaskPreviewFeedbackSnapshot = { + task_id: "task-1", + revision: 3, + items: [item], +}; + +function controller( + overrides: Partial = {}, +): PreviewFeedbackCollectionController { + return { + items: [item], + snapshot, + isLoading: false, + loadError: null, + isMutating: false, + mutationError: null, + update: vi.fn().mockResolvedValue(snapshot), + remove: vi.fn().mockResolvedValue(snapshot), + clear: vi.fn().mockResolvedValue({ task_id: "task-1", revision: 4, items: [] }), + ...overrides, + }; +} + +afterEach(cleanup); + +describe("PreviewFeedbackCollection", () => { + beforeEach(() => { + touchState.enabled = false; + }); + + it("edits, deletes, and clears with the displayed item versions and revision", async () => { + const collection = controller(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Edit feedback" })); + const editor = screen.getByRole("textbox", { name: editCommentLabel }); + fireEvent.change(editor, { target: { value: "Use a stronger contrast" } }); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + await waitFor(() => + expect(collection.update).toHaveBeenCalledWith("feedback-1", "Use a stronger contrast", 2), + ); + + fireEvent.click(screen.getByRole("button", { name: "Delete feedback" })); + expect(collection.remove).toHaveBeenCalledWith("feedback-1", 2); + + fireEvent.click(screen.getByRole("button", { name: "Clear all" })); + expect(screen.getByText("Remove all pending preview feedback from this task?")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Clear all" })); + await waitFor(() => expect(collection.clear).toHaveBeenCalledWith(3)); + }); + + it("retains an edit when the server reports a conflict", async () => { + const collection = controller({ + update: vi.fn().mockResolvedValue(null), + mutationError: "The feedback changed.", + }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Edit feedback" })); + const editor = screen.getByRole("textbox", { name: editCommentLabel }); + fireEvent.change(editor, { target: { value: "Keep my typed change" } }); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(collection.update).toHaveBeenCalled()); + expect( + (screen.getByRole("textbox", { name: editCommentLabel }) as HTMLTextAreaElement).value, + ).toBe("Keep my typed change"); + expect(screen.getByRole("alert").textContent).toContain("The feedback could not be saved."); + }); + + it("renders the authorized screenshot content for a persisted item", () => { + const screenshotItem: TaskPreviewFeedback = { + ...item, + id: "feedback-screenshot-1", + kind: "screenshot", + screenshot_attachment_id: "attachment-screenshot-1", + screenshot_attachment: { + attachment_id: "attachment-screenshot-1", + name: "preview.png", + mime_type: "image/png", + kind: "image", + delivery_mode: "prompt", + size_bytes: 128, + state: "claimed", + }, + }; + const collection = controller({ + items: [screenshotItem], + snapshot: { ...snapshot, items: [screenshotItem] }, + }); + render(); + + expect(screen.getByRole("img", { name: "Screenshot preview" }).getAttribute("src")).toBe( + "/api/v1/attachments/attachment-screenshot-1/content", + ); + }); + + it("shows loading errors and the empty collection state", () => { + const collection = controller({ + items: [], + snapshot: { task_id: "task-1", revision: 4, items: [] }, + loadError: "Could not load feedback.", + }); + render(); + + expect(screen.getByRole("alert").textContent).toContain( + "The pending preview feedback could not be loaded.", + ); + expect(screen.getByText("No pending preview feedback.")).toBeTruthy(); + }); +}); + +describe("PreviewFeedbackCollectionSurface", () => { + it("opens the desktop Popover from its task-scoped trigger", () => { + const collection = controller(); + render( + , + ); + + expect(screen.getByRole("button", { name: "1 preview feedback items" })).toBeTruthy(); + }); + + it("uses the touch Drawer and keeps the trigger at a touch-sized target", () => { + touchState.enabled = true; + const collection = controller(); + render( + , + ); + + const trigger = screen.getByRole("button", { name: "1 preview feedback items" }); + expect(trigger.className).toContain("h-11"); + expect(screen.getByTestId("preview-feedback-collection-drawer")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/inspector/preview-feedback-collection.tsx b/apps/web/components/task/inspector/preview-feedback-collection.tsx new file mode 100644 index 0000000000..7f6bbc7b7d --- /dev/null +++ b/apps/web/components/task/inspector/preview-feedback-collection.tsx @@ -0,0 +1,492 @@ +"use client"; + +import { useEffect, useRef, useState, type MutableRefObject, type ReactNode } from "react"; +import { IconEdit, IconMessageDots, IconTrash, IconX } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { DrawerClose } from "@kandev/ui/drawer"; +import { Popover, PopoverAnchor, PopoverContent } from "@kandev/ui/popover"; +import { Textarea } from "@kandev/ui/textarea"; +import { useTranslation } from "react-i18next"; +import { createFocusReturnHandler } from "@/lib/dialog-focus-return"; +import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; +import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet"; +import { attachmentContentUrl } from "@/lib/api/domains/attachment-api"; +import type { TaskPreviewFeedback, TaskPreviewFeedbackSnapshot } from "@/lib/types/http"; + +export type PreviewFeedbackCollectionController = { + items: TaskPreviewFeedback[]; + snapshot?: TaskPreviewFeedbackSnapshot; + isLoading?: boolean; + loadError?: string | null; + isMutating: boolean; + mutationError: string | null; + update: ( + id: string, + comment: string, + expectedVersion: number, + ) => Promise; + remove: (id: string, expectedVersion: number) => Promise; + clear: (expectedRevision: number) => Promise; +}; + +export function previewFeedbackElementLabel( + item: Pick, +) { + const element = item.element_snapshot; + if (!element) return ""; + let label = element.tag; + if (element.id) label += `#${element.id}`; + else if (element.classes[0]) label += `.${element.classes[0]}`; + return `<${label}>`; +} + +function feedbackEvidence(item: TaskPreviewFeedback) { + if (item.kind === "text") return item.selected_text ?? ""; + if (item.kind === "element") return previewFeedbackElementLabel(item); + return item.screenshot_attachment?.name ?? ""; +} + +function FeedbackEvidence({ item }: { item: TaskPreviewFeedback }) { + const { t } = useTranslation(); + if (item.kind === "screenshot" && item.screenshot_attachment) { + return ( +
+ {t("task:previewScreenshotAlt")} +

{item.screenshot_attachment.name}

+
+ ); + } + return

{feedbackEvidence(item)}

; +} + +function PendingFeedbackItem({ + item, + collection, + touch, +}: { + item: TaskPreviewFeedback; + collection: PreviewFeedbackCollectionController; + touch: boolean; +}) { + const { t } = useTranslation(); + const [editing, setEditing] = useState(false); + const [editComment, setEditComment] = useState(""); + const actionClass = touch ? "h-11 min-w-11" : "h-7 min-w-7"; + + function startEditing() { + if (collection.isMutating) return; + setEditing(true); + setEditComment(item.comment); + } + + async function saveEditing() { + const comment = editComment.trim(); + if (!comment || collection.isMutating) return; + const result = await collection.update(item.id, comment, item.version); + if (result) setEditing(false); + } + + return ( +
  • +
    +
    +

    + {item.source_label} · {item.page_route} +

    + +
    + + +
    + {editing ? ( +
    +