From 86f8b07844115a8076c05608c4838ca23fce161e Mon Sep 17 00:00:00 2001 From: Simon Schulte Date: Mon, 18 May 2026 10:38:56 +0200 Subject: [PATCH 1/8] Add structured feedback for open questions --- web/src/App.tsx | 189 ++++++++++++------ web/src/TicketDetailPanel.tsx | 56 +++++- web/src/__tests__/App.test.tsx | 137 +++++++++++++ web/src/__tests__/TicketDetailPanel.test.tsx | 111 ++++++++++ .../__tests__/investigationFeedback.test.ts | 66 ++++++ web/src/investigationFeedback.ts | 105 ++++++++++ web/src/styles.css | 17 +- 7 files changed, 610 insertions(+), 71 deletions(-) create mode 100644 web/src/__tests__/App.test.tsx create mode 100644 web/src/__tests__/TicketDetailPanel.test.tsx create mode 100644 web/src/__tests__/investigationFeedback.test.ts create mode 100644 web/src/investigationFeedback.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 40c6304..12042ae 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { applyAction, cleanupAll, @@ -19,6 +19,7 @@ import { import { AddTicketDialog } from "./AddTicketDialog"; import { DiscoverTicketsModal } from "./DiscoverTicketsModal"; import { ExecutionLogsModal } from "./ExecutionLogsModal"; +import { extractOpenQuestions, formatFeedbackMessage } from "./investigationFeedback"; import { TicketDetailPanel } from "./TicketDetailPanel"; import { TicketList } from "./TicketList"; import { @@ -38,7 +39,9 @@ export function App() { const [details, setDetails] = useState(null); const [selectedRunId, setSelectedRunId] = useState(""); const [selectedArtifactContent, setSelectedArtifactContent] = useState(""); - const [feedbackMessage, setFeedbackMessage] = useState(""); + const [currentFeedbackArtifactContent, setCurrentFeedbackArtifactContent] = useState(""); + const [questionAnswers, setQuestionAnswers] = useState>({}); + const [generalFeedback, setGeneralFeedback] = useState(""); const [activeJobId, setActiveJobId] = useState(""); const [activeJob, setActiveJob] = useState(null); const [loading, setLoading] = useState(false); @@ -64,6 +67,15 @@ export function App() { const availableRepoPaths = useMemo(() => knownRepoPaths(repositoryOptions, tickets), [repositoryOptions, tickets]); const stateRuns = useMemo(() => stateRunsFromDetails(details), [details]); const feedbackAction = useMemo(() => getFeedbackAction(details, selectedSummary), [details, selectedSummary]); + const currentRunId = details?.state.current_run_id ?? ""; + const currentRun = useMemo( + () => stateRuns.find((run) => run.id === currentRunId) ?? null, + [currentRunId, stateRuns] + ); + const openQuestions = useMemo( + () => extractOpenQuestions(currentFeedbackArtifactContent), + [currentFeedbackArtifactContent] + ); const selectedSummaryRef = useRef(null); const activeJobIdRef = useRef(""); @@ -72,6 +84,58 @@ export function App() { const prevLastRunIdRef = useRef(""); const reconnectErrorMessage = "event stream connection lost; reconnecting"; + const handleServerEvent = useEffectEvent(async (evt: ServerEvent) => { + const selected = selectedSummaryRef.current; + const trackedJobID = activeJobIdRef.current; + if (evt.type === "job" && evt.job_id && trackedJobID && evt.job_id === trackedJobID) { + const status = evt.status ?? ""; + if (status === "failed") { + try { + const job = await getJob(evt.job_id); + setActiveJob(job); + } catch (err) { + setError(err instanceof Error ? err.message : "failed to refresh job"); + } finally { + setActiveJobId(""); + } + } else if (status === "done") { + setActiveJob(null); + setActiveJobId(""); + } else if (status === "queued" || status === "running") { + setActiveJob((current) => { + if (!current) { + return current; + } + return { ...current, status }; + }); + } + } + + let needsFullRefresh = evt.type === "repo_tickets_synced"; + setTickets((current) => { + const ticketUpdate = applyTicketEvent(current, evt); + if (ticketUpdate.needsFullRefresh) { + needsFullRefresh = true; + } + return ticketUpdate.tickets; + }); + if (needsFullRefresh) { + scheduleFullRefresh(); + } + + if ( + selected && + evt.type === "ticket_updated" && + evt.repo_path === selected.repo_path && + evt.ticket_number === selected.ticket_number + ) { + await refreshTicketDetails(selected.repo_path, selected.ticket_number, false); + if (showLogsModalRef.current) { + await refreshExecutionLogs(selected.repo_path, selected.ticket_number); + } + } + }); + useEffect(() => { selectedSummaryRef.current = selectedSummary; }, [selectedSummary]); @@ -102,13 +166,16 @@ export function App() { } ); return () => stream.close(); - }, []); + }, [handleServerEvent]); useEffect(() => { if (!selectedSummary) { setDetails(null); setSelectedRunId(""); setSelectedArtifactContent(""); + setCurrentFeedbackArtifactContent(""); + setQuestionAnswers({}); + setGeneralFeedback(""); setArtifactLoading(false); setShowLogsModal(false); setExecutionLogs([]); @@ -116,7 +183,7 @@ export function App() { return; } void refreshTicketDetails(selectedSummary.repo_path, selectedSummary.ticket_number); - }, [selectedSummary?.repo_path, selectedSummary?.ticket_number]); + }, [selectedSummary]); useEffect(() => { if (stateRuns.length === 0) { @@ -182,6 +249,44 @@ export function App() { }; }, [selectedRunId, selectedSummary, stateRuns]); + useEffect(() => { + if (!selectedSummary || !currentRun) { + setCurrentFeedbackArtifactContent(""); + return; + } + const artifactRef = currentRun.artifact_ref || currentRun.log_ref; + if (!artifactRef) { + setCurrentFeedbackArtifactContent(""); + return; + } + if (selectedRunId === currentRun.id) { + setCurrentFeedbackArtifactContent(selectedArtifactContent); + return; + } + + let cancelled = false; + void getArtifact(selectedSummary.repo_path, selectedSummary.ticket_number, artifactRef) + .then((content) => { + if (!cancelled) { + setCurrentFeedbackArtifactContent(content); + } + }) + .catch((err) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : "failed to load feedback artifact"); + setCurrentFeedbackArtifactContent(""); + } + }); + return () => { + cancelled = true; + }; + }, [currentRun, selectedRunId, selectedArtifactContent, selectedSummary]); + + useEffect(() => { + setQuestionAnswers({}); + setGeneralFeedback(""); + }, [selectedSummary?.repo_path, selectedSummary?.ticket_number, currentRunId]); + useEffect(() => { if (!showLogsModal || !selectedSummary) { return; @@ -207,7 +312,7 @@ export function App() { return () => { cancelled = true; }; - }, [showLogsModal, selectedSummary?.repo_path, selectedSummary?.ticket_number]); + }, [showLogsModal, selectedSummary]); async function refreshTickets(showLoader = true) { if (showLoader) { @@ -263,58 +368,6 @@ export function App() { } } - async function handleServerEvent(evt: ServerEvent) { - const selected = selectedSummaryRef.current; - const trackedJobID = activeJobIdRef.current; - if (evt.type === "job" && evt.job_id && trackedJobID && evt.job_id === trackedJobID) { - const status = evt.status ?? ""; - if (status === "failed") { - try { - const job = await getJob(evt.job_id); - setActiveJob(job); - } catch (err) { - setError(err instanceof Error ? err.message : "failed to refresh job"); - } finally { - setActiveJobId(""); - } - } else if (status === "done") { - setActiveJob(null); - setActiveJobId(""); - } else if (status === "queued" || status === "running") { - setActiveJob((current) => { - if (!current) { - return current; - } - return { ...current, status }; - }); - } - } - - let needsFullRefresh = evt.type === "repo_tickets_synced"; - setTickets((current) => { - const ticketUpdate = applyTicketEvent(current, evt); - if (ticketUpdate.needsFullRefresh) { - needsFullRefresh = true; - } - return ticketUpdate.tickets; - }); - if (needsFullRefresh) { - scheduleFullRefresh(); - } - - if ( - selected && - evt.type === "ticket_updated" && - evt.repo_path === selected.repo_path && - evt.ticket_number === selected.ticket_number - ) { - await refreshTicketDetails(selected.repo_path, selected.ticket_number, false); - if (showLogsModalRef.current) { - await refreshExecutionLogs(selected.repo_path, selected.ticket_number); - } - } - } - function scheduleFullRefresh() { if (fullRefreshScheduledRef.current) { return; @@ -402,7 +455,11 @@ export function App() { } function submitFeedback() { - if (!selectedSummary || !feedbackAction || !feedbackMessage.trim()) { + if (!selectedSummary || !feedbackAction) { + return; + } + const message = formatFeedbackMessage(openQuestions, questionAnswers, generalFeedback); + if (!message.trim()) { return; } void queueAction(() => @@ -410,15 +467,20 @@ export function App() { selectedSummary.repo_path, selectedSummary.ticket_number, feedbackAction.label, - feedbackMessage + message ) ).then((ok) => { if (ok) { - setFeedbackMessage(""); + setQuestionAnswers({}); + setGeneralFeedback(""); } }); } + function updateQuestionAnswer(index: number, value: string) { + setQuestionAnswers((current) => ({ ...current, [String(index)]: value })); + } + function applyNamedAction(label: string) { if (!selectedSummary) { return; @@ -522,8 +584,13 @@ export function App() { artifactLoading={artifactLoading} feedbackAction={feedbackAction} feedbackMessage={feedbackMessage} + openQuestions={feedbackAction ? openQuestions : []} + questionAnswers={questionAnswers} + generalFeedback={generalFeedback} + isRunning={!!activeJobId} onSelectRun={setSelectedRunId} - onFeedbackMessageChange={setFeedbackMessage} + onQuestionAnswerChange={updateQuestionAnswer} + onGeneralFeedbackChange={setGeneralFeedback} onSubmitFeedback={submitFeedback} onApplyAction={applyNamedAction} onOpenLogs={() => setShowLogsModal(true)} diff --git a/web/src/TicketDetailPanel.tsx b/web/src/TicketDetailPanel.tsx index ccf2576..fa4d297 100644 --- a/web/src/TicketDetailPanel.tsx +++ b/web/src/TicketDetailPanel.tsx @@ -13,8 +13,13 @@ type TicketDetailPanelProps = { artifactLoading: boolean; feedbackAction?: ActionInfo; feedbackMessage: string; + openQuestions: string[]; + questionAnswers: Record; + generalFeedback: string; + isRunning: boolean; onSelectRun: (runId: string) => void; - onFeedbackMessageChange: (value: string) => void; + onQuestionAnswerChange: (index: number, value: string) => void; + onGeneralFeedbackChange: (value: string) => void; onSubmitFeedback: () => void; onApplyAction: (label: string) => void; onOpenLogs: () => void; @@ -32,8 +37,13 @@ export function TicketDetailPanel({ artifactLoading, feedbackAction, feedbackMessage, + openQuestions, + questionAnswers, + generalFeedback, + isRunning, onSelectRun, - onFeedbackMessageChange, + onQuestionAnswerChange, + onGeneralFeedbackChange, onSubmitFeedback, onApplyAction, onOpenLogs, @@ -111,13 +121,41 @@ export function TicketDetailPanel({ onSubmitFeedback(); }} > -