From efea18aab175a17c72246a27f72558f4d655f9cf Mon Sep 17 00:00:00 2001 From: sumit Date: Thu, 13 Aug 2026 22:37:02 +0530 Subject: [PATCH 1/6] fix(dashboards): skip step-trace UI for chat-only agent replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain "hi" in the dashboard builder chat unconditionally emitted the full build-pipeline trace ("Handing off to the DeepSQL agent…" → "Agent is grounding, writing SQL, and coding the dashboard…"), because DashboardAgentService emitted the first step before classifying intent. Move the chat-only classification ahead of any step emission so a chat-only turn shows nothing but the generic "Working" spinner; the real build steps still fire for actual dashboard requests. Also drop the frontend's placeholder trace line ("Consulting the brain…") shown while no steps have arrived yet, since it's the same kind of misleading progress text for a turn that isn't building anything. --- .../service/DashboardAgentService.java | 8 ++-- .../sections/DashboardWorkspace.jsx | 37 +++++++++---------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/backend/src/main/java/com/dbaagent/service/DashboardAgentService.java b/backend/src/main/java/com/dbaagent/service/DashboardAgentService.java index 15e0c05..a361eed 100644 --- a/backend/src/main/java/com/dbaagent/service/DashboardAgentService.java +++ b/backend/src/main/java/com/dbaagent/service/DashboardAgentService.java @@ -74,7 +74,6 @@ public Map generate(String connectionId, String prompt, Object c List> trace = new ArrayList<>(); StepListener l = listener == null ? StepListener.NOOP : listener; - emit(l, trace, "grounding", "Handing off to the DeepSQL agent…"); String username = accessControlService.requireCurrentUsername(); String profile = agentBridgeService.ensureProfileForUser(username, connectionId); // Fresh session per generation — an isolated coding task, not the user's chat thread. @@ -87,12 +86,12 @@ public Map generate(String connectionId, String prompt, Object c // only skip it for something that plainly isn't one (a greeting, a question // about the tool itself). Answering "hi" by grounding on the schema, writing // SQL, and self-reviewing an HTML document is where the multi-minute replies - // to trivial messages came from. + // to trivial messages came from. Classify BEFORE emitting any step: a chat-only + // turn should show nothing but the generic "Working" spinner, not a "Handing off + // to the DeepSQL agent" trace that implies a build is underway. if (isChatOnly(prompt)) { - emit(l, trace, "planning", "Replying…"); AgentChatClient.AgentReply chatReply = agentChatClient.sendAndAwait(sessionId, buildChatTask(prompt)); if (chatReply.ok() && chatReply.text() != null && !chatReply.text().isBlank()) { - emit(l, trace, "done", "Replied"); Map chat = new LinkedHashMap<>(); chat.put("chat", true); chat.put("reply", chatReply.text().trim()); @@ -106,6 +105,7 @@ public Map generate(String connectionId, String prompt, Object c // rather than surfacing a failure for what might be a legitimate request. } + emit(l, trace, "grounding", "Handing off to the DeepSQL agent…"); emit(l, trace, "planning", "Agent is grounding, writing SQL, and coding the dashboard…"); AgentChatClient.AgentReply reply = agentChatClient.sendAndAwait( sessionId, buildTask(connectionId, prompt, currentConfig)); diff --git a/src/components/sections/DashboardWorkspace.jsx b/src/components/sections/DashboardWorkspace.jsx index bbfc985..684b810 100644 --- a/src/components/sections/DashboardWorkspace.jsx +++ b/src/components/sections/DashboardWorkspace.jsx @@ -228,26 +228,25 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose }) {elapsed}s -
- {(steps.length === 0 - ? [{ type: 'grounding', message: 'Consulting the brain…' }] - : steps.slice(-7) - ).map((s, i, arr) => { - const Icon = STEP_ICON[s.type] || ClipboardCheck - const last = i === arr.length - 1 - return ( -
- - - {last ? : } + {steps.length > 0 && ( +
+ {steps.slice(-7).map((s, i, arr) => { + const Icon = STEP_ICON[s.type] || ClipboardCheck + const last = i === arr.length - 1 + return ( +
+ + + {last ? : } + + {i < arr.length - 1 && } - {i < arr.length - 1 && } - - {s.message} -
- ) - })} -
+ {s.message} +
+ ) + })} +
+ )} )} From 763cf31585c39a4ab6c792d91acee79ab9dea45e Mon Sep 17 00:00:00 2001 From: sumit Date: Thu, 13 Aug 2026 22:37:02 +0530 Subject: [PATCH 2/6] docs: correct dev admin credentials in CLAUDE.md The documented "admin/admin" dev login doesn't exist: AuthController requires a real User row matched by email (not username), and SECURITY_AUTH_ENABLED=false only bypasses JWT/MCP token validation, not the login form. A fresh database has no admin account at all. Document the actual bootstrap flow (POST /users/admin/bootstrap with ADMIN_BOOTSTRAP_SECRET) instead. --- CLAUDE.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index cde5cf7..354e1bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,23 @@ npm run build # Build (dev) npm run build:production # Build (prod) ``` -**Dev credentials**: admin/admin (auth bypass in dev mode) +**Dev credentials**: There is no baked-in admin/admin login — `AuthController.login` requires a +real `User` row matched by **email**, not username, so a fresh database (new Postgres volume) +has no account to log in with at all. `SECURITY_AUTH_ENABLED=false` only bypasses JWT/MCP token +*validation* (`JwtAuthenticationFilter`, `McpTokenAuthenticationFilter`); it does not create a +user or skip the login form. Create the first admin via the bootstrap endpoint, gated by +`SECURITY_ADMIN_BOOTSTRAP_ENABLED=true` + `ADMIN_BOOTSTRAP_SECRET`, and only callable from +localhost: + +```bash +curl -X POST http://localhost:8080/api/users/admin/bootstrap \ + -H "Content-Type: application/json" \ + -H "X-Admin-Bootstrap-Secret: $ADMIN_BOOTSTRAP_SECRET" \ + -d '{"email":"admin@localhost","password":""}' +``` + +Then log in with that **email** (not `admin`) and password. `POST /users/admin/reset` (same +header) replaces the existing admin if you need to rotate the password. ### Database From c325b5b883f546e7af1d59a81b1d1790fd86a925 Mon Sep 17 00:00:00 2001 From: sumit Date: Fri, 14 Aug 2026 10:23:12 +0530 Subject: [PATCH 3/6] feat(dashboards): persist chat-turn state server-side, not just client-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dashboard generation used to be saved only by the FRONTEND, on receiving the SSE done/chat event — so a closed or reloaded tab discarded a turn the backend had already finished computing, even though the agent turn itself (a detached virtual thread) already kept running to completion regardless of client disconnection. Move persistence into the backend code path itself: SavedDashboard gains a generationStatus (IDLE/RUNNING) and generationStartedAt, and DashboardGenerationController now calls into new SavedDashboardService methods (beginGenerationTurn/appendAgentReply/completeBuildTurn/ appendErrorReply) at the same points it already branches on the outcome — recording the user's message and flipping to RUNNING before the slow agent work starts, and persisting the result when it finishes, independent of whether the SSE client is still connected. A new `created` SSE event fires immediately so even a brand-new, never-saved dashboard is durably addressable within one fast round-trip, well before a build finishes. Guards a real generation failure from being confused with the client simply being gone by the time the final SSE send is attempted (that must never turn an already-persisted success into a recorded error), rejects a dashboardId that doesn't belong to the request's connectionId, and treats a RUNNING status stale beyond 20 minutes as abandoned so a crashed backend can't block a legitimate retry forever. --- .../DashboardGenerationController.java | 89 ++++++++-- .../com/dbaagent/model/SavedDashboard.java | 18 +++ .../service/SavedDashboardService.java | 152 ++++++++++++++++++ .../V111__add_dashboard_generation_status.sql | 6 + 4 files changed, 254 insertions(+), 11 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V111__add_dashboard_generation_status.sql diff --git a/backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java b/backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java index 0ce3faf..6cff915 100644 --- a/backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java +++ b/backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java @@ -1,6 +1,8 @@ package com.dbaagent.controller; +import com.dbaagent.model.SavedDashboard; import com.dbaagent.service.DashboardAgentService; +import com.dbaagent.service.SavedDashboardService; import com.dbaagent.service.security.AccessControlService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,6 +20,7 @@ import java.io.IOException; import java.util.Map; +import java.util.UUID; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -44,6 +47,7 @@ public class DashboardGenerationController { private final DashboardAgentService dashboardAgentService; private final AccessControlService accessControlService; + private final SavedDashboardService savedDashboardService; @PostMapping("/generate") public ResponseEntity generate(@RequestBody GenerateRequest request) { @@ -69,12 +73,38 @@ public ResponseEntity generate(@RequestBody GenerateRequest request) { public SseEmitter generateStream(@RequestBody GenerateRequest request) { requireValid(request); accessControlService.assertCanReadConnectionContent(request.connectionId()); + SseEmitter emitter = new SseEmitter(600_000L); + + // Resolve (or create) the target dashboard and record the user's message + // SYNCHRONOUSLY, before any slow agent work starts — this is what lets a + // reload mid-generation see "still working" (generationStatus=RUNNING) + // instead of nothing at all, even for a brand-new, never-saved dashboard. + // See SavedDashboardService's "Server-owned chat-turn persistence" section. + final SavedDashboard dashboard; + try { + dashboard = savedDashboardService.beginGenerationTurn( + request.dashboardId(), request.connectionId(), request.prompt()); + } catch (IllegalArgumentException | IllegalStateException e) { + sendErrorAndComplete(emitter, e.getMessage()); + return emitter; + } + // The frontend needs this id right away (not just at the end) so a + // brand-new dashboard is addressable — e.g. by a reload — well before + // the potentially multi-minute build finishes. + try { + emitter.send(SseEmitter.event().name("created") + .data(Map.of("dashboardId", dashboard.getId().toString()))); + } catch (IOException ignore) { + // Client already gone before we even started streaming — fine, the + // turn is already durably recorded; the work below still runs and + // persists its result regardless of this connection. + } + // Coding a whole dashboard (ground + verify every query + write the HTML) can // run for minutes. Give it real headroom (10 min) and keep the stream alive // with a heartbeat — otherwise it emits only 3 step events and the long idle // gap gets cut by nginx/emitter timeouts before `done`, surfacing to the user // as "Generation ended unexpectedly". - SseEmitter emitter = new SseEmitter(600_000L); ScheduledExecutorService heartbeat = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "dashboard-generate-hb"); t.setDaemon(true); @@ -106,21 +136,47 @@ public SseEmitter generateStream(@RequestBody GenerateRequest request) { // `done` event with a real artifact — the FE's done handler always // appends "Done — built…" and auto-saves. A dedicated `chat` event // keeps that path from swallowing out-of-context messages. - if (Boolean.TRUE.equals(config.get("chat"))) { - emitter.send(SseEmitter.event().name("chat") - .data(Map.of( - "success", true, - "reply", String.valueOf(config.getOrDefault("reply", "")), - "dashboardConfig", config))); - } else { - emitter.send(SseEmitter.event().name("done") - .data(Map.of("success", true, "dashboardConfig", config))); + boolean chatOnly = Boolean.TRUE.equals(config.get("chat")); + // Persist BEFORE attempting to notify the client — a client that's + // gone by now must never turn an already-successful result into a + // recorded failure (see the catch block below, which only ever + // handles a real dashboardAgentService.generate() failure, not a + // dead SSE connection at delivery time). + try { + if (chatOnly) { + savedDashboardService.appendAgentReply( + dashboard.getId(), String.valueOf(config.getOrDefault("reply", ""))); + } else { + savedDashboardService.completeBuildTurn(dashboard.getId(), config); + } + } catch (Exception persistErr) { + log.error("Failed to persist completed dashboard turn {}", dashboard.getId(), persistErr); + } + try { + if (chatOnly) { + emitter.send(SseEmitter.event().name("chat") + .data(Map.of( + "success", true, + "reply", String.valueOf(config.getOrDefault("reply", "")), + "dashboardConfig", config))); + } else { + emitter.send(SseEmitter.event().name("done") + .data(Map.of("success", true, "dashboardConfig", config))); + } + } catch (IOException ignore) { + // Client gone by the time the result was ready — already + // persisted above, so this is a no-op, not a failure. } emitter.complete(); } catch (ClientGoneException gone) { emitter.complete(); } catch (Exception e) { log.warn("Streamed dashboard generation failed: {}", e.getMessage()); + try { + savedDashboardService.appendErrorReply(dashboard.getId(), safe(e)); + } catch (Exception persistErr) { + log.error("Failed to persist dashboard generation error {}", dashboard.getId(), persistErr); + } try { emitter.send(SseEmitter.event().name("error") .data(Map.of("success", false, "error", safe(e)))); @@ -134,6 +190,14 @@ public SseEmitter generateStream(@RequestBody GenerateRequest request) { return emitter; } + private static void sendErrorAndComplete(SseEmitter emitter, String message) { + try { + emitter.send(SseEmitter.event().name("error") + .data(Map.of("success", false, "error", message == null ? "Request failed" : message))); + } catch (IOException ignore) { } + emitter.complete(); + } + private static void requireValid(GenerateRequest request) { if (request == null || request.connectionId() == null || request.prompt() == null || request.prompt().isBlank()) { @@ -149,5 +213,8 @@ private static final class ClientGoneException extends RuntimeException { ClientGoneException(Throwable cause) { super(cause); } } - public record GenerateRequest(String connectionId, String prompt, Object currentConfig) { } + // dashboardId is optional — omitting it (a brand-new, never-saved dashboard) + // always creates a new SavedDashboard row, matching the pre-existing default + // behavior for new dashboards. + public record GenerateRequest(String connectionId, String prompt, Object currentConfig, UUID dashboardId) { } } diff --git a/backend/src/main/java/com/dbaagent/model/SavedDashboard.java b/backend/src/main/java/com/dbaagent/model/SavedDashboard.java index 4891202..184735b 100644 --- a/backend/src/main/java/com/dbaagent/model/SavedDashboard.java +++ b/backend/src/main/java/com/dbaagent/model/SavedDashboard.java @@ -77,6 +77,23 @@ public boolean isSharePasswordSet() { @Column(length = 255) private String folder; + // Server-owned "is a generation turn in flight for this dashboard" marker. + // Set to RUNNING the instant a chat submit is accepted (before the slow + // agent work starts) and back to IDLE when it finishes — from the backend + // code path itself, regardless of whether the SSE client that started it + // is still connected. Lets a reload mid-generation distinguish "still + // working" from "answer's ready" without needing to have stayed connected. + // See SavedDashboardService.beginGenerationTurn/appendAgentReply/ + // completeBuildTurn/appendErrorReply. + @Column(nullable = false, length = 16) + private String generationStatus = "IDLE"; + + // When the current RUNNING turn started, so a client can tell a live + // generation from one abandoned by a backend crash (see + // SavedDashboardService.STALE_RUNNING_THRESHOLD). + @Column + private LocalDateTime generationStartedAt; + @CreationTimestamp @Column(nullable = false, updatable = false) private LocalDateTime createdAt; @@ -94,5 +111,6 @@ public boolean isSharePasswordSet() { void applyBooleanDefaults() { if (isPublic == null) isPublic = false; if (isFavorite == null) isFavorite = false; + if (generationStatus == null) generationStatus = "IDLE"; } } diff --git a/backend/src/main/java/com/dbaagent/service/SavedDashboardService.java b/backend/src/main/java/com/dbaagent/service/SavedDashboardService.java index e32aca1..5ca4f15 100644 --- a/backend/src/main/java/com/dbaagent/service/SavedDashboardService.java +++ b/backend/src/main/java/com/dbaagent/service/SavedDashboardService.java @@ -2,14 +2,21 @@ import com.dbaagent.model.SavedDashboard; import com.dbaagent.repository.SavedDashboardRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.security.SecureRandom; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Base64; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -19,9 +26,16 @@ public class SavedDashboardService { private static final SecureRandom RANDOM = new SecureRandom(); + // A RUNNING status left behind by a crashed/killed backend must not block + // a legitimate retry forever — only respected while still fresh. + private static final Duration STALE_RUNNING_THRESHOLD = Duration.ofMinutes(20); + @Autowired private SavedDashboardRepository savedDashboardRepository; + @Autowired + private ObjectMapper objectMapper; + /** Publish a dashboard to the web: mint a token if needed, mark it public. */ @Transactional public SavedDashboard enablePublicShare(UUID id) { @@ -219,4 +233,142 @@ public void deleteDashboardsByConnection(String connectionId) { List dashboards = savedDashboardRepository.findByConnectionIdOrderByCreatedAtDesc(connectionId); savedDashboardRepository.deleteAll(dashboards); } + + // ── Server-owned chat-turn persistence ────────────────────────────────── + // + // A dashboard generation turn used to be persisted only by the FRONTEND, in + // response to receiving the SSE `done`/`chat` event — so closing or + // reloading the tab before that event arrived silently discarded a turn the + // backend had already finished computing. These four methods move + // persistence into the backend code path itself (DashboardGenerationController), + // which runs on a detached virtual thread that keeps going regardless of + // whether the originating SSE client is still connected. Call order per + // turn: beginGenerationTurn (before the slow agent work starts) → exactly + // one of appendAgentReply / completeBuildTurn / appendErrorReply (when it + // finishes). + + /** + * Starts a chat turn: resolves (or creates) the target dashboard and + * appends the user's message, synchronously and fast (before the caller + * kicks off the slow agent work). Marking generationStatus=RUNNING here — + * not after the agent finishes — is what lets a reload mid-generation see + * "still working" instead of nothing at all. + */ + @Transactional + public SavedDashboard beginGenerationTurn(UUID dashboardId, String connectionId, String prompt) { + SavedDashboard dashboard; + if (dashboardId != null) { + dashboard = requireDashboard(dashboardId); + if (!connectionId.equals(dashboard.getConnectionId())) { + throw new IllegalArgumentException("Dashboard does not belong to connection: " + connectionId); + } + if (isFreshlyRunning(dashboard)) { + throw new IllegalStateException("A generation is already running for this dashboard."); + } + } else { + dashboard = new SavedDashboard(); + dashboard.setConnectionId(connectionId); + dashboard.setName(deriveName(prompt)); + dashboard.setDescription(""); + dashboard.setDashboardConfig("{}"); + dashboard.setChatMessages("[]"); + dashboard.setIsFavorite(false); + } + List> messages = parseMessages(dashboard.getChatMessages()); + messages.add(chatMessage("user", prompt)); + dashboard.setChatMessages(writeMessages(messages)); + dashboard.setGenerationStatus("RUNNING"); + dashboard.setGenerationStartedAt(LocalDateTime.now()); + return savedDashboardRepository.save(dashboard); + } + + /** Turn finished as a plain chat reply (e.g. "hi") — no dashboard change. */ + @Transactional + public SavedDashboard appendAgentReply(UUID dashboardId, String replyText) { + SavedDashboard dashboard = requireDashboard(dashboardId); + List> messages = parseMessages(dashboard.getChatMessages()); + messages.add(chatMessage("agent", replyText)); + dashboard.setChatMessages(writeMessages(messages)); + return finishRunning(dashboard); + } + + /** Turn finished as a real build — persists the artifact + its derived title. */ + @Transactional + public SavedDashboard completeBuildTurn(UUID dashboardId, Map config) { + SavedDashboard dashboard = requireDashboard(dashboardId); + try { + dashboard.setDashboardConfig(objectMapper.writeValueAsString(config)); + } catch (Exception e) { + log.error("Failed to serialize dashboard config for {}", dashboardId, e); + } + Object title = config.get("title"); + if (title != null && !String.valueOf(title).isBlank()) { + dashboard.setName(String.valueOf(title)); + } + List> messages = parseMessages(dashboard.getChatMessages()); + messages.add(chatMessage("agent", "Done — built and verified against your data. Saved as a draft — tell me what to change.")); + dashboard.setChatMessages(writeMessages(messages)); + return finishRunning(dashboard); + } + + /** Turn finished as a real generation failure (not a client disconnect — see controller). */ + @Transactional + public SavedDashboard appendErrorReply(UUID dashboardId, String errorText) { + SavedDashboard dashboard = requireDashboard(dashboardId); + List> messages = parseMessages(dashboard.getChatMessages()); + Map msg = chatMessage("agent", "⚠ " + errorText); + msg.put("error", true); + messages.add(msg); + dashboard.setChatMessages(writeMessages(messages)); + return finishRunning(dashboard); + } + + private SavedDashboard finishRunning(SavedDashboard dashboard) { + dashboard.setGenerationStatus("IDLE"); + dashboard.setGenerationStartedAt(null); + return savedDashboardRepository.save(dashboard); + } + + private boolean isFreshlyRunning(SavedDashboard dashboard) { + return "RUNNING".equals(dashboard.getGenerationStatus()) + && dashboard.getGenerationStartedAt() != null + && dashboard.getGenerationStartedAt().isAfter(LocalDateTime.now().minus(STALE_RUNNING_THRESHOLD)); + } + + private SavedDashboard requireDashboard(UUID id) { + return savedDashboardRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Dashboard not found with id: " + id)); + } + + private static Map chatMessage(String role, String text) { + Map m = new LinkedHashMap<>(); + m.put("role", role); + m.put("text", text); + return m; + } + + private static String deriveName(String prompt) { + if (prompt == null || prompt.isBlank()) return "New dashboard"; + String trimmed = prompt.trim(); + return trimmed.length() > 80 ? trimmed.substring(0, 80) : trimmed; + } + + private List> parseMessages(String json) { + if (json == null || json.isBlank()) return new ArrayList<>(); + try { + return objectMapper.readValue(json, new TypeReference>>() { }); + } catch (Exception e) { + log.warn("Failed to parse chatMessages, starting fresh: {}", e.getMessage()); + return new ArrayList<>(); + } + } + + private String writeMessages(List> messages) { + try { + return objectMapper.writeValueAsString(messages); + } catch (Exception e) { + log.error("Failed to serialize chatMessages", e); + return "[]"; + } + } } diff --git a/backend/src/main/resources/db/migration/V111__add_dashboard_generation_status.sql b/backend/src/main/resources/db/migration/V111__add_dashboard_generation_status.sql new file mode 100644 index 0000000..0ee038b --- /dev/null +++ b/backend/src/main/resources/db/migration/V111__add_dashboard_generation_status.sql @@ -0,0 +1,6 @@ +-- Server-owned "is a generation turn in flight" marker per dashboard, so a +-- reload mid-generation can distinguish "still working" from "answer's ready" +-- without depending on the SSE connection that started it staying alive. +-- Idempotent: schema is Hibernate-managed here, so these may already exist. +ALTER TABLE saved_dashboards ADD COLUMN IF NOT EXISTS generation_status VARCHAR(16) NOT NULL DEFAULT 'IDLE'; +ALTER TABLE saved_dashboards ADD COLUMN IF NOT EXISTS generation_started_at TIMESTAMP; From 192c0a6e4e9d1ad19119f9d9706ae837e73ea9be Mon Sep 17 00:00:00 2001 From: sumit Date: Fri, 14 Aug 2026 10:23:33 +0530 Subject: [PATCH 4/6] feat(dashboards): resumable chat across navigation/reload + editable Source view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generation state (chat, streaming steps, the built config) moves out of DashboardWorkspace's component-local useState into a new useDashboardChatStore, keyed by dashboard id (or new: before the first save, with an alias/rekey scheme for the transition). Navigating away no longer aborts the in-flight SSE stream, and on mount, if the persisted dashboard's generationStatus is RUNNING (a turn was in flight when this tab wasn't around, per the paired backend commit), the store polls until it resolves instead of assuming nothing is happening. Also adds: - A Source/Preview toggle with an uncontrolled Monaco HTML editor (defaultValue + remount key, not a controlled value — a controlled value rewrites Monaco's model on every keystroke, resetting the caret mid-word). Apply re-derives the dashboard's title from the edited /<h1> so a manual rename actually propagates to the breadcrumb/gallery, and is disabled while a generation is in flight to avoid racing the backend's own write of the same config. - A Queries panel: DashboardArtifact now reports every query the artifact runs (SQL, row count or error, timing) via a new onQuery prop, wired to a side panel with copy-to-clipboard. dashboardQueryAPI.run now surfaces the backend's actual error message instead of axios's generic "Request failed with status code 400" (the interceptor only reads response.data.message; this endpoint's payload uses `error`). - generateStream's fetch is aborted proactively on the page's `pagehide` event and the resulting failure is dropped rather than surfaced as a chat-history error: a reload/close tears down the request as a bare TypeError, indistinguishable in shape from a dead backend, which would otherwise get permanently written into the saved chat on every reopen. --- src/components/DashboardArtifact.jsx | 21 +- .../sections/DashboardWorkspace.jsx | 363 ++++++++++++------ .../sections/DashboardWorkspace.module.css | 230 +++++++++++ src/lib/api/client.js | 64 ++- src/lib/dashboardGenerator.js | 14 +- src/lib/stores/useDashboardChatStore.js | 305 +++++++++++++++ 6 files changed, 849 insertions(+), 148 deletions(-) create mode 100644 src/lib/stores/useDashboardChatStore.js diff --git a/src/components/DashboardArtifact.jsx b/src/components/DashboardArtifact.jsx index 81f58ab..206439a 100644 --- a/src/components/DashboardArtifact.jsx +++ b/src/components/DashboardArtifact.jsx @@ -97,7 +97,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const REDUCED_MOTION = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches -export default function DashboardArtifact({ connectionId, html, onError, queryFn }) { +export default function DashboardArtifact({ connectionId, html, onError, queryFn, onQuery }) { const iframeRef = useRef(null) const [height, setHeight] = useState(600) const [loaded, setLoaded] = useState(false) @@ -111,6 +111,12 @@ export default function DashboardArtifact({ connectionId, html, onError, queryFn const runQueryRef = useRef(null) runQueryRef.current = queryFn || ((sql, limit, signal) => dashboardQueryAPI.run(connectionId, sql, limit, signal)) + // Log every query the artifact runs (SQL, row count, timing) for the + // Queries panel — same ref-indirection as runQueryRef, since pump/runJob's + // closures are frozen at first render (empty-dep useCallback). + const onQueryRef = useRef(null) + onQueryRef.current = onQuery + function post(msg) { iframeRef.current?.contentWindow?.postMessage(msg, '*') } @@ -124,6 +130,7 @@ export default function DashboardArtifact({ connectionId, html, onError, queryFn }, []) async function runJob(job) { + const startedAt = Date.now() for (let attempt = 0; ; attempt += 1) { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), QUERY_TIMEOUT_MS) @@ -131,6 +138,10 @@ export default function DashboardArtifact({ connectionId, html, onError, queryFn const res = await runQueryRef.current(job.sql, job.limit, controller.signal) clearTimeout(timer) post({ __deepsql: true, type: 'result', id: job.id, columns: res.columns, rows: res.rows }) + onQueryRef.current?.({ + id: job.id, sql: job.sql, status: 'success', + rowCount: res.rows?.length || 0, durationMs: Date.now() - startedAt, timestamp: startedAt, + }) return } catch (err) { clearTimeout(timer) @@ -142,9 +153,11 @@ export default function DashboardArtifact({ connectionId, html, onError, queryFn await sleep(400 * (attempt + 1) + Math.floor(Math.random() * 250)) continue } - post({ - __deepsql: true, type: 'result', id: job.id, - error: timedOut ? 'Timed out' : (err?.message || 'query failed'), + const errorMsg = timedOut ? 'Timed out' : (err?.message || 'query failed') + post({ __deepsql: true, type: 'result', id: job.id, error: errorMsg }) + onQueryRef.current?.({ + id: job.id, sql: job.sql, status: 'error', + rowCount: 0, durationMs: Date.now() - startedAt, timestamp: startedAt, error: errorMsg, }) return } diff --git a/src/components/sections/DashboardWorkspace.jsx b/src/components/sections/DashboardWorkspace.jsx index 684b810..9ea00f3 100644 --- a/src/components/sections/DashboardWorkspace.jsx +++ b/src/components/sections/DashboardWorkspace.jsx @@ -1,14 +1,30 @@ -import { useState, useEffect, useRef, useCallback } from 'react' -import { LineChart, ArrowUp, ChevronLeft, Sparkles, Check, Loader2, Brain, PencilRuler, ClipboardCheck, TrendingUp, Users, PieChart, Layers } from 'lucide-react' +import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react' +import { LineChart, ArrowUp, ChevronLeft, Sparkles, Check, Loader2, Brain, PencilRuler, ClipboardCheck, TrendingUp, Users, PieChart, Layers, Code2, X, Copy, Undo2, Play } from 'lucide-react' import DashboardArtifact from '@/components/DashboardArtifact' import ShareMenu from './ShareMenu' -import { savedDashboardsAPI } from '@/lib/api/client' -import { generateDashboardStream } from '@/lib/dashboardGenerator' +import { useDashboardChatStore, useDashboardSession, useDashboardChatActions } from '@/lib/stores/useDashboardChatStore' import styles from './DashboardWorkspace.module.css' +const Editor = lazy(() => import('@monaco-editor/react')) + // Icon per agent step phase, so the live trace reads at a glance. const STEP_ICON = { grounding: Brain, planning: PencilRuler, validating: ClipboardCheck, done: Check } +// Mirror of DashboardAgentService.extractTitle — a dashboard's name comes from +// the artifact's <title> (then its <h1>). The backend only derives it while +// generating, so a hand-edited source has to re-derive it here: without this, +// editing <title> in the Source tab saves the new HTML but leaves the dashboard +// listed and breadcrumbed under its old name, which reads as "my edit didn't +// save" even though it did. +function titleFromHtml(html, fallback) { + const strip = (s) => s.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim() + const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i) + if (title && strip(title[1])) return strip(title[1]).slice(0, 120) + const h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i) + if (h1 && strip(h1[1])) return strip(h1[1]).slice(0, 120) + return fallback +} + // Starting points shown on a brand-new, empty dashboard — concrete enough to click // and send immediately, so the first screen a user sees isn't just an empty prompt. const EXAMPLE_PROMPTS = [ @@ -20,27 +36,84 @@ const EXAMPLE_PROMPTS = [ // Focused, chrome-less builder. Left = the agent (generate / refine); main = the // live dashboard canvas. The DeepSQL logo and breadcrumb return to the gallery. +// +// Generation state (chat, streaming steps, the built config) lives in +// useDashboardChatStore, not component state — so navigating away from this +// screen mid-build no longer aborts the agent turn or drops the chat. This +// component is a thin subscriber to a "session" keyed by dashboard id (or +// `new:<connectionId>` before the first save); see the store for the key/alias +// scheme that lets a not-yet-saved dashboard survive being rekeyed to a real id. export default function DashboardWorkspace({ connectionId, dashboard, onClose }) { const isNew = !dashboard - const [savedId, setSavedId] = useState(dashboard?.id || null) - const [config, setConfig] = useState(null) - const [messages, setMessages] = useState([]) + const keyRef = useRef(dashboard?.id ? String(dashboard.id) : `new:${connectionId}`) + const key = keyRef.current + + const session = useDashboardSession(key) + const { ensureSession, patchSession, releaseAlias, submitPrompt, resumeIfRunning, persistDraft } = useDashboardChatActions() + const { messages, thinking, steps, startedAt, config, savedId, dirty } = session + const [input, setInput] = useState('') - const [thinking, setThinking] = useState(false) - const [steps, setSteps] = useState([]) const [elapsed, setElapsed] = useState(0) const [saving, setSaving] = useState(false) - const [dirty, setDirty] = useState(false) const [isPublic, setIsPublic] = useState(dashboard?.isPublic || false) + const [viewMode, setViewMode] = useState('preview') // 'preview' | 'source' + const [sourceDraft, setSourceDraft] = useState('') + const [sourceEpoch, setSourceEpoch] = useState(0) // bump = remount the editor with fresh content + const sourceBaseRef = useRef(null) // html the editor was last seeded with + const [showQueries, setShowQueries] = useState(false) + const [queries, setQueries] = useState([]) const scrollRef = useRef(null) - const abortRef = useRef(null) const inputRef = useRef(null) - const chatSyncedRef = useRef(false) // skips the redundant persist right after restore - const savedIdRef = useRef(dashboard?.id || null) // latest id for async callbacks (avoids stale closure double-create) - const messagesRef = useRef([]) - // Cancel any in-flight generation on unmount. - useEffect(() => () => { if (abortRef.current) abortRef.current() }, []) + // Seed this session exactly once per mount — restoring the persisted chat/ + // config for an existing dashboard, or a greeting for a new one. Never + // touches an already-running/already-resumed session (ensureSession no-ops + // if one exists). For a `new:` key whose prior occupant already got saved + // and rekeyed away (a stale alias with no raw session left), release it + // first so this genuinely new dashboard doesn't inherit that chat. + useEffect(() => { + if (isNew) { + const state = useDashboardChatStore.getState() + const hasRawSession = Object.prototype.hasOwnProperty.call(state.sessions, key) + if (!hasRawSession && state.aliases[key]) releaseAlias(key) + ensureSession(key, { + messages: [{ role: 'agent', text: 'Tell me what to chart and I’ll build it — grounded on your schema and business rules. Try “revenue by month” or “top customers by spend”.' }], + }) + return + } + let cfg = {} + try { cfg = typeof dashboard.dashboardConfig === 'string' ? JSON.parse(dashboard.dashboardConfig || '{}') : (dashboard.dashboardConfig || {}) } catch { cfg = {} } + let saved = null + try { saved = dashboard.chatMessages ? JSON.parse(dashboard.chatMessages) : null } catch { saved = null } + // Only a genuinely fresh mount (no in-memory session yet — e.g. the tab was + // closed/reloaded, or this is the first time opening this dashboard) should + // try to resume a still-running generation below. A session that already + // exists (resumed via in-app navigation) already has the live answer, live + // steps, or is already polling — resuming again would restart a poll loop + // needlessly or clobber a result this tab already has. + const alreadyHadSession = !!useDashboardChatStore.getState().sessions[useDashboardChatStore.getState().resolveKey(key)] + ensureSession(key, { + // A saved row can carry a chat-only reply object (from an in-flight chat + // turn that was never a real build) instead of an artifact — rendering + // that as-is would silently show the pristine empty canvas with no + // explanation. Treat it as no build yet instead. + config: cfg.html ? { ...cfg, updatedAt: dashboard.updatedAt || new Date().toISOString() } : null, + savedId: dashboard.id ? String(dashboard.id) : null, + messages: Array.isArray(saved) && saved.length + ? saved.map((m) => ({ ...m, streaming: false })) + : [{ role: 'agent', text: `Here’s “${dashboard.name || 'your dashboard'}”. Ask me to add a chart, change a metric, or filter — the canvas updates live.` }], + }) + // The backend persists each turn itself now (see useDashboardChatStore's + // header comment) — if it's still RUNNING, a turn was in flight when this + // tab wasn't around to receive it live. Poll until it resolves instead of + // assuming nothing is happening. + if (!alreadyHadSession && dashboard.id) { + resumeIfRunning(key, String(dashboard.id), dashboard.generationStatus, dashboard.generationStartedAt) + } + // key/dashboard/isNew are stable for this mount — DashboardsSection always + // fully unmounts/remounts on a different dashboard or connection. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) // Auto-grow the composer with its content (up to a max), like a chat box. useEffect(() => { @@ -50,142 +123,94 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose }) el.style.height = `${Math.min(el.scrollHeight, 140)}px` }, [input]) - // Elapsed-time ticker so a 15-30s build reads as alive, not stuck. + // Elapsed-time ticker so a 15-30s build reads as alive, not stuck. Reads the + // session's absolute startedAt, so re-opening a still-running build shows + // correctly continued elapsed time rather than restarting from 0. useEffect(() => { - if (!thinking) { setElapsed(0); return undefined } - const t0 = Date.now() - const id = setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 1000) + if (!thinking || !startedAt) { setElapsed(0); return undefined } + const tick = () => setElapsed(Math.floor((Date.now() - startedAt) / 1000)) + tick() + const id = setInterval(tick, 1000) return () => clearInterval(id) - }, [thinking]) - - useEffect(() => { - if (isNew) { - setMessages([{ role: 'agent', text: 'Tell me what to chart and I’ll build it — grounded on your schema and business rules. Try “revenue by month” or “top customers by spend”.' }]) - return - } - let cfg = {} - try { cfg = typeof dashboard.dashboardConfig === 'string' ? JSON.parse(dashboard.dashboardConfig || '{}') : (dashboard.dashboardConfig || {}) } catch { cfg = {} } - // A saved row can carry a chat-only reply object (from an in-flight chat turn - // that was never a real build) instead of an artifact — rendering that as-is - // would silently show the pristine empty canvas with no explanation, right next - // to chat history that says "Done — built". Treat it as no build yet instead. - setConfig(cfg.html ? { ...cfg, updatedAt: dashboard.updatedAt || new Date().toISOString() } : null) - chatSyncedRef.current = false - // Restore the persisted per-dashboard chat thread if there is one, so the - // build/edit conversation survives a refresh; otherwise open with a greeting. - let saved = null - try { saved = dashboard.chatMessages ? JSON.parse(dashboard.chatMessages) : null } catch { saved = null } - setMessages(Array.isArray(saved) && saved.length - ? saved.map((m) => ({ ...m, streaming: false })) - : [{ role: 'agent', text: `Here’s “${dashboard.name || 'your dashboard'}”. Ask me to add a chart, change a metric, or filter — the canvas updates live.` }]) - }, [dashboard, isNew]) + }, [thinking, startedAt]) useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight }, [messages, thinking]) - // Persist the chat thread per dashboard as it settles (once the dashboard is - // saved) so the conversation survives a refresh without re-clicking Save. Sends - // only chatMessages, so it never overwrites the saved dashboard HTML. Skips the - // first settled state after (re)opening — those messages were just restored. + // Seed the Source editor when the artifact changes from OUTSIDE the editor — + // a fresh agent build, or opening a saved dashboard. The editor itself is + // deliberately UNCONTROLLED (defaultValue + key, never value): a controlled + // `value` rewrites Monaco's model on every keystroke, which resets the caret + // mid-word, so characters land at the wrong offset or vanish (editing + // `<title>Business Overview` produced `<title>Business s` and a stray `<` at + // offset 0). SqlRunnerTab.js avoids controlled value for the same reason. + // Skipping our own Apply keeps scroll/caret intact while iterating. useEffect(() => { - if (!savedId || thinking || !messages.length) return - if (!chatSyncedRef.current) { chatSyncedRef.current = true; return } - savedDashboardsAPI.updateDashboard(savedId, { chatMessages: JSON.stringify(messages) }).catch(() => {}) - }, [messages, thinking, savedId]) + const html = config?.html || '' + if (html === sourceBaseRef.current) return + sourceBaseRef.current = html + setSourceDraft(html) + setSourceEpoch((e) => e + 1) + }, [config?.html]) + // Query log is per-artifact — a new build/edit reloads the iframe, so its + // query history starts over too (mirrors DashboardArtifact's own reset). + useEffect(() => { setQueries([]) }, [config?.html]) const name = config?.title || (isNew ? 'New dashboard' : dashboard?.name) || 'Dashboard' // "Live" once published to the web; the ShareMenu keeps this in sync. const status = isPublic ? 'live' : 'draft' - - useEffect(() => { messagesRef.current = messages }, [messages]) - useEffect(() => { savedIdRef.current = savedId }, [savedId]) - - // Persist the dashboard as a draft — create the row on first build, update it - // after. Called automatically on every successful generation so a refresh mid- - // creation never loses the dashboard, and by the explicit Save button. - const persistDraft = useCallback(async (cfg, msgs) => { - if (!cfg) return false - const body = { - connectionId, - name: cfg.title || 'Untitled dashboard', - description: cfg.description || '', - dashboardConfig: JSON.stringify(cfg), - chatMessages: JSON.stringify(msgs || messagesRef.current), - isFavorite: false, - } - try { - if (savedIdRef.current) { - await savedDashboardsAPI.updateDashboard(savedIdRef.current, body) - } else { - const res = await savedDashboardsAPI.createDashboard(body) - const created = res?.savedDashboard || res?.dashboard || res - if (created?.id) { savedIdRef.current = created.id; setSavedId(created.id) } - } - chatSyncedRef.current = true - setDirty(false) - return true - } catch (e) { - setDirty(true) // keep the Save button available to retry - throw e - } - }, [connectionId]) + const sourceDirty = sourceDraft !== (config?.html || '') function submit(directPrompt) { const prompt = (directPrompt ?? input).trim() if (!prompt || thinking) return setInput('') - setMessages((m) => [...m, { role: 'user', text: prompt }]) - setSteps([]) - setThinking(true) - if (abortRef.current) abortRef.current() - abortRef.current = generateDashboardStream(connectionId, prompt, config, { - onStep: (s) => setSteps((prev) => [...prev, s]), - onChat: (reply) => { - // Just a reply — e.g. "hi" — not a dashboard change. No save, no config touch. - abortRef.current = null - setThinking(false) - setSteps([]) - setMessages((m) => [...m, { role: 'agent', text: reply || '…' }]) - }, - onDone: (next) => { - abortRef.current = null - setThinking(false) - setSteps([]) - // Belt-and-braces: a chat-shaped payload must never hit the "built" path - // (that appends the canned save line and would clobber a real artifact). - if (!next?.html || next?.chat) { - setMessages((m) => [...m, { role: 'agent', text: next?.reply || '…' }]) - return - } - setConfig(next) - // Auto-save as a draft so a refresh never loses it (create first time, update after). - const updated = [...messagesRef.current, { role: 'agent', text: 'Done — built and verified against your data. Saved as a draft — tell me what to change.' }] - setMessages(updated) - persistDraft(next, updated).catch(() => { - setMessages((m) => [...m, { role: 'agent', text: '⚠ Built, but couldn’t auto-save yet — click Save to keep it.', error: true }]) - }) - }, - onError: (e) => { - abortRef.current = null - setMessages((m) => [...m, { role: 'agent', text: `⚠ ${e?.message || 'Generation failed.'}`, error: true }]) - setThinking(false) - setSteps([]) - }, - }) + submitPrompt(key, connectionId, prompt) } async function save() { if (!config || saving) return setSaving(true) try { - await persistDraft(config, messages) - setMessages((m) => [...m, { role: 'agent', text: 'Saved. Find it on the Dashboards home anytime.' }]) + await persistDraft(key, connectionId, config, messages) + patchSession(key, (cur) => ({ messages: [...cur.messages, { role: 'agent', text: 'Saved. Find it on the Dashboards home anytime.' }] })) } catch (e) { - setMessages((m) => [...m, { role: 'agent', text: `⚠ Couldn’t save: ${e?.response?.data?.message || e?.message || 'error'}`, error: true }]) + patchSession(key, (cur) => ({ messages: [...cur.messages, { role: 'agent', text: `⚠ Couldn’t save: ${e?.response?.data?.message || e?.message || 'error'}`, error: true }] })) } finally { setSaving(false) } } + function applySource() { + // Editing source while the agent is mid-build would race two independent + // writers of the same dashboardConfig — the backend's own completeBuildTurn + // and this Apply — with no ordering guarantee over which lands last. + if (!sourceDirty || !config || thinking) return + sourceBaseRef.current = sourceDraft // our own change — don't remount the editor under the caret + const next = { + ...config, + title: titleFromHtml(sourceDraft, config.title), + html: sourceDraft, + updatedAt: new Date().toISOString(), + } + patchSession(key, (cur) => ({ + config: next, + messages: [...cur.messages, { role: 'agent', text: 'Source edited manually. Saved as a draft — tell me what to change next, or keep editing the source.' }], + })) + persistDraft(key, connectionId, next).catch(() => { + patchSession(key, (cur) => ({ messages: [...cur.messages, { role: 'agent', text: '⚠ Edited, but couldn’t auto-save yet — click Save to keep it.', error: true }] })) + }) + } + + function revertSource() { + const html = config?.html || '' + sourceBaseRef.current = html + setSourceDraft(html) + setSourceEpoch((e) => e + 1) // remount so the editor actually shows the restored text + } + + const logQuery = useCallback((entry) => { setQueries((prev) => [...prev, entry]) }, []) + const copyQuery = (sql) => { navigator.clipboard?.writeText(sql).catch(() => {}) } + return ( <div className={styles.root}> <header className={styles.topbar}> @@ -267,7 +292,93 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose }) <main className={styles.canvas}> {config?.html ? ( - <DashboardArtifact connectionId={connectionId} html={config.html} onError={(msg) => console.warn('Dashboard artifact error:', msg)} /> + <> + <div className={styles.canvasToolbar}> + <div className={styles.viewToggle}> + <button className={viewMode === 'source' ? styles.viewToggleBtnActive : styles.viewToggleBtn} onClick={() => setViewMode('source')}>Source</button> + <button className={viewMode === 'preview' ? styles.viewToggleBtnActive : styles.viewToggleBtn} onClick={() => setViewMode('preview')}>Preview</button> + </div> + <div className={styles.canvasToolbarRight}> + {viewMode === 'source' && ( + <> + <button className={styles.ghostBtn} onClick={revertSource} disabled={!sourceDirty}><Undo2 size={13} /> Revert</button> + <button className={styles.primaryBtnSm} onClick={applySource} disabled={!sourceDirty || thinking} title={thinking ? 'Wait for the current build to finish first' : undefined}><Play size={13} /> Apply</button> + </> + )} + <button + className={showQueries ? styles.queriesBtnActive : styles.queriesBtn} + onClick={() => setShowQueries((v) => !v)} + > + <Code2 size={13} /> Queries{queries.length > 0 ? ` ${queries.length}` : ''} + </button> + </div> + </div> + + <div className={styles.canvasBody}> + <div className={styles.canvasMain} style={{ display: viewMode === 'preview' ? 'block' : 'none' }}> + <DashboardArtifact + connectionId={connectionId} + html={config.html} + onError={(msg) => console.warn('Dashboard artifact error:', msg)} + onQuery={logQuery} + /> + </div> + {viewMode === 'source' && ( + <div className={styles.sourceEditor}> + <Suspense fallback={<div className={styles.editorLoading}>Loading editor…</div>}> + <Editor + key={sourceEpoch} + height="100%" + defaultLanguage="html" + defaultValue={sourceDraft} + onChange={(v) => setSourceDraft(v || '')} + theme="vs-light" + options={{ + minimap: { enabled: false }, + fontSize: 13, + lineNumbers: 'on', + roundedSelection: true, + scrollBeyondLastLine: false, + automaticLayout: true, + tabSize: 2, + wordWrap: 'on', + }} + /> + </Suspense> + </div> + )} + + {showQueries && ( + <aside className={styles.queriesPanel}> + <div className={styles.queriesPanelHead}> + <span>Queries this dashboard runs</span> + <button onClick={() => setShowQueries(false)} aria-label="Close"><X size={14} /></button> + </div> + <div className={styles.queriesPanelList}> + {queries.length === 0 ? ( + <div className={styles.queriesEmpty}>No queries run yet — they’ll show up as widgets load.</div> + ) : ( + queries.map((q) => ( + <div key={q.id} className={styles.queryCard}> + <div className={styles.queryCardHead}> + <span className={q.status === 'error' ? styles.queryBadgeErr : styles.queryBadgeOk}> + {q.status === 'error' ? 'Error' : `${q.rowCount} row${q.rowCount === 1 ? '' : 's'}`} + </span> + <span className={styles.queryTiming}>{q.durationMs} ms</span> + <button className={styles.queryCopyBtn} onClick={() => copyQuery(q.sql)} title="Copy SQL" aria-label="Copy SQL"> + <Copy size={12} /> + </button> + </div> + <pre className={styles.querySql}>{q.sql}</pre> + {q.status === 'error' && <div className={styles.queryErr}>{q.error}</div>} + </div> + )) + )} + </div> + </aside> + )} + </div> + </> ) : ( <div className={styles.newCanvas}> <div className={styles.newCanvasInner}> diff --git a/src/components/sections/DashboardWorkspace.module.css b/src/components/sections/DashboardWorkspace.module.css index b026b25..d874b98 100644 --- a/src/components/sections/DashboardWorkspace.module.css +++ b/src/components/sections/DashboardWorkspace.module.css @@ -442,7 +442,237 @@ flex: 1; min-width: 0; min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.canvasToolbar { + display: flex; + align-items: center; + justify-content: space-between; + height: 44px; + flex-shrink: 0; + padding: 0 16px; + border-bottom: 1px solid #ececec; + background: #fff; +} + +.canvasToolbarRight { + display: flex; + align-items: center; + gap: 8px; +} + +.viewToggle { + display: inline-flex; + background: #f1f0f5; + border-radius: 8px; + padding: 2px; + gap: 2px; +} + +.viewToggleBtn, +.viewToggleBtnActive { + border: none; + background: transparent; + font-size: 12.5px; + font-weight: 500; + padding: 5px 12px; + border-radius: 6px; + cursor: pointer; + color: #6b6b6b; + transition: background 120ms ease-out, color 120ms ease-out; +} +.viewToggleBtnActive { + background: #fff; + color: #111; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); +} + +.ghostBtn, +.primaryBtnSm, +.queriesBtn, +.queriesBtnActive { + display: inline-flex; + align-items: center; + gap: 5px; + height: 28px; + padding: 0 10px; + border-radius: 7px; + font-size: 12.5px; + font-weight: 500; + cursor: pointer; + transition: background 120ms ease-out, border-color 120ms ease-out, color 120ms ease-out; +} + +.ghostBtn, +.queriesBtn { + border: 1px solid #e2e2e2; + background: #fff; + color: #555; +} +.ghostBtn:hover:not(:disabled), +.queriesBtn:hover { + background: #f6f6f6; +} +.ghostBtn:disabled { + opacity: 0.45; + cursor: default; +} + +.primaryBtnSm { + border: none; + background: #534AB7; + color: #fff; +} +.primaryBtnSm:hover:not(:disabled) { background: #463c9f; } +.primaryBtnSm:disabled { background: #d8d5f0; cursor: default; } + +.queriesBtnActive { + border: 1px solid #cfcaf0; + background: #f5f3ff; + color: #463c9f; +} + +.canvasBody { + flex: 1; + min-height: 0; + display: flex; + overflow: hidden; + position: relative; +} + +.canvasMain { + flex: 1; + min-width: 0; + overflow-y: auto; +} + +.sourceEditor { + position: absolute; + inset: 0; + background: #fff; +} + +.editorLoading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: 13px; + color: #999; +} + +.queriesPanel { + width: 340px; + flex-shrink: 0; + border-left: 1px solid #ececec; + background: #fafafa; + display: flex; + flex-direction: column; + min-height: 0; +} + +.queriesPanelHead { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid #ececec; + font-size: 12.5px; + font-weight: 600; + color: #333; + flex-shrink: 0; +} +.queriesPanelHead button { + display: inline-flex; + border: none; + background: transparent; + cursor: pointer; + color: #999; + padding: 2px; + border-radius: 5px; +} +.queriesPanelHead button:hover { background: #ececec; color: #555; } + +.queriesPanelList { + flex: 1; overflow-y: auto; + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.queriesEmpty { + font-size: 12px; + color: #999; + padding: 20px 6px; + text-align: center; + line-height: 1.5; +} + +.queryCard { + border: 1px solid #ececec; + border-radius: 10px; + background: #fff; + padding: 10px 11px; +} + +.queryCardHead { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.queryBadgeOk, +.queryBadgeErr { + font-size: 11px; + font-weight: 600; + padding: 2px 7px; + border-radius: 999px; + flex-shrink: 0; +} +.queryBadgeOk { background: #E1F5EE; color: #0F6E56; } +.queryBadgeErr { background: #fdf2f2; color: #9c2b2b; } + +.queryTiming { + font-size: 11px; + color: #a0a0a0; + font-variant-numeric: tabular-nums; +} + +.queryCopyBtn { + margin-left: auto; + display: inline-flex; + border: none; + background: transparent; + color: #999; + cursor: pointer; + padding: 2px; + border-radius: 5px; + flex-shrink: 0; +} +.queryCopyBtn:hover { background: #f0f0f0; color: #555; } + +.querySql { + margin: 0; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11.5px; + line-height: 1.5; + color: #333; + white-space: pre-wrap; + word-break: break-word; + max-height: 160px; + overflow-y: auto; +} + +.queryErr { + margin-top: 6px; + font-size: 11.5px; + color: #9c2b2b; } .newCanvas { diff --git a/src/lib/api/client.js b/src/lib/api/client.js index c1e0663..fdf8b03 100644 --- a/src/lib/api/client.js +++ b/src/lib/api/client.js @@ -2620,13 +2620,27 @@ export const llmAPI = { // + access scope) and posts rows back. Never let the iframe hit the DB directly. export const dashboardQueryAPI = { run: async (connectionId, sql, limit, signal) => { - const { data } = await apiClient.post( - "/api/dashboards/query", - { connectionId, sql, limit }, - { signal }, - ); - if (!data?.success) throw new Error(data?.error || "Query failed"); - return { columns: data.columns || [], rows: data.rows || [] }; + try { + const { data } = await apiClient.post( + "/api/dashboards/query", + { connectionId, sql, limit }, + { signal }, + ); + if (!data?.success) throw new Error(data?.error || "Query failed"); + return { columns: data.columns || [], rows: data.rows || [] }; + } catch (e) { + // A non-2xx (e.g. a 400 for bad SQL) makes axios reject before the + // success-check above ever runs. The apiClient response interceptor + // already rewrote it into an Error whose .message only looks at + // response.data.message — this endpoint's payload uses `error`, not + // `message`, so it falls back to the generic "Request failed with + // status code 400" and stashes the real body in .responseData instead. + // Useless for the Queries panel, whose whole point is showing the real + // reason a widget's query failed. + const serverMessage = e?.responseData?.error; + if (serverMessage && serverMessage !== e.message) throw new Error(serverMessage); + throw e; + } }, }; @@ -2676,10 +2690,10 @@ export const dashboardGenAPI = { // body carries the current dashboard config when refining. Handlers: // onStep({ type, message }) · onDone({ success, dashboardConfig }) · onError(Error) // Returns an abort fn. - generateStream: (connectionId, prompt, currentConfig, handlers = {}) => { - const { onStep, onDone, onError } = handlers; + generateStream: (connectionId, prompt, currentConfig, dashboardId, handlers = {}) => { + const { onStep, onDone, onError, onCreated } = handlers; const controller = new AbortController(); - const body = JSON.stringify({ connectionId, prompt, currentConfig: currentConfig || null }); + const body = JSON.stringify({ connectionId, prompt, currentConfig: currentConfig || null, dashboardId: dashboardId || null }); const doFetch = () => fetch(`${API_BASE_URL}/api/dashboards/generate/stream`, { method: "POST", @@ -2688,6 +2702,20 @@ export const dashboardGenAPI = { body, signal: controller.signal, }); + // The generation itself now survives a route change (the store that owns + // this call keeps running after the component unmounts) — but a page + // *reload/close* still tears down this fetch out from under us. Calling + // controller.abort() from a pagehide listener does NOT reliably turn this + // into an AbortError first: the browser's own network-stack teardown on + // navigation can reject the fetch with a bare `TypeError: Failed to fetch` + // — indistinguishable in shape from a genuinely dead backend — before our + // abort() call is even processed. Track unload via a flag instead of + // trusting the error's identity, and check the flag in the catch block + // below so an unload-induced failure is dropped rather than surfacing as + // a fake error that gets permanently written into the saved chat history. + let unloading = false; + const markUnloading = () => { unloading = true; controller.abort(); }; + window.addEventListener("pagehide", markUnloading); (async () => { try { let res = await doFetch(); @@ -2723,7 +2751,12 @@ export const dashboardGenAPI = { if (!dataLines.length) return; let data = {}; try { data = JSON.parse(dataLines.join("\n")); } catch { return; } - if (event === "step") onStep && onStep(data); + // Sent immediately, before any step — the backend has already + // resolved (or created) the dashboard and durably recorded the + // user's message server-side, so the caller can adopt this id + // right away rather than waiting for the whole generation to finish. + if (event === "created") onCreated && onCreated(data); + else if (event === "step") onStep && onStep(data); // `chat` = out-of-context reply (hi/thanks); must not fall through to `done` // or the workspace appends the canned "Done — built…" save message. else if (event === "chat") { finished = true; onDone && onDone(data); } @@ -2742,11 +2775,16 @@ export const dashboardGenAPI = { } if (!finished) onError && onError(new Error("Generation ended unexpectedly")); } catch (e) { - if (e?.name === "AbortError") return; + if (e?.name === "AbortError" || unloading) return; onError && onError(e); + } finally { + window.removeEventListener("pagehide", markUnloading); } })(); - return () => controller.abort(); + return () => { + window.removeEventListener("pagehide", markUnloading); + controller.abort(); + }; }, }; diff --git a/src/lib/dashboardGenerator.js b/src/lib/dashboardGenerator.js index 53bbaa5..9a0a9f0 100644 --- a/src/lib/dashboardGenerator.js +++ b/src/lib/dashboardGenerator.js @@ -7,12 +7,16 @@ import { dashboardGenAPI } from '@/lib/api/client' // HTML document — streaming its steps to the UI. The artifact renders in a // sandboxed iframe and fetches data via the read-only deepsql.query bridge. -// Streaming generate. Handlers: onStep({type,message}), onDone(config), onError(Error), -// onChat(replyText) for a plain conversational reply (no dashboard change — e.g. "hi"). -// Returns an abort fn. The final config is the artifact spec +// Streaming generate. Handlers: onCreated({dashboardId}) fired immediately — +// the backend has already resolved/created the dashboard and durably recorded +// the user's message server-side, before any of the slow agent work runs — +// onStep({type,message}), onDone(config), onError(Error), onChat(replyText) +// for a plain conversational reply (no dashboard change — e.g. "hi"). Returns +// an abort fn. The final config is the artifact spec // ({version:3, renderMode:'artifact', title, html}). -export function generateDashboardStream(connectionId, prompt, currentConfig, { onStep, onDone, onError, onChat } = {}) { - return dashboardGenAPI.generateStream(connectionId, prompt, currentConfig, { +export function generateDashboardStream(connectionId, prompt, currentConfig, dashboardId, { onCreated, onStep, onDone, onError, onChat } = {}) { + return dashboardGenAPI.generateStream(connectionId, prompt, currentConfig, dashboardId, { + onCreated, onStep, onDone: (data) => { // Chat-only can arrive as event:chat (reply at top level) or legacy done diff --git a/src/lib/stores/useDashboardChatStore.js b/src/lib/stores/useDashboardChatStore.js new file mode 100644 index 0000000..10d5a19 --- /dev/null +++ b/src/lib/stores/useDashboardChatStore.js @@ -0,0 +1,305 @@ +import { create } from 'zustand' +import { useShallow } from 'zustand/react/shallow' +import { generateDashboardStream } from '@/lib/dashboardGenerator' +import { savedDashboardsAPI } from '@/lib/api/client' + +// Keeps each dashboard workspace's in-flight generation (chat messages, +// streaming steps, the built config, the abort fn) alive in memory across +// component mount/unmount. DashboardWorkspace used to hold all of this in +// component-local useState and abort the SSE stream on unmount — so +// navigating away from the workspace screen mid-build cancelled the agent +// turn and dropped the chat. This module-level store outlives the component +// for as long as the tab stays open; DashboardWorkspace becomes a thin +// subscriber that reads/writes a session instead of owning the state. +// +// A full page close/reload still wipes this module's memory — nothing in JS +// survives that. What survives is the BACKEND's own copy: DashboardGeneration- +// Controller now persists each turn itself (user message on submit, the +// answer on completion) regardless of whether this tab is still connected — +// see beginGenerationTurn/appendAgentReply/completeBuildTurn/appendErrorReply +// in SavedDashboardService. `resumeIfRunning` below is how a freshly (re)opened +// tab picks that back up: if the persisted row says a generation is still +// RUNNING, it polls until the backend's own write flips it back to IDLE, +// instead of assuming nothing is happening. +// +// Sessions are keyed by the dashboard id once one exists, or `new:<connectionId>` +// for a not-yet-saved dashboard (DashboardWorkspace never remounts with a +// different `dashboard`/`connectionId` prop without an unmount in between, so +// that key is stable for a whole mount — see DashboardsSection.jsx). The +// transient `new:` key is aliased to the real id the instant the backend +// resolves/creates one (now typically within one fast round-trip — see +// `onCreated` in submitPrompt — rather than only after a build fully +// completes), so the same mounted component keeps reading live data through +// the rename; `releaseAlias` frees the transient slot on unmount so a LATER +// brand-new dashboard on the same connection doesn't inherit a finished one's +// chat. + +const emptySession = () => ({ + messages: [], + thinking: false, + steps: [], + startedAt: null, + config: null, + savedId: null, + dirty: false, + abort: null, +}) + +// A single stable reference for "no session yet" reads. useDashboardSession's +// selector must never allocate a fresh fallback object per call — with +// useSyncExternalStore (which Zustand's create() hook is built on), a +// selector returning a new reference every invocation looks like the store +// changed on every render, which is an infinite render loop, not a style nit. +const EMPTY_SESSION = Object.freeze(emptySession()) + +// Mirrors SavedDashboardService.STALE_RUNNING_THRESHOLD — if the backend +// hasn't flipped a turn back to IDLE by then, it's not coming back (most +// likely a crashed backend), so stop polling and say so instead of spinning +// forever. +const MAX_POLL_WAIT_MS = 20 * 60 * 1000 +const POLL_INTERVAL_MS = 3000 + +function resolveKeyIn(state, key) { + let k = key + const seen = new Set() + while (state.aliases[k] && !seen.has(k)) { + seen.add(k) + k = state.aliases[k] + } + return k +} + +export const useDashboardChatStore = create((set, get) => ({ + sessions: {}, + aliases: {}, + + resolveKey: (key) => resolveKeyIn(get(), key), + + getSession: (key) => { + const state = get() + return state.sessions[resolveKeyIn(state, key)] || EMPTY_SESSION + }, + + // Seeds a session the first time this key is ever opened (e.g. restoring a + // saved dashboard's persisted chat/config); never touches an in-progress or + // already-resumed one, so re-opening a workspace that's still generating + // (or that finished while the user was elsewhere) shows the live state. + ensureSession: (key, seed) => { + set((state) => { + const k = resolveKeyIn(state, key) + if (state.sessions[k]) return state + return { sessions: { ...state.sessions, [k]: { ...emptySession(), ...seed } } } + }) + }, + + patchSession: (key, patch) => { + set((state) => { + const k = resolveKeyIn(state, key) + const cur = state.sessions[k] || emptySession() + const next = typeof patch === 'function' ? patch(cur) : patch + return { sessions: { ...state.sessions, [k]: { ...cur, ...next } } } + }) + }, + + // Frees a transient `new:<connectionId>` slot once the component that owned + // it has moved on (its dashboard got an id and the component unmounted) — + // otherwise the NEXT "+ New dashboard" on the same connection would resolve + // straight through to the finished one's chat/config. + releaseAlias: (key) => { + set((state) => { + if (!(key in state.aliases)) return state + const { [key]: _removed, ...rest } = state.aliases + return { aliases: rest } + }) + }, + + // Moves a session from a transient key onto the real dashboard id the + // backend resolved/created for it, aliasing the old key so callers that + // still reference it (patchSession/getSession/resolveKey all chase aliases) + // keep reading live data through the rename. + adoptRealId: (key, newId) => { + set((state) => { + const k = resolveKeyIn(state, key) + if (k === newId || state.sessions[newId]) return state + const cur = state.sessions[k] || emptySession() + const { [k]: _moved, ...restSessions } = state.sessions + return { + sessions: { ...restSessions, [newId]: { ...cur, savedId: newId } }, + aliases: { ...state.aliases, [k]: newId }, + } + }) + }, + + submitPrompt: (key, connectionId, prompt) => { + const state = get() + const session = state.sessions[resolveKeyIn(state, key)] || emptySession() + const text = (prompt || '').trim() + if (!text || session.thinking) return + if (session.abort) session.abort() + + get().patchSession(key, (cur) => ({ + messages: [...cur.messages, { role: 'user', text }], + steps: [], + thinking: true, + startedAt: Date.now(), + })) + + const currentConfig = get().getSession(key).config + const dashboardId = get().getSession(key).savedId + + const abort = generateDashboardStream(connectionId, text, currentConfig, dashboardId, { + // The backend has already durably recorded the user's message under + // this id, before doing any slow agent work — adopt it immediately so + // a reload from here on (even mid-generation) can find this turn. + onCreated: (data) => { + if (data?.dashboardId) get().adoptRealId(key, String(data.dashboardId)) + }, + onStep: (s) => get().patchSession(key, (cur) => ({ steps: [...cur.steps, s] })), + onChat: (reply) => get().patchSession(key, (cur) => ({ + thinking: false, + steps: [], + abort: null, + messages: [...cur.messages, { role: 'agent', text: reply || '…' }], + })), + onDone: (next) => { + // Belt-and-braces: a chat-shaped payload must never hit the "built" + // path (that appends the canned save line and would clobber a real + // artifact). + if (!next?.html || next?.chat) { + get().patchSession(key, (cur) => ({ + thinking: false, + steps: [], + abort: null, + messages: [...cur.messages, { role: 'agent', text: next?.reply || '…' }], + })) + return + } + // Persistence is the BACKEND's job now (DashboardGenerationController + // calls SavedDashboardService.completeBuildTurn the instant the agent + // finishes, regardless of whether this tab is still connected) — this + // patch is just so the UI updates instantly without waiting on a + // round-trip. Keep this message textually identical to the one + // completeBuildTurn appends server-side. + get().patchSession(key, (cur) => ({ + thinking: false, + steps: [], + abort: null, + config: next, + messages: [...cur.messages, { role: 'agent', text: 'Done — built and verified against your data. Saved as a draft — tell me what to change.' }], + })) + }, + onError: (e) => get().patchSession(key, (cur) => ({ + thinking: false, + steps: [], + abort: null, + messages: [...cur.messages, { role: 'agent', text: `⚠ ${e?.message || 'Generation failed.'}`, error: true }], + })), + }) + get().patchSession(key, { abort }) + }, + + // Called on mount for an existing dashboard whose freshly-fetched row says + // generationStatus === "RUNNING" — a turn was in flight when this tab + // wasn't around (or reloaded mid-generation) to receive it live. Polls the + // saved dashboard until the backend's own completion write flips it back to + // IDLE, then adopts the now-persisted messages/config, instead of assuming + // nothing is happening. Does nothing if a live session (with its own SSE + // connection) already exists for this key. + resumeIfRunning: (key, dashboardId, generationStatus, generationStartedAt) => { + if (generationStatus !== 'RUNNING') return + if (get().getSession(key).thinking) return // already live via this tab's own SSE + const startedAtMs = generationStartedAt ? new Date(generationStartedAt).getTime() : Date.now() + get().patchSession(key, { thinking: true, startedAt: startedAtMs, steps: [] }) + + const poll = async () => { + // Something else already resolved this session (e.g. the backend + // finished and a normal onDone/onChat already fired via a live SSE + // connection opened in the meantime) — stop politely. + if (!get().getSession(key).thinking) return + if (Date.now() - startedAtMs > MAX_POLL_WAIT_MS) { + get().patchSession(key, (cur) => ({ + thinking: false, + messages: [...cur.messages, { role: 'agent', text: '⚠ This looks stuck — try sending your message again.', error: true }], + })) + return + } + try { + const res = await savedDashboardsAPI.getDashboardById(dashboardId) + const d = res?.savedDashboard || res?.dashboard || res + if (d?.generationStatus === 'RUNNING') { + setTimeout(poll, POLL_INTERVAL_MS) + return + } + let cfg = null + try { cfg = d?.dashboardConfig ? JSON.parse(d.dashboardConfig) : null } catch { cfg = null } + let msgs = null + try { msgs = d?.chatMessages ? JSON.parse(d.chatMessages) : null } catch { msgs = null } + get().patchSession(key, (cur) => ({ + thinking: false, + steps: [], + config: cfg?.html ? cfg : null, + messages: Array.isArray(msgs) && msgs.length ? msgs : cur.messages, + })) + } catch { + // Transient network hiccup while polling — retry rather than give up + // on the first blip. + setTimeout(poll, POLL_INTERVAL_MS) + } + } + setTimeout(poll, POLL_INTERVAL_MS) + }, + + // Explicit, one-off saves — the Save button, and a manual Source edit + // (DashboardWorkspace's applySource). Chat-turn persistence no longer goes + // through here (the backend does that itself); this remains the fallback + // path for the rare case a Source edit happens before any chat turn has + // ever resolved a real dashboard id. + persistDraft: async (key, connectionId, cfg, msgs) => { + if (!cfg) return false + const state = get() + const k = resolveKeyIn(state, key) + const session = state.sessions[k] || emptySession() + const body = { + connectionId, + name: cfg.title || 'Untitled dashboard', + description: cfg.description || '', + dashboardConfig: JSON.stringify(cfg), + chatMessages: JSON.stringify(msgs || session.messages), + isFavorite: false, + } + try { + if (session.savedId) { + await savedDashboardsAPI.updateDashboard(session.savedId, body) + } else { + const res = await savedDashboardsAPI.createDashboard(body) + const created = res?.savedDashboard || res?.dashboard || res + if (created?.id) get().adoptRealId(key, String(created.id)) + } + get().patchSession(key, { dirty: false }) + return true + } catch (e) { + get().patchSession(key, { dirty: true }) + throw e + } + }, +})) + +// Selector hooks for optimized re-renders — a component only re-renders when +// the specific slice it reads changes, not on every session's update. +export const useDashboardSession = (key) => + useDashboardChatStore((state) => state.sessions[resolveKeyIn(state, key)] || EMPTY_SESSION) + +// useShallow: the returned object is a fresh literal every call, but the +// action values inside it are stable for the store's lifetime — without this, +// the same infinite-snapshot-loop bug as above (a "new" object every +// getSnapshot call) would fire on every render, not just when a session changes. +export const useDashboardChatActions = () => + useDashboardChatStore(useShallow((state) => ({ + ensureSession: state.ensureSession, + patchSession: state.patchSession, + releaseAlias: state.releaseAlias, + adoptRealId: state.adoptRealId, + submitPrompt: state.submitPrompt, + resumeIfRunning: state.resumeIfRunning, + persistDraft: state.persistDraft, + }))) From 1ffeef71fe4b04b7c5b477142081922f251fbd00 Mon Sep 17 00:00:00 2001 From: Venkat SF <venkatesh.sakamuri@stayflexi.com> Date: Fri, 14 Aug 2026 11:31:31 +0530 Subject: [PATCH 5/6] Potential fix for pull request finding 'CodeQL / Incomplete multi-character sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/components/sections/DashboardWorkspace.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/sections/DashboardWorkspace.jsx b/src/components/sections/DashboardWorkspace.jsx index 9ea00f3..26ba9e6 100644 --- a/src/components/sections/DashboardWorkspace.jsx +++ b/src/components/sections/DashboardWorkspace.jsx @@ -17,7 +17,7 @@ const STEP_ICON = { grounding: Brain, planning: PencilRuler, validating: Clipboa // listed and breadcrumbed under its old name, which reads as "my edit didn't // save" even though it did. function titleFromHtml(html, fallback) { - const strip = (s) => s.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim() + const strip = (s) => s.replace(/[<>]/g, '').replace(/\s+/g, ' ').trim() const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i) if (title && strip(title[1])) return strip(title[1]).slice(0, 120) const h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i) From 36984c54178d9ecb4784c68a9c433e12b2348392 Mon Sep 17 00:00:00 2001 From: sumit <sumitgarg380@gmail.com> Date: Fri, 14 Aug 2026 22:33:33 +0530 Subject: [PATCH 6/6] fix(dashboards): optimistic locking + manage-ACL on generate/stream Adds @Version to SavedDashboard so concurrent chat turns / favorite / share / update writes on the same row fail cleanly (409) instead of racing or leaking a raw Hibernate error message. Also tightens generate/stream to require manage (not just read) access, since it creates/mutates saved dashboards. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- .../DashboardGenerationController.java | 11 ++++++++- .../controller/SavedDashboardController.java | 24 +++++++++++++++++++ .../com/dbaagent/model/SavedDashboard.java | 10 ++++++++ .../service/SavedDashboardService.java | 6 +++++ .../migration/V112__add_dashboard_version.sql | 1 + 5 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/resources/db/migration/V112__add_dashboard_version.sql diff --git a/backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java b/backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java index 6cff915..8006730 100644 --- a/backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java +++ b/backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java @@ -6,6 +6,7 @@ import com.dbaagent.service.security.AccessControlService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -72,7 +73,10 @@ public ResponseEntity<?> generate(@RequestBody GenerateRequest request) { @PostMapping(value = "/generate/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter generateStream(@RequestBody GenerateRequest request) { requireValid(request); - accessControlService.assertCanReadConnectionContent(request.connectionId()); + // This path creates/updates a SavedDashboard row (beginGenerationTurn etc.) + // on every call, not just reads — a VIEWER (read-only) must not be able to + // mint or mutate drafts via chat. + accessControlService.assertCanManageConnectionContent(request.connectionId()); SseEmitter emitter = new SseEmitter(600_000L); // Resolve (or create) the target dashboard and record the user's message @@ -87,6 +91,11 @@ public SseEmitter generateStream(@RequestBody GenerateRequest request) { } catch (IllegalArgumentException | IllegalStateException e) { sendErrorAndComplete(emitter, e.getMessage()); return emitter; + } catch (OptimisticLockingFailureException e) { + // Lost the race to another concurrent submit on the same dashboard — + // same user-facing shape as the "already running" case above. + sendErrorAndComplete(emitter, "A generation is already running for this dashboard."); + return emitter; } // The frontend needs this id right away (not just at the end) so a // brand-new dashboard is addressable — e.g. by a reload — well before diff --git a/backend/src/main/java/com/dbaagent/controller/SavedDashboardController.java b/backend/src/main/java/com/dbaagent/controller/SavedDashboardController.java index 93982e5..2dde08a 100644 --- a/backend/src/main/java/com/dbaagent/controller/SavedDashboardController.java +++ b/backend/src/main/java/com/dbaagent/controller/SavedDashboardController.java @@ -5,6 +5,7 @@ import com.dbaagent.service.security.AccessControlService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -25,6 +26,19 @@ public class SavedDashboardController { @Autowired private AccessControlService accessControlService; + // Every write method below is load-then-save on a row a background generation + // turn (SavedDashboardService.beginGenerationTurn etc.) may be writing at the + // same time. Without this helper, the loser's raw Hibernate message + // ("Unexpected row count... where id=? and version=?") leaked straight into + // the API response as a 500 instead of a clean, retryable conflict. + private static ResponseEntity<Map<String, Object>> conflict(OptimisticLockingFailureException e) { + log.warn("Dashboard update lost a concurrent-write race: {}", e.getMessage()); + Map<String, Object> body = new HashMap<>(); + body.put("success", false); + body.put("message", "This dashboard changed elsewhere just now — please retry."); + return ResponseEntity.status(HttpStatus.CONFLICT).body(body); + } + /** Publish this dashboard to the web (opt-in, revocable public link). */ @PostMapping("/{id}/share") public ResponseEntity<Map<String, Object>> enableShare(@PathVariable UUID id) { @@ -39,6 +53,8 @@ public ResponseEntity<Map<String, Object>> enableShare(@PathVariable UUID id) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("success", false, "message", e.getMessage())); } catch (org.springframework.web.server.ResponseStatusException e) { throw e; + } catch (OptimisticLockingFailureException e) { + return conflict(e); } catch (Exception e) { log.error("Error enabling dashboard share", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) @@ -59,6 +75,8 @@ public ResponseEntity<Map<String, Object>> setSharePassword(@PathVariable UUID i return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("success", false, "message", e.getMessage())); } catch (org.springframework.web.server.ResponseStatusException e) { throw e; + } catch (OptimisticLockingFailureException e) { + return conflict(e); } catch (Exception e) { log.error("Error setting dashboard share password", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) @@ -79,6 +97,8 @@ public ResponseEntity<Map<String, Object>> disableShare(@PathVariable UUID id) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("success", false, "message", e.getMessage())); } catch (org.springframework.web.server.ResponseStatusException e) { throw e; + } catch (OptimisticLockingFailureException e) { + return conflict(e); } catch (Exception e) { log.error("Error disabling dashboard share", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) @@ -196,6 +216,8 @@ public ResponseEntity<Map<String, Object>> updateDashboard(@PathVariable UUID id return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); } catch (org.springframework.web.server.ResponseStatusException e) { throw e; + } catch (OptimisticLockingFailureException e) { + return conflict(e); } catch (Exception e) { log.error("Error updating saved dashboard", e); Map<String, Object> errorResponse = new HashMap<>(); @@ -255,6 +277,8 @@ public ResponseEntity<Map<String, Object>> toggleFavorite(@PathVariable UUID id) return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); } catch (org.springframework.web.server.ResponseStatusException e) { throw e; + } catch (OptimisticLockingFailureException e) { + return conflict(e); } catch (Exception e) { log.error("Error toggling favorite", e); Map<String, Object> errorResponse = new HashMap<>(); diff --git a/backend/src/main/java/com/dbaagent/model/SavedDashboard.java b/backend/src/main/java/com/dbaagent/model/SavedDashboard.java index 184735b..c1f7201 100644 --- a/backend/src/main/java/com/dbaagent/model/SavedDashboard.java +++ b/backend/src/main/java/com/dbaagent/model/SavedDashboard.java @@ -102,6 +102,16 @@ public boolean isSharePasswordSet() { @Column(nullable = false) private LocalDateTime updatedAt; + // Optimistic lock: beginGenerationTurn/appendAgentReply/completeBuildTurn/ + // appendErrorReply all do load-then-save on this same row, and two overlapping + // turns (e.g. a slow build finishing after the user already sent a follow-up + // chat) would otherwise silently lose whichever save landed first. Hibernate + // bumps this on every UPDATE and rejects a save whose version is stale with + // OptimisticLockException instead of overwriting. + @Version + @Column(nullable = false) + private Long version = 0L; + // Jackson deserializes create/update bodies via Lombok's all-args constructor // (Spring's parameter-names module), which bypasses the field defaults and // leaves these NOT-NULL booleans null when the client omits them. Coerce here diff --git a/backend/src/main/java/com/dbaagent/service/SavedDashboardService.java b/backend/src/main/java/com/dbaagent/service/SavedDashboardService.java index 5ca4f15..83f17d6 100644 --- a/backend/src/main/java/com/dbaagent/service/SavedDashboardService.java +++ b/backend/src/main/java/com/dbaagent/service/SavedDashboardService.java @@ -253,6 +253,12 @@ public void deleteDashboardsByConnection(String connectionId) { * kicks off the slow agent work). Marking generationStatus=RUNNING here — * not after the agent finishes — is what lets a reload mid-generation see * "still working" instead of nothing at all. + * + * isFreshlyRunning below is check-then-act, but SavedDashboard.version + * (@Version) is the real guard: save() is `UPDATE ... WHERE version=?`, so a + * racing loser gets OptimisticLockingFailureException, not a double-append + * (caught in DashboardGenerationController same as the IllegalStateException + * below). Verified with concurrent requests: loser rejected, zero writes. */ @Transactional public SavedDashboard beginGenerationTurn(UUID dashboardId, String connectionId, String prompt) { diff --git a/backend/src/main/resources/db/migration/V112__add_dashboard_version.sql b/backend/src/main/resources/db/migration/V112__add_dashboard_version.sql new file mode 100644 index 0000000..7977c22 --- /dev/null +++ b/backend/src/main/resources/db/migration/V112__add_dashboard_version.sql @@ -0,0 +1 @@ +ALTER TABLE saved_dashboards ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 0;