Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
```
56 changes: 54 additions & 2 deletions docs/web-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -280,6 +284,54 @@ stdout, and refuses any request that does not present it as
`Authorization: Bearer <token>`. 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ CLI parity for the `/agents` REST surface. Reads/writes `<config_dir>/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 |
Expand Down
8 changes: 8 additions & 0 deletions src/keboola_agent_cli/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/keboola_agent_cli/commands/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
16 changes: 13 additions & 3 deletions src/keboola_agent_cli/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 28 additions & 1 deletion src/keboola_agent_cli/server/routers/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
58 changes: 58 additions & 0 deletions tests/test_serve_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
13 changes: 11 additions & 2 deletions web/frontend/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export function CommandPalette() {
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(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
Expand Down Expand Up @@ -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();
Expand Down
Loading