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 diff --git a/backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java b/backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java index 0ce3faf..8006730 100644 --- a/backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java +++ b/backend/src/main/java/com/dbaagent/controller/DashboardGenerationController.java @@ -1,9 +1,12 @@ 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; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -18,6 +21,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 +48,7 @@ public class DashboardGenerationController { private final DashboardAgentService dashboardAgentService; private final AccessControlService accessControlService; + private final SavedDashboardService savedDashboardService; @PostMapping("/generate") public ResponseEntity generate(@RequestBody GenerateRequest request) { @@ -68,13 +73,47 @@ 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 + // 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; + } 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 + // 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 +145,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 +199,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 +222,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/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> conflict(OptimisticLockingFailureException e) { + log.warn("Dashboard update lost a concurrent-write race: {}", e.getMessage()); + Map 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> enableShare(@PathVariable UUID id) { @@ -39,6 +53,8 @@ public ResponseEntity> 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> 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> 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> 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 errorResponse = new HashMap<>(); @@ -255,6 +277,8 @@ public ResponseEntity> 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 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 4891202..c1f7201 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; @@ -85,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 @@ -94,5 +121,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/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/backend/src/main/java/com/dbaagent/service/SavedDashboardService.java b/backend/src/main/java/com/dbaagent/service/SavedDashboardService.java index e32aca1..83f17d6 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,148 @@ 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. + * + * 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) { + 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; 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; 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 bbfc985..26ba9e6 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 (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}> @@ -228,26 +253,25 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose }) </span> <span className={styles.traceTime}>{elapsed}s</span> </div> - <div className={styles.traceList}> - {(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 ( - <div key={steps.length - arr.length + i} className={styles.traceRow}> - <span className={styles.traceRail}> - <span className={last ? styles.traceIconActive : styles.traceIconDone}> - {last ? <Icon size={11} /> : <Check size={11} />} + {steps.length > 0 && ( + <div className={styles.traceList}> + {steps.slice(-7).map((s, i, arr) => { + const Icon = STEP_ICON[s.type] || ClipboardCheck + const last = i === arr.length - 1 + return ( + <div key={steps.length - arr.length + i} className={styles.traceRow}> + <span className={styles.traceRail}> + <span className={last ? styles.traceIconActive : styles.traceIconDone}> + {last ? <Icon size={11} /> : <Check size={11} />} + </span> + {i < arr.length - 1 && <span className={styles.traceLine} />} </span> - {i < arr.length - 1 && <span className={styles.traceLine} />} - </span> - <span className={last ? styles.traceTextActive : styles.traceTextDone}>{s.message}</span> - </div> - ) - })} - </div> + <span className={last ? styles.traceTextActive : styles.traceTextDone}>{s.message}</span> + </div> + ) + })} + </div> + )} </div> )} </div> @@ -268,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, + })))