diff --git a/CLAUDE.md b/CLAUDE.md index 6b1eaa8f..449df3ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -954,5 +954,12 @@ kbagent update [--beta] # re-executes; failures print a copy-paste recovery command. kbagent changelog [--limit N] [--full] # Default shows a one-line summary (first sentence) per version; --full / -v expands every note. -kbagent serve [--host HOST] [--port PORT] [--ui] [--ui-dist PATH] [--reload] [--log-level LVL] [--cors-origin ORIGIN] [--config-dir DIR] +kbagent serve [--host HOST] [--port PORT] [--ui] [--ui-dist PATH] [--reload] [--log-level LVL] [--cors-origin ORIGIN] [--config-dir DIR] [--no-banner] +# `--no-banner` (since vNEXT): suppress the web UI's "What's new" popup. The UI shows a curated +# per-version highlights modal once per version (localStorage `kbagent.whatsnew.seen`); this flag +# turns the UNSOLICITED popup off fleet-wide. Surfaced to the SPA via `GET /ui-config` +# ({"banner": bool}) -- NOT injected into index.html: that injection point was removed in favour of +# the session cookie, and an injected copy would miss the StaticFiles fallback that serves the shell +# for deep links, silently re-enabling the popup the operator suppressed. The command palette's +# "What's new" action still opens it on request -- the flag governs what appears uninvited. ``` diff --git a/docs/web-server.md b/docs/web-server.md index 1d3a6ae5..225a1cd2 100644 --- a/docs/web-server.md +++ b/docs/web-server.md @@ -121,8 +121,12 @@ and `next_run_at` so re-runs after restarts pick up where they left off. A NERD-themed React SPA that drives the API: - **Command palette** — `Ctrl+K` / `Cmd+K` anywhere: fuzzy jump to any - page, switch the active project, toggle the theme, open Swagger `/docs`. - Arrows + enter, esc closes. + page, switch the active project, toggle the theme, open Swagger `/docs`, + reopen **What's new**. Arrows + enter, esc closes. +- **What's new popup** *(since vNEXT)* — a curated per-version highlights + modal, shown once per version. See + [What's-new popup](#whats-new-popup-since-vnext) below for the curated + list's location, the storage key, and the `--no-banner` opt-out. - **Dashboard** — greeting, big Kai chat input, stat tiles (projects / agents / doctor / recent jobs / PAYG credits), scheduled-agent activity, suggested next steps, recent jobs panel. The credits tile @@ -280,6 +284,54 @@ stdout, and refuses any request that does not present it as `Authorization: Bearer `. Public paths: `/health/ping`, `/health/auth-info`, `/openapi.json`, `/docs`, `/redoc`. +### What's-new popup *(since vNEXT)* + +The web UI shows a curated per-version highlights modal on load, once per +version, so features like the command palette get discovered instead of +waiting to be stumbled upon. + +**Curated list — `web/frontend/src/whatsnew.ts`.** A hand-maintained +`WhatsNewRelease[]`, deliberately *not* the raw `changelog.py` output: the +changelog records everything, this reel records the handful of things a UI +user should look at. **Release PRs that ship user-visible UI features must +add an entry here** — same pass that resolves `vNEXT` placeholders. Adding +one is a single array element: + +```ts +{ version: "0.90.0", items: [{ title: "…", body: "…", hint: "ctrl+k" }] } +``` + +A release with no entry of its own is **not** silent: the UI falls back to +the newest entry at or below the running version, so users still see the +most recent curated reel (each shown at most once). Exact matching would +make the feature ship dark — the popup first runs in the release *after* +the one whose highlights seeded the list. Silence happens only when no +entry is `<=` the running version. + +**Mechanics.** + +- Dismissal is persisted to `localStorage["kbagent.whatsnew.seen"]` as the + release version string; the popup reappears only when the running version + moves to another curated entry. A PEP 440 pre-release suffix is stripped + before matching, so `0.90.0b1` sees the `0.90.0` reel. +- Esc, a backdrop click, and the "got it" button all dismiss and persist. +- The command palette's **What's new** action reopens it on demand, + ignoring both the seen marker and `--no-banner`. + +**Opt-out — `kbagent serve --no-banner`.** Suppresses the *unsolicited* +popup fleet-wide (an explicit palette request still works). The SPA reads +the switch from `GET /ui-config` -> `{"banner": bool}`, and fails **closed** +— while that request is in flight or if it fails, no popup. + +It is an endpoint rather than something injected into `index.html`, for two +reasons. There is no injection point to extend: the one that existed +(`window.__KBAGENT_TOKEN`) was removed in favour of the session cookie, and +`tests/test_serve_ui.py` asserts it stays gone. And injection would only +cover `GET /` and `GET /index.html` — the SPA shell is *also* served by the +StaticFiles `html=True` fallback for any unmatched path, and that copy would +carry no config, silently re-enabling the popup an operator had suppressed. +For a suppression flag, failing open is the wrong direction. + ### Session-registered projects Projects registered through `kbagent auth login --register-projects` carry a diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 3fe545b0..6ee21179 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -440,6 +440,7 @@ CLI parity for the `/agents` REST surface. Reads/writes `/agents.jso - `doctor` -- health checks (no `--fix` since v0.85.0 -- it only installed the MCP server). Includes a `sync_secrets` check (since 0.55.0): when run inside a sync working tree (`.keboola/manifest.json`), warns if any in-sync config holds plaintext `#`-secrets (#378); `skip` outside a sync tree. Also an `mcp_tool_tasks` check: FAILs when `agents.json` holds tasks using the removed `mcp_tool` action, listing each task in `details.tasks[]` -- map the tool to its native command via `docs/mcp-migration.md` and recreate the task as `--type cli_command` - `version` -- show the kbagent version and check for kbagent updates (kbagent only since v0.85.0); on a standalone binary it advertises the native channel's upgrade command instead of `kbagent update` (v0.79.0+) - `context` -- full usage instructions for AI agents +- `serve [--host HOST] [--port PORT] [--ui] [--ui-dist PATH] [--reload] [--log-level LVL] [--cors-origin ORIGIN] [--config-dir DIR] [--no-banner]` -- launch the FastAPI HTTP server backing the web UI. `--ui` mounts the built SPA at `/` and authenticates the browser with an HttpOnly `kbagent_session` cookie (nothing is injected into `index.html`). `--no-banner` *(since vNEXT)* suppresses the UI's "What's new" popup -- a curated per-version highlights modal shown once per version, dismissal persisted in localStorage `kbagent.whatsnew.seen`. The SPA reads the switch from `GET /ui-config` -> `{"banner": bool}`; it gates only the UNSOLICITED popup, so the command palette's "What's new" action still opens it on request. Requires the optional `server` extra ## Global Flags | Flag | Description | diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index d257c3c7..56d8e07a 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -1837,6 +1837,7 @@ kbagent serve [--host HOST] [--port PORT] [--ui] [--ui-dist PATH] [--reload] [--log-level LVL] [--cors-origin ORIGIN] [--config-dir DIR] + [--no-banner] Launch the FastAPI HTTP server backing the web UI. Two modes: - `--ui` (single-process, recommended): bundles the built React SPA from @@ -1852,6 +1853,13 @@ subprocesses). Requires the optional 'server' extra: `uv pip install -e ".[server]"`. + --no-banner (since vNEXT) suppresses the web UI's "What's new" popup -- + a curated per-version highlights modal the UI shows once per version + (dismissal persisted in localStorage `kbagent.whatsnew.seen`). The SPA + reads the switch from `GET /ui-config` -> {{"banner": bool}}; nothing is + injected into index.html. It governs only the UNSOLICITED popup: the + command palette's "What's new" action still opens it on request. + kbagent doctor Health checks (no --fix since 0.85.0 -- it only installed the MCP server). Inside a sync working tree, the sync_secrets check (since 0.55.0) warns about diff --git a/src/keboola_agent_cli/commands/serve.py b/src/keboola_agent_cli/commands/serve.py index fde0fa63..607d2604 100644 --- a/src/keboola_agent_cli/commands/serve.py +++ b/src/keboola_agent_cli/commands/serve.py @@ -184,6 +184,11 @@ def serve_command( "Implies --ui." ), ), + no_banner: bool = typer.Option( + False, + "--no-banner", + help="Suppress the What's-new popup in the web UI.", + ), ) -> None: """Launch the kbagent HTTP API server. @@ -273,6 +278,9 @@ def serve_command( cors_origins=cors, serve_url=serve_url, ui_dist=resolved_ui_dist, + # Inverted at the boundary: the CLI flag is opt-OUT ("--no-banner"), + # the app-level switch is a plain positive ("is the banner allowed"). + ui_banner=not no_banner, ) if resolved_ui_dist: diff --git a/src/keboola_agent_cli/server/app.py b/src/keboola_agent_cli/server/app.py index f18ebe29..a6c4b5a3 100644 --- a/src/keboola_agent_cli/server/app.py +++ b/src/keboola_agent_cli/server/app.py @@ -549,6 +549,7 @@ def create_app( cors_origins: list[str] | None = None, serve_url: str | None = None, ui_dist: str | None = None, + ui_banner: bool = True, ) -> FastAPI: """Build and configure the FastAPI application. @@ -569,12 +570,17 @@ def create_app( we do it server-side via an ASGI path-rewrite middleware), 2) mounts the dist directory at ``/`` so static assets and the SPA fallback are served by uvicorn directly, - 3) intercepts ``GET /`` to inject ``window.__KBAGENT_TOKEN`` into - ``index.html`` so the SPA boots already authenticated -- no - BFF and no manual paste step. + 3) intercepts ``GET /`` to set the HttpOnly ``kbagent_session`` + cookie so the SPA boots already authenticated -- no BFF and no + manual paste step. Nothing is injected into ``index.html``; + see :func:`_install_ui` for why the older + ``window.__KBAGENT_TOKEN`` script injection was removed. If the path does not exist, the UI mount is skipped silently and a warning is logged so ``--ui`` typos don't break the API path. + ui_banner: Whether the web UI may show its unsolicited "What's new" + popup. Surfaced to the SPA over ``GET /ui-config`` rather than + injected into the page -- see that endpoint's docstring. Returns: Configured FastAPI app ready for uvicorn. @@ -710,6 +716,10 @@ async def _generic_handler(_request, exc: Exception): app.include_router(agents.router) app.state.auth_token = resolved_token + # Read back by GET /ui-config. Set unconditionally (not only under + # ``ui_dist``) because the SPA also runs against a bare `kbagent serve` + # through the Vite dev server / Node BFF, where no UI is mounted here. + app.state.ui_banner = ui_banner if ui_dist: _install_ui(app, ui_dist=ui_dist, token=resolved_token) diff --git a/src/keboola_agent_cli/server/routers/health.py b/src/keboola_agent_cli/server/routers/health.py index f9065470..757d25ea 100644 --- a/src/keboola_agent_cli/server/routers/health.py +++ b/src/keboola_agent_cli/server/routers/health.py @@ -4,7 +4,7 @@ from typing import Any -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from ... import __version__ from ...changelog import CHANGELOG, get_changelog @@ -38,6 +38,33 @@ def version(registry: ServiceRegistry = Depends(get_registry)) -> dict[str, Any] return registry.version.get_versions() +@router.get("/ui-config", summary="Web UI bootstrap configuration") +def ui_config(request: Request) -> dict[str, Any]: + """Non-secret switches the web UI reads at boot. + + ``banner`` is ``kbagent serve``'s ``--no-banner`` inverted: false tells the + SPA to suppress its unsolicited "What's new" popup. A user who explicitly + asks for the popup (the command palette's "What's new" action) still gets + it -- the flag governs what appears uninvited, not what the user requests. + + Delivered as an endpoint rather than injected into ``index.html``: + + * There is no injection point to extend. The one that used to exist + (``window.__KBAGENT_TOKEN``) was deliberately removed in favour of the + HttpOnly cookie -- see :func:`..app._install_ui` -- and + ``test_serve_ui.py`` asserts it stays gone. + * Injection would only cover ``GET /`` and ``GET /index.html``. The SPA + shell is ALSO served by the StaticFiles ``html=True`` fallback for any + unmatched path, and that copy would carry no config -- so a deep link + would silently re-enable the very popup the operator suppressed. For a + suppression flag, failing open is the wrong direction. + + Reading from ``app.state`` keeps this correct however the shell was + served, and matches how the SPA already gets ``/version``. + """ + return {"banner": bool(getattr(request.app.state, "ui_banner", True))} + + @router.get("/changelog", summary="List release notes") def changelog(limit: int | None = None) -> dict[str, Any]: """Return release entries; pass ``?limit=N`` for the latest N.""" diff --git a/tests/test_serve_ui.py b/tests/test_serve_ui.py index 92560251..fde34c7d 100644 --- a/tests/test_serve_ui.py +++ b/tests/test_serve_ui.py @@ -264,6 +264,64 @@ def test_header_takes_precedence_when_both_present(self, tmp_path: Path, ui_dist assert resp.status_code == 200 +class TestUiConfigBanner: + """``GET /ui-config`` -- the SPA's non-secret bootstrap switches. + + Delivered as an endpoint, NOT injected into ``index.html``: the injection + point this would have extended (``window.__KBAGENT_TOKEN``) was removed in + favour of the session cookie, and an injected copy would miss the + StaticFiles ``html=True`` fallback that serves the shell for deep links -- + letting a suppressed popup reappear. ``test_banner_flag_not_injected_into_html`` + pins the "still nothing in the HTML" half of that decision. + """ + + def test_banner_defaults_to_enabled(self, tmp_path: Path) -> None: + client = _make_client(tmp_path, token="t") + resp = client.get("/ui-config", headers={"authorization": "Bearer t"}) + assert resp.status_code == 200 + assert resp.json() == {"banner": True} + + def test_banner_disabled_when_flag_set(self, tmp_path: Path) -> None: + app = create_app( + config_dir=str(tmp_path / "cfg"), + auth_token="t", + ui_banner=False, + ) + client = TestClient(app, raise_server_exceptions=False) + resp = client.get("/ui-config", headers={"authorization": "Bearer t"}) + assert resp.status_code == 200 + assert resp.json() == {"banner": False} + + def test_banner_flag_available_without_ui_mount(self, tmp_path: Path) -> None: + # The SPA also runs against a bare `kbagent serve` via the Vite dev + # server / Node BFF, where no dist is mounted -- the switch must still + # be readable there. + app = create_app(config_dir=str(tmp_path / "cfg"), auth_token="t", ui_banner=False) + client = TestClient(app, raise_server_exceptions=False) + assert client.get("/ui-config", headers={"authorization": "Bearer t"}).json() == { + "banner": False + } + + def test_banner_flag_not_injected_into_html(self, tmp_path: Path, ui_dist: Path) -> None: + # The shell stays a static artifact: no config script, no secrets. + app = create_app( + config_dir=str(tmp_path / "cfg"), + auth_token="t", + ui_dist=str(ui_dist), + ui_banner=False, + ) + client = TestClient(app, raise_server_exceptions=False) + body = client.get("/").text + assert "__KBAGENT_UI__" not in body + assert "banner" not in body + + def test_ui_config_requires_auth(self, tmp_path: Path, ui_dist: Path) -> None: + # Not part of the public bootstrap surface -- the SPA reads it with the + # session cookie, exactly like /version. + client = _make_client(tmp_path, ui_dist=ui_dist, token="t") + assert client.get("/api/ui-config").status_code == 401 + + class TestUiOptional: def test_no_ui_path_no_ui_routes(self, tmp_path: Path) -> None: client = _make_client(tmp_path, ui_dist=None, token="t") diff --git a/web/frontend/src/components/CommandPalette.tsx b/web/frontend/src/components/CommandPalette.tsx index 6bad0ea8..60e975ae 100644 --- a/web/frontend/src/components/CommandPalette.tsx +++ b/web/frontend/src/components/CommandPalette.tsx @@ -99,7 +99,7 @@ export function CommandPalette() { const inputRef = useRef(null); const listRef = useRef(null); - const { project, setProject, setBranchId, setPage } = useUIState(); + const { project, setProject, setBranchId, setPage, setWhatsNewForced } = useUIState(); const { theme, toggle } = useTheme(); // Projects are already cached by the top bar under this exact key, so @@ -183,8 +183,17 @@ export function CommandPalette() { icon: ArrowRight, run: () => window.open("/docs", "_blank", "noopener,noreferrer"), }); + out.push({ + id: "action:whatsnew", + kind: "action", + label: "What's new", + hint: "release highlights", + keywords: "changelog release highlights whatsnew version news", + icon: Sparkles, + run: () => setWhatsNewForced(true), + }); return out; - }, [projectsQ.data, setPage, setProject, setBranchId, theme, toggle]); + }, [projectsQ.data, setPage, setProject, setBranchId, theme, toggle, setWhatsNewForced]); const results = useMemo(() => { const q = query.trim(); diff --git a/web/frontend/src/components/WhatsNew.tsx b/web/frontend/src/components/WhatsNew.tsx new file mode 100644 index 00000000..88b3d59f --- /dev/null +++ b/web/frontend/src/components/WhatsNew.tsx @@ -0,0 +1,204 @@ +/** + * "What's new" release-highlights popup. + * + * Shows the curated reel from ``whatsnew.ts`` once per version, then never + * again unless the user explicitly asks for it from the command palette. + * + * Three independent gates decide whether the UNSOLICITED popup appears: + * 1. the operator has not disabled it (``GET /ui-config`` -> ``banner``), + * 2. a curated entry exists for the running version, and + * 3. the seen-marker in localStorage is not already that version. + * + * All three must pass. The banner gate FAILS CLOSED: while the query is in + * flight, if it errors, or if the flag is anything other than ``true``, no + * auto-popup. A release-notes modal is never important enough to appear + * against an operator who ran ``kbagent serve --no-banner``. + * + * The forced path (command palette action) bypasses gates 1 and 3 -- the + * operator's ``--no-banner`` suppresses the *unsolicited* popup, not one the + * user just asked for by name. + */ +import { useQuery } from "@tanstack/react-query"; +import { Sparkles, X } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { api } from "../api/client"; +import { useUIState } from "../state"; +import { whatsNewFor } from "../whatsnew"; + +interface VersionResp { + kbagent: { version: string; latest_version: string; up_to_date: boolean }; +} + +interface UiConfigResp { + banner: boolean; +} + +/** Last dismissed release version, e.g. "0.89.0". */ +const SEEN_KEY = "kbagent.whatsnew.seen"; + +/** Safari private mode throws on read as well as write -- never let it crash the shell. */ +function readSeen(): string | null { + try { + return window.localStorage.getItem(SEEN_KEY); + } catch { + return null; + } +} + +function writeSeen(version: string): void { + try { + window.localStorage.setItem(SEEN_KEY, version); + } catch { + // Storage unavailable (private mode, quota). The popup will reappear on + // the next load -- annoying, but strictly better than a white screen. + } +} + +/** Inline keycap, matching the inline-code treatment used elsewhere in the UI. */ +function Keycap({ children }: { children: string }) { + return ( + + {children} + + ); +} + +export function WhatsNew() { + const { setPage, whatsNewForced, setWhatsNewForced } = useUIState(); + const [autoOpen, setAutoOpen] = useState(false); + // Drives the single entrance transition; flipped one frame after mount. + const [shown, setShown] = useState(false); + + // Same query key + staleTime as StatusBar, so this shares the cache and + // costs no extra request. + const versionQ = useQuery({ + queryKey: ["version"], + queryFn: () => api.get("/version"), + staleTime: 5 * 60_000, + }); + const uiConfigQ = useQuery({ + queryKey: ["ui-config"], + queryFn: () => api.get("/ui-config"), + staleTime: 5 * 60_000, + }); + + const release = whatsNewFor(versionQ.data?.kbagent.version); + // Fail closed: loading and error both read as "not enabled". + const bannerEnabled = uiConfigQ.data?.banner === true; + + useEffect(() => { + if (!bannerEnabled || !release) return; + if (readSeen() === release.version) return; + setAutoOpen(true); + }, [bannerEnabled, release]); + + const open = autoOpen || whatsNewForced; + + useEffect(() => { + if (!open) { + setShown(false); + return; + } + const t = window.setTimeout(() => setShown(true), 10); + return () => window.clearTimeout(t); + }, [open]); + + const dismiss = useCallback(() => { + if (release) writeSeen(release.version); + setAutoOpen(false); + setWhatsNewForced(false); + }, [release, setWhatsNewForced]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") dismiss(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, dismiss]); + + if (!open) return null; + + const version = versionQ.data?.kbagent.version; + + return createPortal( + // z-[55]: above drawers (z-50), below the command palette (z-[60]). +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label="What's new" + > +
+ +

