diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5b3be7c1..a1172e13 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.40.2", + "version": "0.40.3", "source": "./plugins/kbagent", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "category": "development" diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index 24f3d216..9681ffd5 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.40.2", + "version": "0.40.3", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "author": { "name": "Keboola", diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 430218c8..05ed5b85 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -1415,6 +1415,32 @@ Full CRUD for configuration rows is exposed as a separate `Rows` command panel: - `row-delete` is **destructive** (gated behind `--allow-destructive` if the session firewall is on). 404 from the API on a non-existent row surfaces as `NOT_FOUND` exit 1 — deletion is **not** treated as idempotent success. - `--json` mode auto-skips the interactive confirmation prompt on `row-delete`; in human mode pass `--yes` to skip. +## `project status` / `project list` expose `org_id` / `org_name`; `org_name` is Manage-API-only (since v0.40.3) + +`ProjectConfig` now persists `org_id` (int | None) and `org_name` (str | None); +both are surfaced verbatim in `kbagent project status` and `kbagent project +list` JSON output. The two fields are populated from **different sources**: + +- **`org_id`** comes from `data.organization.id` at the **top level** of the + Storage API `/v2/storage/tokens/verify` response (NOT under `owner`). + Populated whenever a project is added / re-verified — including the + opportunistic backfill that `/projects/status` performs for projects + registered before this release. The API returns the id as a string + (e.g. `"73"`); the parser normalises it to int. +- **`org_name`** is **Manage-API-only**. The Storage API never carries it. + It is populated only when the project flows through `kbagent org setup` + (which calls `/manage/organizations/{id}`) or when `kbagent project add` + runs in a context that has a Manage API token. Projects registered via + plain `kbagent project add` (Storage token only) keep `org_name: null` + indefinitely. + +**AI agent rule of thumb**: when reading `project status` JSON, ALWAYS +handle `org_name: null` even when `org_id` is set. Do not pattern-match on +both being present; the asymmetry is the steady state for the majority of +projects. The web UI Projects table renders `#` (e.g. `#73`) as a +fallback when only the id is known, so any agent producing a human-readable +project list should do the same — never render the bare null. + ## `config oauth-url` requires a master Storage API token (since v0.30.0) The OAuth wizard URL embeds a short-lived **child** Storage API token scoped diff --git a/pyproject.toml b/pyproject.toml index ebae9cb0..d62ff6d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-agent-cli" -version = "0.40.2" +version = "0.40.3" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index 24834096..f43797b2 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -8,6 +8,20 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.40.3": [ + "New: `kbagent serve --ui` workspace SQL editor gains an AI-assisted SQL writer (#287). The 'Help me write this SQL' button opens an inline helper that spawns a local `claude` / `codex` / `gemini` CLI via the new `POST /workspaces/sql/improve/stream` SSE endpoint, feeds it a meta-prompt grounded in the user's workspace (project alias, backend, default schema, visible bucket catalog, backend-specific INFORMATION_SCHEMA recipes, and a MANDATORY-FIRST-STEP block forcing `kbagent storage bucket-detail` for linked-bucket FQN resolution), streams the response back, and pastes the cleaned SQL into the Monaco editor. Three transparency panels are surfaced: the full meta-prompt (so users can audit what the AI received), an Activity log (tool_use -> tool_result events the AI invoked: `-> Bash: kbagent storage bucket-detail ...`), and the final AI suggestion with copy-to-clipboard. Each panel carries an inline copy pill. The `clean_sql_helper_response` strip pipeline handles claude's Insight blocks (the user-set `explanatory` output style leaks them despite the OUTPUT CONTRACT), code fences, preambles, and JSONL duplication. Fix-mode: when a query Run fails, a 'Send to for fix' button re-opens the helper with the failing SQL + the warehouse error pre-filled; `build_sql_helper_meta_prompt` pivots framing to 'diagnose and fix'. The Snowflake backend hint mandates double-quoting of EVERY identifier including column / table / CTE aliases (`AS \"month\"` not `AS month`) -- Snowflake uppercases unquoted aliases and the resulting CSV columns came back MONTH / EMPLOYEE_COUNT instead of the lowercase names users expected.", + "Fix: `wait_for_query_job` now extracts the real warehouse error from `statements[i].error` (a plain string on Snowflake, sometimes a dict on BigQuery), not from a top-level `error` field that is ABSENT on failures. The previous extractor emitted the useless 'Query job failed: Query execution failed' constant for every failure; the SQL editor's red error box and the AI fix-mode prompt now receive messages like 'SQL compilation error: Function DATE_TRUNC does not support VARCHAR(10) argument type' verbatim. New module-level `_extract_query_job_error` helper walks statements first (with one-line `Statement N:` prefix only when multiple statements failed), falls back through top-level (string OR dict-with-message), and finally an explicit `Query execution failed (no error details from Query Service)` so the caller never gets an empty error string. 6 unit tests pin the four input shapes plus the no-info fallback.", + 'New: `project status` / `project list` JSON output exposes per-project `org_id` (int) and `org_name` (str | None) fields (#290). `org_id` is parsed from the top-level `organization.id` of the `/v2/storage/tokens/verify` response and normalised from string (`"73"`) to int (`73`) so persisted `ProjectConfig.org_id` keeps its declared int type. `org_name` is **Manage-API-only** -- the Storage API only carries the id; the name is populated via `kbagent org setup` (which calls `/manage/organizations/{id}`) or by `kbagent project add` when a manage token is in scope. Opportunistic backfill: `/projects/status` writes the freshly-discovered `org_id` back to `config.json` in a single serial pass after the parallel status check completes, so the value sticks for projects registered before this release. The web UI Projects table and top-bar project picker render `Keboola Demo` when the name is known, `#73` (monospace) when only the id is known, and dash when neither is known. The React Query cache for `/projects` is invalidated automatically once `/projects/status` returns so the ORG column populates without a manual page reload. Tooltips on `#73` and dash explain how to populate the name. ProjectConfig migration is backward-compatible: legacy `config.json` files without the new fields load cleanly with both fields defaulting to `None`.', + "New: `kbagent serve --ui` auto-generates a stable `KBAGENT_CONVERSATION_ID` for the session in the format `serve--<8-hex>` (e.g. `serve-20260515T091949Z-699ea57b`) and exports it to env before `create_app()`. Child processes (MCP subprocess, AI agent CLI invocations, every scheduled `kbagent http` call) inherit it and emit `X-Conversation-ID` on every Keboola API request. `kbagent doctor` flips from `warn: Conversation ID not set` to `pass: X-Conversation-ID: serve-...`. The `serve-` prefix lets observability dashboards filter human-driven sessions; the timestamp makes log lookups by session-start trivial; the hex suffix disambiguates rapid restarts in the same second. A pre-set `KBAGENT_CONVERSATION_ID` in env is respected verbatim so CI / supervisor scripts can pin a stable id across restarts. The startup banner gains a `conv id` line in both UI and API-only mode, plus the `export KBAGENT_CONVERSATION_ID=...` hint for the second-terminal `kbagent http` workflow.", + "Fix: Lineage / Sharing graph (#289). Three changes layered on top of each other. (1) Mermaid's `maxTextSize` config is bumped from the default 50 KB to ~5 MB so the typical 50+ project / 250+ edge graph renders natively instead of bouncing off the size guard. (2) When Mermaid still hits the guard, soft-error detection now post-checks the rendered SVG for the literal 'Maximum text size in diagram exceeded' marker -- the renderer does NOT throw on size limit, it embeds the failure text INSIDE the SVG, so the previous `.catch()` path never fired. The styled amber banner kicks in instead of a silent useless red box. The banner replaces the previous CLI hint (`kbagent lineage server --load ...`) with two in-UI buttons: `Open Deep Lineage tab` (one-click switch to the dedicated viewer) and `Download Mermaid source` (handoff to mermaid.live or any external renderer). (3) The diagram itself is now wrapped in a 600px fixed-height scrollable viewport with `overflow: auto` so scrolling stays INSIDE the box instead of pushing the entire page layout. Two ` setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + if (query) setQuery(""); + else setOpen(false); + } else if (e.key === "Enter" && filtered.length === 1) { + onChange(filtered[0].alias); + setOpen(false); + } }} - className={`w-full text-left px-3 py-2 text-sm flex items-center justify-between hover:bg-zinc-100 dark:hover:bg-zinc-900 ${ - p.alias === current ? "text-keboola" : "text-zinc-700 dark:text-zinc-300" - }`} - > -
-
{p.alias}
-
{p.project_name}
-
- {p.is_default ? default : null} - - )) - )} + placeholder={`filter ${projects.length} projects (alias, name, org)`} + className="flex-1 bg-transparent text-sm focus:outline-none placeholder-zinc-400 dark:placeholder-zinc-600" + /> + {query ? ( + + {filtered.length}/{projects.length} + + ) : null} + + ) : null} +
+ {projects.length === 0 ? ( +
+ No projects. Add one via the Projects page. +
+ ) : filtered.length === 0 ? ( +
+ No project matches “{query}”. +
+ ) : ( + filtered.map((p) => ( + + )) + )} +
) : null} diff --git a/web/frontend/src/pages/Dashboard.tsx b/web/frontend/src/pages/Dashboard.tsx index ad659cb3..f1adb39c 100644 --- a/web/frontend/src/pages/Dashboard.tsx +++ b/web/frontend/src/pages/Dashboard.tsx @@ -93,7 +93,7 @@ export function DashboardPage() { return (
Deep lineage (from JSON)
- {tab === "sharing" ? : } + {tab === "sharing" ? setTab("deep")} /> : } ); } -function SharingTab() { +function SharingTab({ onOpenDeepLineage }: { onOpenDeepLineage: () => void }) { const q = useQuery({ queryKey: ["lineage-sharing"], queryFn: () => api.get("/lineage/edges"), @@ -101,7 +112,7 @@ function SharingTab() { {q.data.edges.length > 0 ? ( <> - + @@ -462,12 +473,63 @@ function Stat({ label, value }: { label: string; value: number }) { ); } -function MermaidGraph({ edges }: { edges: LineageEdge[] }) { +function MermaidGraph({ + edges, + onOpenDeepLineage, +}: { + edges: LineageEdge[]; + onOpenDeepLineage: () => void; +}) { const ref = useRef(null); const renderSeq = useRef(0); const [error, setError] = useState(null); + // The Mermaid source code we generated for THIS render. Stashed so the + // oversize banner can offer it as a download — even when the embedded + // renderer can't draw the graph, the user can paste this into + // mermaid.live or a local renderer. + const [mermaidCode, setMermaidCode] = useState(""); + // Filter state — when empty (""), no filter is applied. The picker + // dropdowns let users narrow the edge set on the source or target + // project alias, which is the main escape hatch when the full graph + // hits Mermaid's size guard (#289). + const [sourceFilter, setSourceFilter] = useState(""); + const [targetFilter, setTargetFilter] = useState(""); const { theme } = useTheme(); + // Build the unique alias lists for the dropdowns. We sort them so the + // user can scan alphabetically; "(all)" is rendered separately as the + // empty-string option in the JSX, so this list excludes it. + const sourceAliases = useMemo(() => { + const set = new Set(); + for (const e of edges) { + const alias = e.source_project_alias || `p${e.source_project_id}`; + set.add(alias); + } + return Array.from(set).sort((a, b) => a.localeCompare(b)); + }, [edges]); + const targetAliases = useMemo(() => { + const set = new Set(); + for (const e of edges) { + const alias = e.target_project_alias || `p${e.target_project_id}`; + set.add(alias); + } + return Array.from(set).sort((a, b) => a.localeCompare(b)); + }, [edges]); + + // Apply both filters. Empty filter strings pass through unconditionally; + // when both are set the edges must satisfy BOTH (AND, not OR) — that's + // how users isolate a specific project-to-project pair. + const filteredEdges = useMemo(() => { + if (!sourceFilter && !targetFilter) return edges; + return edges.filter((e) => { + const src = e.source_project_alias || `p${e.source_project_id}`; + const dst = e.target_project_alias || `p${e.target_project_id}`; + if (sourceFilter && src !== sourceFilter) return false; + if (targetFilter && dst !== targetFilter) return false; + return true; + }); + }, [edges, sourceFilter, targetFilter]); + useEffect(() => { if (!ref.current) return; let cancelled = false; @@ -488,7 +550,7 @@ function MermaidGraph({ edges }: { edges: LineageEdge[] }) { .replace(/>/g, ">"); const lines = ["graph LR"]; - for (const e of edges) { + for (const e of filteredEdges) { const src = e.source_project_alias || `p${e.source_project_id}`; const dst = e.target_project_alias || `p${e.target_project_id}`; const srcId = `n_${slug(src)}_${slug(e.source_bucket_id)}`; @@ -498,11 +560,33 @@ function MermaidGraph({ edges }: { edges: LineageEdge[] }) { ); } const code = lines.join("\n"); + setMermaidCode(code); + + if (filteredEdges.length === 0) { + // Avoid handing Mermaid an empty graph — it would render a single + // placeholder node which is confusing in this "I filtered too hard" + // context. Show our own empty-state instead. + ref.current.innerHTML = ""; + setError(null); + return; + } mermaid .render(runId, code) .then(({ svg }) => { if (cancelled || !ref.current) return; + // Mermaid's text-size guard does NOT throw — it returns a "soft + // error" SVG with the failure rendered as a element. Detect + // the marker substring before mounting so the banner kicks in + // instead of silently embedding a useless red box. (Confirmed + // against mermaid 10.x output; future versions may shift wording + // slightly, so we match a case-insensitive substring not the exact + // phrase.) + if (/maximum text size/i.test(svg)) { + setError("Maximum text size in diagram exceeded"); + ref.current.innerHTML = ""; + return; + } ref.current.innerHTML = svg; setError(null); }) @@ -518,7 +602,7 @@ function MermaidGraph({ edges }: { edges: LineageEdge[] }) { const orphan = document.getElementById(runId); orphan?.remove(); }; - }, [edges, theme]); + }, [filteredEdges, theme]); if (edges.length === 0) { return ( @@ -528,13 +612,158 @@ function MermaidGraph({ edges }: { edges: LineageEdge[] }) { ); } + // Mermaid trips its hardcoded text-size guard around ~50 KB of source. + // The error message is "Maximum text size in diagram exceeded"; we also + // accept a generic substring match in case the message wording shifts. + const isOversize = !!error && /maximum text size/i.test(error); + + const filtered = !!sourceFilter || !!targetFilter; + return (
-

Diagram

- {error ? ( +
+

Diagram

+ {/* Filter toolbar: two pickers + edge counter. Lets the user narrow + the graph down to a specific source / target project pair so the + embedded renderer never needs to fight a 250+-edge graph (#289). */} +
+ + + {filtered ? ( + + ) : null} + + {filteredEdges.length}/{edges.length} edges + +
+
+ {isOversize ? ( + + ) : error ? (
{error}
) : null} -
+ {!isOversize ? ( + filteredEdges.length === 0 ? ( +
+ No edges match the current filter. +
+ ) : ( + // Fixed-height scrollable viewport so the diagram never pushes + // the page layout — scrolling stays INSIDE the box (#289). + // Mermaid renders the SVG with its natural size; we let it + // overflow horizontally + vertically and the user pans inside. +
+ ) + ) : null} +
+ ); +} + +function OversizeBanner({ + edgeCount, + mermaidCode, + onOpenDeepLineage, +}: { + edgeCount: number; + mermaidCode: string; + onOpenDeepLineage: () => void; +}) { + const downloadMermaid = () => { + // Hand the user the raw Mermaid source so they can paste it into + // mermaid.live or a local renderer. The embedded preview can't draw + // it, but the source is small enough to ship and any external + // renderer (which usually has much higher caps) handles it fine. + const blob = new Blob([mermaidCode], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `lineage-sharing-${edgeCount}-edges.mmd`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return ( +
+
+ Diagram too large to render here ({edgeCount} edges) +
+

+ Mermaid's embedded renderer caps source-text size and rejected this graph. + Two ways forward — both stay in the UI, no shell required: +

+
+ + +
+

+ Tip: filter the edge table below first — narrowing the set often + brings the diagram back under the embedded renderer's limit. +

); } diff --git a/web/frontend/src/pages/Projects.tsx b/web/frontend/src/pages/Projects.tsx index 096d431d..ad6f16b1 100644 --- a/web/frontend/src/pages/Projects.tsx +++ b/web/frontend/src/pages/Projects.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckCircle2, Plus, RefreshCw, Trash2, XCircle } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { ApiError, api } from "../api/client"; import { Empty, ErrorBox, Loading, PageTitle } from "../components/Empty"; import { JsonView } from "../components/JsonView"; @@ -21,6 +21,14 @@ export function ProjectsPage() { queryFn: () => api.get("/projects/status"), }); + // /projects/status performs an opportunistic backfill of org_id/org_name + // on the backend for projects registered before #290. Invalidate the + // /projects cache once status finishes so the ORG column picks up the + // freshly persisted values without the user having to reload the page. + useEffect(() => { + if (statusQ.data) qc.invalidateQueries({ queryKey: ["projects"] }); + }, [statusQ.data, qc]); + const removeMu = useMutation({ mutationFn: (alias: string) => api.delete(`/projects/${encodeURIComponent(alias)}`), onSuccess: () => qc.invalidateQueries({ queryKey: ["projects"] }), @@ -89,6 +97,38 @@ export function ProjectsPage() { ), }, + { + header: "Org", + cell: (p) => { + // Org name comes from Manage API (via `org setup`); the + // Storage token alone only returns the numeric id. We show + // the name when we have it, fall back to "#73" so multi-org + // setups are still distinguishable, and only render "—" + // when neither is known (very old stacks without + // organization in the verify response). + if (p.org_name) { + return {p.org_name}; + } + if (p.org_id != null) { + return ( + \` to populate the name.`} + > + #{p.org_id} + + ); + } + return ( + + — + + ); + }, + }, { header: "Project", cell: (p) => {p.project_name} }, { header: "ID", diff --git a/web/frontend/src/pages/Workspaces.tsx b/web/frontend/src/pages/Workspaces.tsx index 1aa365f1..17d8026f 100644 --- a/web/frontend/src/pages/Workspaces.tsx +++ b/web/frontend/src/pages/Workspaces.tsx @@ -12,15 +12,32 @@ import { Sparkles, Trash2, Upload, + X, } from "lucide-react"; -import { useState } from "react"; -import { api } from "../api/client"; +import { useEffect, useRef, useState } from "react"; +import { api, ssePost, type SsePostHandle } from "../api/client"; import { Drawer } from "../components/Drawer"; import { Empty, ErrorBox, Loading, PageTitle, TwoPathEmpty } from "../components/Empty"; import { DataTable } from "../components/Table"; import { useUIState } from "../state"; import type { ProjectError, Workspace } from "../types"; +/** + * AbortError shape detection across browsers (DOMException on standards, + * named Error on some shims). Kept inline so a future move of this util + * to a shared module doesn't fan out to every page that needs it. + */ +function isAbortError(err: unknown): boolean { + if (err instanceof DOMException && err.name === "AbortError") return true; + if (err instanceof Error && err.name === "AbortError") return true; + return Boolean( + err && + typeof err === "object" && + "message" in err && + String((err as { message: unknown }).message).toLowerCase().includes("abort"), + ); +} + interface WorkspacesResp { workspaces: Workspace[]; errors: ProjectError[]; @@ -437,7 +454,16 @@ function CreateWorkspaceDrawer({ } interface BucketsResp { - buckets: Array<{ project_alias: string; id: string }>; + // Trimmed view of the Bucket type — only the fields the SQL editor's + // sidebar uses. is_linked + source_* are surfaced so the tree can flag + // cross-project buckets (whose SQL must use the source-project DB FQN). + buckets: Array<{ + project_alias: string; + id: string; + is_linked?: boolean; + source_project_id?: number | null; + source_project_name?: string; + }>; } interface TablesResp { tables: Array<{ project_alias: string; id: string; bucket_id: string }>; @@ -457,6 +483,13 @@ SELECT current_timestamp() AS now;`); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [hint, setHint] = useState(null); + // The AI CLI choice is persisted on the SqlEditorDrawer (not the panel) + // so the user's pick survives toggling the helper open/closed. + const [aiCli, setAiCli] = useState<"claude" | "codex" | "gemini">("claude"); + // Imperative seed for the SQL helper: bumped when the user clicks + // "Send to AI for fix" so the helper opens pre-filled with the failed + // query + warehouse error. + const [helperRequest, setHelperRequest] = useState(null); // Fetch buckets + tables from the workspace's project so users can click // them into the editor (Storage Explorer pattern from Keboola UI). @@ -566,6 +599,8 @@ SELECT current_timestamp() AS now;`); @@ -575,6 +610,19 @@ SELECT current_timestamp() AS now;`); {/* Editor + results */}
+ b.id)} + onApply={(generatedSql) => setSql(generatedSql)} + request={helperRequest} + onRequestConsumed={() => setHelperRequest(null)} + />
) : null} + {/* Fast path out of a broken query: hand the failing SQL + + warehouse error to the AI helper. The seed nonce ensures + repeated clicks always retrigger the panel even when the + query / error are unchanged. */} +
) : null} {result ? : null} @@ -609,10 +675,14 @@ SELECT current_timestamp() AS now;`); function BucketNode({ bucketId, + isLinked, + sourceProjectName, tables, onPick, }: { bucketId: string; + isLinked: boolean; + sourceProjectName?: string; tables: string[]; onPick: (tableId: string) => void; }) { @@ -626,6 +696,18 @@ function BucketNode({ > {open ? "▾" : "▸"} {bucketId} + {isLinked ? ( + + linked + + ) : null} {tables.length} {open && tables.length > 0 ? ( @@ -661,10 +743,13 @@ function SqlResults({ result }: { result: unknown }) {
{statements.map((stmt, i) => (
-
+
Statement {i + 1} ・ {stmt.status} ・ {stmt.rows_affected} rows + {stmt.csv_data ? ( + + ) : null}
{stmt.csv_data ? ( @@ -677,6 +762,90 @@ function SqlResults({ result }: { result: unknown }) { ); } +/** + * "Download CSV" + "Copy as CSV" buttons rendered in the result-table + * header. The result's CSV is already on the client (returned from + * /workspaces/.../query), so both actions are pure DOM operations — no + * extra backend roundtrip. Filename embeds the statement index so users + * can run multiple queries and not collide on the default name. + */ +/** + * Tiny "copy to clipboard" pill used on AI helper panel headers and the + * AI suggestion block (#287). Tracks a transient "copied" state so the + * label flashes "✓ copied" for ~1.2s on success, matching the result- + * table copy button and the Linear / Slack convention. + */ +function CopyTextButton({ text, label }: { text: string; label: string }) { + const [copied, setCopied] = useState(false); + const copy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + } catch { + /* Clipboard API blocked by browser permissions — silent. */ + } + }; + return ( + + ); +} + +function ResultExportButtons({ csv, index }: { csv: string; index: number }) { + const [copied, setCopied] = useState(false); + const download = () => { + const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `query-result-${index}-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, "")}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + const copy = async () => { + try { + await navigator.clipboard.writeText(csv); + setCopied(true); + // 1.2s feedback window matches Linear / Slack copy buttons — short + // enough not to confuse rapid copies, long enough to register. + window.setTimeout(() => setCopied(false), 1200); + } catch { + // Clipboard API can be blocked by browser permissions; fall through + // silently rather than crashing the whole panel. + } + }; + return ( +
+ + +
+ ); +} + function CsvTable({ csv }: { csv: string }) { const rows = csv .trim() @@ -719,3 +888,484 @@ function CsvTable({ csv }: { csv: string }) {
); } + +/** + * Inline AI helper for the workspace SQL editor (#287). + * + * Modeled on Agents.tsx > PromptHelperPanel — same SSE protocol (init / + * stdout / stderr / done), same claude / codex / gemini CLI selector, same + * "live preview while streaming → final suggestion → Apply / Discard" + * workflow. The differences: + * + * - Endpoint is /workspaces/sql/improve/stream (workspace-grounded + * meta-prompt: backend, schema, visible buckets are passed in). + * - The `done` payload carries `sql` (not `prompt`); `onApply` replaces + * the editor body with it instead of a prompt textarea. + * - The cancel-on-unmount cleanup is mandatory — without it, the backend + * keeps the claude/codex/gemini subprocess alive while waiting for an + * SSE consumer that will never return. + */ +/** + * Imperative request from the parent ``SqlEditorDrawer`` to open the helper + * pre-filled with a goal (and, in fix-mode, the warehouse error message). + * Wraps two correlated bits of state — the seed nonce and its payload — + * into a single prop so the helper's effect dependency is well-defined. + */ +interface HelperRequest { + /** Monotonic nonce. Bump to retrigger even if goal/error are unchanged. */ + seed: number; + goal: string; + failedError?: string; +} + +function SqlHelperPanel({ + cli, + onCliChange, + project, + workspaceId, + backend, + schemaName, + draftSql, + bucketIds, + onApply, + request, + onRequestConsumed, +}: { + cli: "claude" | "codex" | "gemini"; + onCliChange: (c: "claude" | "codex" | "gemini") => void; + project: string; + workspaceId: number; + backend: string; + schemaName: string; + draftSql: string; + bucketIds: string[]; + onApply: (sql: string) => void; + /** Optional imperative seed from parent (e.g. "Send to AI for fix" button). */ + request?: HelperRequest | null; + /** Called after the panel consumes a request, so parent can clear it. */ + onRequestConsumed?: () => void; +}) { + const [open, setOpen] = useState(false); + const [goal, setGoal] = useState(""); + const [running, setRunning] = useState(false); + const [elapsed, setElapsed] = useState(0); + const [livePreview, setLivePreview] = useState(""); + const [finalSql, setFinalSql] = useState(null); + const [error, setError] = useState(null); + // Transparency state — surfaced in collapsible panels so users can see + // what the AI actually received (#287). Captured at init time (meta_prompt) + // and accumulated during streaming (activity log for tool calls + stderr). + const [metaPrompt, setMetaPrompt] = useState(null); + const [activityLog, setActivityLog] = useState([]); + const [showPrompt, setShowPrompt] = useState(false); + const [showActivity, setShowActivity] = useState(true); + // Fix mode: warehouse error from a failed query run. When non-empty the + // helper switches to "diagnose and fix this SQL" framing in the backend. + const [failedError, setFailedError] = useState(""); + const handleRef = useRef(null); + + // Imperative seed from parent: pre-fill goal + error and pop the helper + // open so the user can click Generate. We key on `request.seed` so a + // repeat-click of "Send to AI for fix" with the same error still + // re-opens the panel. + useEffect(() => { + if (!request) return; + setOpen(true); + setGoal(request.goal); + setFailedError(request.failedError ?? ""); + onRequestConsumed?.(); + }, [request, onRequestConsumed]); + + const reset = () => { + setLivePreview(""); + setFinalSql(null); + setError(null); + setElapsed(0); + setMetaPrompt(null); + setActivityLog([]); + // failedError is intentionally NOT cleared here -- the parent controls + // it via `request`, and clearing on each generate cycle would drop the + // fix-mode framing if the user clicks Regenerate. + }; + + const start = () => { + if (!goal.trim()) { + setError("Describe the query you want first (e.g. 'top 10 customers by revenue last 30 days')."); + return; + } + if (handleRef.current) { + handleRef.current.abort(); + handleRef.current = null; + } + reset(); + setRunning(true); + const startMs = Date.now(); + const tick = setInterval( + () => setElapsed(Math.round((Date.now() - startMs) / 1000)), + 500, + ); + let assistantText = ""; + const handle = ssePost( + "/workspaces/sql/improve/stream", + { + cli, + goal, + project, + backend, + schema_name: schemaName, + workspace_id: workspaceId, + draft_sql: draftSql, + bucket_ids: bucketIds, + failed_error: failedError, + }, + { + init: (d) => { + // Capture the full meta-prompt for the "Show prompt" panel so + // users can see exactly what context the AI received. + const data = (d ?? {}) as Record; + if (typeof data.meta_prompt === "string") { + setMetaPrompt(data.meta_prompt); + } + }, + stdout: (d) => { + const data = (d ?? {}) as Record; + // Claude stream-json: assistant turns carry message.content[] + // blocks of either "text" (free-form reasoning / output) or + // "tool_use" (a CLI call the AI decided to make, e.g. running + // `kbagent storage bucket-detail` to resolve a linked bucket). + // We render text into the live preview and surface tool_use as + // a one-line "→ Bash: ..." in the activity log so the user can + // watch the AI's discovery work in real time. + if (data.type === "assistant" && typeof data.message === "object") { + const msg = data.message as Record; + const content = msg.content; + if (Array.isArray(content)) { + for (const block of content) { + if (!block || typeof block !== "object") continue; + const b = block as Record; + if (b.type === "text" && typeof b.text === "string") { + assistantText += b.text; + setLivePreview(assistantText); + } else if (b.type === "tool_use") { + const toolName = typeof b.name === "string" ? b.name : "tool"; + const input = b.input; + const argsPreview = + typeof input === "object" && input !== null + ? (() => { + const obj = input as Record; + // For Bash, surface the command verbatim. For other + // tools, dump the first 200 chars of JSON. + if (typeof obj.command === "string") return obj.command; + if (typeof obj.description === "string") return obj.description; + return JSON.stringify(obj).slice(0, 200); + })() + : ""; + setActivityLog((prev) => [...prev, `→ ${toolName}: ${argsPreview}`]); + } + } + } + } else if (data.type === "user" && typeof data.message === "object") { + // Tool results come back as user messages. We log just a one-line + // status (success / error) instead of the full payload so the + // activity panel stays scannable. + const msg = data.message as Record; + const content = msg.content; + if (Array.isArray(content)) { + for (const block of content) { + if (!block || typeof block !== "object") continue; + const b = block as Record; + if (b.type === "tool_result") { + const isErr = b.is_error === true; + setActivityLog((prev) => [ + ...prev, + ` ${isErr ? "✗" : "✓"} tool result${isErr ? " (error)" : ""}`, + ]); + } + } + } + } else if (typeof data.raw === "string") { + // codex / gemini stream raw text lines (no jsonl). + assistantText += (assistantText ? "\n" : "") + data.raw; + setLivePreview(assistantText); + } + }, + stderr: (d) => { + // Most stderr is progress noise; we attribute it to activity only + // if it carries a non-empty `raw` line. Keeps the panel focused. + const data = (d ?? {}) as Record; + if (typeof data.raw === "string" && data.raw.trim()) { + setActivityLog((prev) => [...prev, ` ⚠ ${data.raw}`]); + } + }, + done: (d) => { + const data = (d ?? {}) as Record; + if (data.status === "error") { + setError(String(data.error ?? "AI helper failed")); + return; + } + const cleaned = typeof data.sql === "string" ? data.sql.trim() : ""; + if (!cleaned) { + setError("AI returned an empty query. Refine the goal and regenerate."); + return; + } + setFinalSql(cleaned); + }, + message: () => { + /* unknown event — ignore */ + }, + }, + ); + handleRef.current = handle; + handle.done + .catch((err) => { + if (isAbortError(err)) return; + setError((err as Error).message); + }) + .finally(() => { + clearInterval(tick); + setRunning(false); + handleRef.current = null; + }); + }; + + const cancel = () => { + if (handleRef.current) { + handleRef.current.abort(); + handleRef.current = null; + } + setRunning(false); + }; + + useEffect(() => { + return () => { + if (handleRef.current) { + handleRef.current.abort(); + handleRef.current = null; + } + }; + }, []); + + if (!open) { + return ( +
+ + + uses {cli} with your workspace context (project, backend, visible buckets) baked in + +
+ ); + } + + return ( +
+
+
+ + AI SQL helper · {cli} + {failedError ? ( + + fix mode + + ) : null} +
+ +
+ +
+ CLI: + {(["claude", "codex", "gemini"] as const).map((c) => ( + + ))} +
+ +