What's new

+ {release ? ( + {release.version} + ) : null} + +
+ +
+ {release ? ( +
    + {release.items.map((item) => ( +
  • +
    + + {item.title} + + {item.hint ? {item.hint} : null} +
    +

    + {item.body} +

    +
  • + ))} +
+ ) : ( + // Forced open on a version nobody curated highlights for. Say so + // plainly rather than rendering an empty shell or inventing items. +

+ Nothing curated for {version ?? "this version"} — see the full changelog for + everything that shipped. +

+ )} +
+ +
+ + +
+
+
, + document.body, + ); +} diff --git a/web/frontend/src/layout/Shell.tsx b/web/frontend/src/layout/Shell.tsx index 06db03d2..a9736d89 100644 --- a/web/frontend/src/layout/Shell.tsx +++ b/web/frontend/src/layout/Shell.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { CommandPalette } from "../components/CommandPalette"; +import { WhatsNew } from "../components/WhatsNew"; import { Sidebar } from "./Sidebar"; import { StatusBar } from "./StatusBar"; import { TopBar } from "./TopBar"; @@ -18,6 +19,9 @@ export function Shell({ children }: { children: ReactNode }) { {/* Mounted at the shell so Ctrl/Cmd+K works from every page. Renders null until opened, so it costs nothing while closed. */} + {/* Release-highlights popup: shows once per version, or on demand from + the palette. Renders null when there is nothing to show. */} + ); } diff --git a/web/frontend/src/state.tsx b/web/frontend/src/state.tsx index 13d85467..3d8aefc8 100644 --- a/web/frontend/src/state.tsx +++ b/web/frontend/src/state.tsx @@ -46,6 +46,12 @@ interface UIState { // Avoids re-typing while keeping all chat plumbing on a single page. pendingLocalAiMessage: string | null; setPendingLocalAiMessage: (m: string | null) => void; + // Force-open slot: the command palette's "What's new" action flips this to + // true, and the WhatsNew modal reads it on render, shows itself regardless + // of the seen-marker, and clears the slot when dismissed. Same hand-off + // shape as pendingLocalAiMessage -- one writer, one reader, self-clearing. + whatsNewForced: boolean; + setWhatsNewForced: (v: boolean) => void; } const UIStateContext = createContext(null); @@ -56,6 +62,7 @@ export function UIStateProvider({ children }: { children: ReactNode }) { const [branchId, setBranchId] = useState(null); const [manageToken, setManageToken] = useState(null); const [pendingLocalAiMessage, setPendingLocalAiMessage] = useState(null); + const [whatsNewForced, setWhatsNewForced] = useState(false); return ( {children} diff --git a/web/frontend/src/whatsnew.ts b/web/frontend/src/whatsnew.ts new file mode 100644 index 00000000..518b88a9 --- /dev/null +++ b/web/frontend/src/whatsnew.ts @@ -0,0 +1,129 @@ +/** + * Curated "What's new" highlights, per release. + * ============================================= + * + * HAND-MAINTAINED. This is NOT the changelog -- `changelog.py` stays the + * complete, authoritative record of every release note, and the Changelog page + * renders it. This file is a short, curated reel of the *UI-visible* things a + * returning user would want pointed out, written in the second person and kept + * to a handful of items per version. + * + * MUST be updated in the release PR whenever a release ships user-visible UI + * features. Add a new `WhatsNewRelease` entry keyed by the exact + * `pyproject.toml` version, newest first. + * + * A release with no entry of its own is NOT silent: `whatsNewFor` falls back + * to the newest entry at or below the running version, so users still get the + * most recent curated reel (capped at one showing each by the seen-marker). + * Silence happens only when NO entry is <= the running version -- i.e. before + * the first curated release. See `whatsNewFor` for why exact matching would + * make the feature ship dark. + */ + +export interface WhatsNewItem { + /** Short headline -- a few words, sentence case. */ + title: string; + /** One or two sentences. What it is and why you'd reach for it. */ + body: string; + /** Optional keyboard hint, rendered as an inline keycap (e.g. "ctrl+k"). */ + hint?: string; +} + +export interface WhatsNewRelease { + /** Exact release version, e.g. "0.89.0" (no pre-release suffix). */ + version: string; + items: WhatsNewItem[]; +} + +export const WHATS_NEW: WhatsNewRelease[] = [ + { + version: "0.89.0", + items: [ + { + title: "Command palette", + body: + "One keystroke to jump to any page, switch the active project, toggle the theme " + + "or open the Swagger docs. It resolves locally, so the list never waits on a request.", + hint: "ctrl+k / ⌘k", + }, + { + title: "Tokens page", + body: + "Create, rotate and revoke scoped Storage tokens without the web UI. The secret is " + + "shown once at mint; the opt-in \"derive last-used\" pass sorts dormant tokens first, " + + "so reading order is cleanup order.", + }, + { + title: "Trash & restore for configs", + body: + "Deleting a configuration is soft. The Configs page has a Trash tab listing what was " + + "deleted, with per-row restore -- no more digging through the API to undo a mistake.", + }, + { + title: "Re-run and terminate jobs", + body: + "Both actions are available straight from the Jobs table and from the job drawer. " + + "A re-run preserves the job's original branch, so a dev-branch job never silently " + + "re-fires against production.", + }, + { + title: "Editable column descriptions + table layout", + body: + "Click any description in a table's Schema tab to edit it in place -- written through " + + "the native endpoint the UI, the MCP server and the warehouse all read. The Info tab " + + "now also shows BigQuery partitioning and clustering.", + }, + { + title: "PAYG credits tile", + body: + "The Dashboard shows remaining credits and minutes for the active project, so you " + + "notice a draining balance before a job queue does.", + }, + { + title: "Flow notifications", + body: + "Every flow gets a read-only Notifications tab listing who actually gets paged, " + + "including the project-wide catch-all subscriptions that fire for every job.", + }, + ], + }, +]; + +/** Compare two plain `X.Y.Z` versions. Negative when `a` sorts before `b`. */ +function compareVersions(a: string, b: string): number { + const pa = a.split(".").map((n) => Number.parseInt(n, 10) || 0); + const pb = b.split(".").map((n) => Number.parseInt(n, 10) || 0); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pa[i] ?? 0) - (pb[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +/** + * Pick the curated reel to show on a given running kbagent version: the + * NEWEST entry at or below it. + * + * Not an exact match, deliberately. An exact match makes the feature ship + * dark: this popup first runs in the release AFTER the one whose highlights + * seeded the list, so on day one the running version would have no entry and + * nobody would ever see a reel. "Newest entry <= running version" also + * degrades correctly in every other direction -- a user who skipped a release + * still gets the most recent curated reel rather than nothing, and once a + * release PR adds an entry for the version actually shipping, that entry wins + * immediately. The seen-marker still caps it at one showing per reel. + * + * A PEP 440 pre-release suffix on the RUNNING version is stripped before + * comparing, so `0.90.0b1` is treated as `0.90.0`. Entries themselves are + * always keyed by the plain release version. + */ +export function whatsNewFor(version: string | undefined): WhatsNewRelease | undefined { + if (!version) return undefined; + const base = version.trim().replace(/(a|b|rc)\d+$/i, ""); + let best: WhatsNewRelease | undefined; + for (const release of WHATS_NEW) { + if (compareVersions(release.version, base) > 0) continue; + if (!best || compareVersions(release.version, best.version) > 0) best = release; + } + return best; +}