I3eka commented on code in PR #43133:
URL: https://github.com/apache/superset/pull/43133#discussion_r4385439802


##########
superset/ai/runtime/messages.py:
##########
@@ -0,0 +1,574 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+The default runtime: a plain tool-use loop over the provider's message API.
+
+Chosen as the default because it needs nothing beyond an HTTP call — no agent
+engine subprocess, no working directory, no bundled binary — so it works with
+whatever provider a deployment configures.
+"""
+
+from __future__ import annotations
+
+import logging
+import time
+from collections.abc import AsyncIterator
+from typing import Any
+
+from superset.ai.events import (
+    assistant_delta_event,
+    checkpoint_event,
+    error_event,
+    final_event,
+    GENERIC_ERROR_MESSAGE,
+    StreamEvent,
+    thinking_event,
+    thoughts_event,
+)
+from superset.ai.llm.base import (
+    CompletionRequest,
+    LLMError,
+    LLMResponse,
+    Message,
+    StreamEventKind,
+    ToolCall,
+    ToolResult,
+)
+from superset.ai.runtime.base import BaseAgentRuntime, RunRequest, RunResult
+from superset.ai.telemetry import (
+    current_run,
+    POLICY_DENIED,
+    RunRecorder,
+    TOOL_UNAVAILABLE,
+)
+from superset.ai.types import MessageRole, ProgressStage, TokenUsage
+
+logger = logging.getLogger(__name__)
+
+#: How much of a tool's output is kept on the persisted message. The model
+#: still sees the whole thing; this is the audit copy.
+_RECORDED_OUTPUT_LIMIT = 2_000
+
+#: Size of the chunks the finished answer is delivered in.
+_DELIVERY_CHUNK_SIZE = 512
+
+#: How much reasoning is kept on the result. Reasoning can run several times
+#: longer than the answer, and this is persisted next to it.
+_RECORDED_THOUGHTS_LIMIT = 8_000
+
+_NO_ANSWER = (
+    "I wasn't able to reach an answer for that. Try narrowing the question, "
+    "or naming the dataset you have in mind."
+)
+
+
+class MessagesApiRuntime(BaseAgentRuntime):
+    """
+    Alternates model calls and tool calls until the model stops asking.
+
+    Two behaviours are worth understanding before changing this class.
+
+    First, prose the model emits *before* a tool call is treated as reasoning,
+    not answer: it becomes a ``thoughts`` event and is dropped from the answer.
+    A model narrating "the orders table looks right, let me check" is stating a
+    hypothesis it may abandon, and appending that to the answer produces a
+    reply that contradicts itself.
+
+    Second, the loop always terminates and never raises for an operational
+    failure. By the time it runs, response headers have been flushed and an
+    exception can no longer become an HTTP status, so every failure is an 
event.
+    """
+
+    def __init__(self, provider: Any) -> None:
+        super().__init__(provider)
+        self._result = RunResult()
+        #: Set when the model signals it has finished answering.
+        self._finished = False
+        #: The most recent round trip's response, or ``None`` if it failed. The
+        #: turn methods are generators and cannot return a value.
+        self._last_response: LLMResponse | None = None
+        #: Whether any answer text has already been sent as it was generated. 
The
+        #: finished answer is only replayed in chunks when it has not.
+        self._streamed_text = False
+
+    @property
+    def result(self) -> RunResult:
+        return self._result
+
+    async def run(self, request: RunRequest) -> AsyncIterator[StreamEvent]:
+        self._result = RunResult()
+        self._finished = False
+        self._last_response = None
+        self._streamed_text = False
+        answer_parts: list[str] = []
+
+        yield thinking_event(ProgressStage.START, "Working on your question")
+
+        # The provider's connection pool belongs to the loop this run is driven
+        # on, and the caller closes that loop as soon as the run ends. Closing
+        # here — inside the loop, however the run finishes, including when the
+        # generator is abandoned mid-way by a user pressing stop — is what 
keeps
+        # a client from being finalised against a dead loop.
+        try:
+            async for event in self._turn_loop(request, answer_parts):
+                yield event
+
+            # A run that failed or was abandoned has already said so; emitting 
an
+            # answer as well would contradict it.
+            if self._result.error is not None or self._result.cancelled:
+                return
+
+            answer = "\n\n".join(part for part in answer_parts if part).strip()
+            self._result.answer = answer or _NO_ANSWER
+
+            # Only replayed when nothing was streamed — a provider without
+            # streaming support still gets to deliver its answer progressively.
+            # Replaying after live text would show the answer twice.
+            if not self._streamed_text:
+                for chunk in _chunk(self._result.answer):
+                    yield assistant_delta_event(chunk)
+            yield final_event(self._result.answer)
+        finally:
+            await self.provider.aclose()
+
+    async def _turn_loop(
+        self,
+        request: RunRequest,
+        answer_parts: list[str],
+    ) -> AsyncIterator[StreamEvent]:
+        """
+        Alternate model and tool calls until the model stops or a budget runs 
out.
+
+        Appends to ``answer_parts`` rather than returning the answer, because 
an
+        async generator cannot both yield events and return a value.
+        """
+        deadline = time.monotonic() + request.timeout_seconds
+        conversation = list(request.messages)
+
+        for turn in range(1, request.max_turns + 1):
+            self._result.turns = turn
+
+            if self._should_stop(request, deadline):
+                if self._result.timed_out:
+                    yield thinking_event(
+                        ProgressStage.FALLBACK,
+                        "Taking longer than expected — answering with what I 
have",
+                    )
+                return
+
+            async for event in self._safe_turn(request, conversation, turn):
+                yield event
+            response = self._last_response
+            if response is None:
+                yield error_event()
+                return
+
+            async for event in self._consume(
+                request, response, conversation, answer_parts
+            ):
+                yield event
+
+            if self._finished or self._result.cancelled:
+                return
+
+        # Budget exhausted without the model choosing to stop.
+        yield thinking_event(
+            ProgressStage.FALLBACK,
+            "Reached the step limit — answering with what I have",
+        )
+
+    async def _consume(
+        self,
+        request: RunRequest,
+        response: LLMResponse,
+        conversation: list[Message],
+        answer_parts: list[str],
+    ) -> AsyncIterator[StreamEvent]:
+        """Act on one model response, running any tools it asked for."""
+        if response.thinking:
+            self._record_thoughts(response.thinking)
+            yield thoughts_event(response.thinking)
+
+        if not response.wants_tools:
+            self._finished = True
+            if response.text:
+                answer_parts.append(response.text)
+                # Recorded as it arrives, not just at the end, so a run stopped
+                # after this point still persists what the user already saw.
+                self._result.answer = "\n\n".join(
+                    part for part in answer_parts if part
+                ).strip()
+            return
+
+        # Prose accompanying a tool call is reasoning, not answer.
+        if response.text:
+            self._record_thoughts(response.text)
+            yield thoughts_event(response.text)
+
+        conversation.append(
+            Message(
+                role=MessageRole.ASSISTANT,
+                content=response.text,
+                tool_calls=list(response.tool_calls),
+            )
+        )
+
+        results: list[ToolResult] = []
+        async for event in self._run_tools(request, response.tool_calls, 
results):
+            yield event
+
+        conversation.append(Message(role=MessageRole.USER, 
tool_results=results))
+
+    async def _run_tools(
+        self,
+        request: RunRequest,
+        calls: list[ToolCall],
+        results: list[ToolResult],
+    ) -> AsyncIterator[StreamEvent]:
+        """Execute this turn's tool calls, appending outcomes to 
``results``."""
+        for call in calls:
+            if self._cancelled(request):
+                self._result.cancelled = True
+                return
+
+            yield thinking_event(
+                ProgressStage.TOOL,
+                f"Running {call.name}",
+                {"tool_name": call.name},
+            )
+            result, detail = self._invoke_tool(request, call)
+            results.append(result)
+            record = self._record_call(call, result, detail)
+
+            # The frame carries the same record that is persisted, rather than 
a
+            # subset assembled separately. The subset was missing the arguments
+            # and the output, so a step expanded during a run showed nothing at
+            # all unless its tool happened to supply a display — and then 
filled
+            # itself in on reload, which looked like the detail arrived late.
+            # Sharing one record makes that class of drift impossible.
+            yield checkpoint_event(
+                f"{'Failed' if result.is_error else 'Finished'} {call.name}",
+                # ``tool_name`` as well as ``name``: the progress frames use 
that
+                # key, so a consumer reading either finds what it expects.
+                {"tool_name": call.name, **record},
+            )
+
+    async def _safe_turn(
+        self,
+        request: RunRequest,
+        conversation: list[Message],
+        turn: int,
+    ) -> AsyncIterator[StreamEvent]:
+        """
+        One model round trip, converting failure into a ``None`` response.
+
+        A generator rather than a coroutine so the answer can reach the client 
as
+        the model produces it. The response is handed back on
+        :attr:`_last_response` because an async generator cannot both yield 
events
+        and return a value — the same reason ``_turn_loop`` writes into
+        ``answer_parts``.
+
+        The failure detail goes to the log; the caller emits a message that 
cannot
+        leak a URL, a credential or a fragment of someone else's query.
+        """
+        recorder = current_run()
+        started = time.monotonic()
+        self._last_response = None
+        try:
+            async for event in self._one_turn(request, conversation):
+                yield event
+        except LLMError as ex:
+            logger.warning("AI provider error on turn %s: %s", turn, ex)
+            self._result.error = str(ex)
+            self._trace_model_call(recorder, request, turn, started, error=ex)
+            self._last_response = None
+            return
+        except Exception as ex:  # pylint: disable=broad-except
+            logger.exception("Unexpected error in AI runtime on turn %s", turn)
+            self._result.error = GENERIC_ERROR_MESSAGE
+            self._trace_model_call(recorder, request, turn, started, error=ex)
+            self._last_response = None
+            return
+        self._trace_model_call(
+            recorder, request, turn, started, response=self._last_response
+        )
+
+    def _trace_model_call(
+        self,
+        recorder: RunRecorder,
+        request: RunRequest,
+        turn: int,
+        started: float,
+        response: LLMResponse | None = None,
+        error: BaseException | None = None,
+    ) -> None:
+        """
+        Report one round trip to telemetry.
+
+        Content is passed as-is; whether any of it survives into a trace is the
+        redaction policy's decision, made in one place rather than here.
+        """
+        if not recorder.enabled:
+            return
+        usage = response.usage if response is not None else TokenUsage()
+        recorder.model_call(
+            turn=turn,
+            # The concrete identifier when the provider reported one, and the
+            # capability tier otherwise, so a trace can always be grouped by
+            # what the run asked for.
+            model=usage.get("model") or request.model_alias.value,
+            duration_ms=int((time.monotonic() - started) * 1000),
+            input_tokens=usage.get("input_tokens"),
+            output_tokens=usage.get("output_tokens"),
+            stop_reason=response.stop_reason if response is not None else None,
+            error_type=type(error).__name__ if error is not None else None,
+            system_prompt=request.system_prompt,
+            response_text=response.text if response is not None else None,
+        )
+        if error is not None:
+            recorder.error(error)
+
+    async def _one_turn(
+        self,
+        request: RunRequest,
+        conversation: list[Message],
+    ) -> AsyncIterator[StreamEvent]:
+        """
+        Call the model once, yielding answer text as the model produces it.
+
+        Streaming is used when the provider supports it. The assembled response
+        is left on :attr:`_last_response` rather than returned, because a
+        generator cannot do both; it has the same shape either way, so callers 
do
+        not branch on which path ran.
+        """
+        completion = CompletionRequest(
+            messages=conversation,
+            system=request.system_prompt,
+            model_alias=request.model_alias,
+            tools=tuple(request.tools.definitions()) if request.tools else (),
+        )
+
+        if not self.provider.supports_streaming:
+            self._last_response = await self.provider.complete(completion)
+            return
+
+        text_parts: list[str] = []
+        thinking_parts: list[str] = []
+        tool_calls: list[ToolCall] = []
+        usage = None
+
+        async for event in self.provider.stream(completion):
+            if event.kind is StreamEventKind.TEXT:
+                text_parts.append(event.text)
+                # Forwarded as it arrives. Text is buffered as well, because 
the
+                # turn is only known to be an answer once the model stops 
without
+                # asking for a tool — prose before a tool call is reasoning, 
and
+                # is re-routed as such in ``_consume``. A reader that has 
already
+                # seen it replaces its copy on the ``final`` frame.
+                if event.text:
+                    self._streamed_text = True
+                    yield assistant_delta_event(event.text)

Review Comment:
   Fixed on this branch in 5c278b2bde by keeping ordinary assistant text in the 
answer channel across tool calls and abnormal termination. Only actual provider 
reasoning enters thoughts. The live deltas and stored partial answer now use 
the same classification; cancellation/tool-turn regressions and the updated 
telemetry transcript assertion pass.



##########
superset/ai/orchestrator.py:
##########
@@ -0,0 +1,647 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Runs one assistant turn end to end.
+
+Sits between the HTTP layer and the runtime: loads the conversation, assembles
+the prompt, resolves the tools the chosen profile allows, drives the runtime,
+publishes every event to the bus, and records the outcome on the assistant
+message.
+
+Deliberately independent of *where* it runs. The same function body serves the
+inline path and the Celery path, which is what makes the execution mode a
+configuration choice rather than two implementations that drift apart.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import uuid as uuid_module
+from collections.abc import AsyncIterator, Iterator
+from dataclasses import dataclass
+from typing import Any
+
+from superset.ai.events import (
+    cancelled_event,
+    done_event,
+    error_event,
+    GENERIC_ERROR_MESSAGE,
+    session_event,
+    StreamEvent,
+)
+from superset.ai.llm.base import Message
+from superset.ai.telemetry import bind_run, current_run, start_run
+from superset.ai.types import MessageRole, MessageStatus, RunOutcome, 
StreamEventType
+from superset.utils.decorators import transaction
+
+logger = logging.getLogger(__name__)
+
+#: Cache key prefix for a run's cancellation flag. A flag rather than a signal
+#: because a worker cannot be interrupted mid-call reliably; the runtime checks
+#: this between steps.
+_CANCEL_PREFIX = "ai-cancel-"
+
+#: How long a cancellation request stays meaningful.
+_CANCEL_TTL_SECONDS = 900
+
+#: Stored when a run is stopped before it produced any answer, so the
+#: transcript still records that the turn happened.
+_STOPPED_WITHOUT_ANSWER = "_Stopped before an answer was produced._"
+
+#: Stored when a run exhausted its time budget without saying anything. Phrased
+#: as something the user can act on, because retrying is usually the right 
move.
+_TIMED_OUT_WITHOUT_ANSWER = (
+    "The assistant ran out of time before it could answer. Please try again."
+)
+
+#: Ceiling on the page context recorded on a message. Well below the prompt's 
own
+#: limit: this is stored per turn and read back with the whole transcript.
+_RECORDED_CONTEXT_LIMIT = 4_000
+
+
+@dataclass
+class TurnRequest:
+    """One unit of work: answer the latest message on a thread."""
+
+    thread_uuid: str
+    user_id: int
+    run_id: str
+    #: Assistant message row to fill in. Created before the run starts so a
+    #: client that reconnects has something to attach to.
+    assistant_message_uuid: str
+    profile_key: str | None = None
+    #: Concrete model to pin, overriding the profile's tier.
+    model: str | None = None
+    #: What the user had on screen when they asked. Supplied by the client,
+    #: which is the only party that knows which tab is open, what is typed in
+    #: the editor and which filters are applied.
+    page_context: dict[str, Any] | None = None
+
+    def to_payload(self) -> dict[str, Any]:
+        """Serialise for the task broker."""
+        return {
+            "thread_uuid": self.thread_uuid,
+            "user_id": self.user_id,
+            "run_id": self.run_id,
+            "assistant_message_uuid": self.assistant_message_uuid,
+            "profile_key": self.profile_key,
+            "model": self.model,
+            "page_context": self.page_context,
+        }
+
+    @classmethod
+    def from_payload(cls, payload: dict[str, Any]) -> TurnRequest:
+        """Rebuild from a broker payload."""
+        return cls(**payload)
+
+
+def new_run_id() -> str:
+    """Identifier for one run, used as the event-stream key."""
+    return str(uuid_module.uuid4())
+
+
+#: Runs cancelled in this process.
+#:
+#: Held alongside the cache rather than instead of it. Superset's default cache
+#: is a null cache, which accepts a write and discards it — so a cache-only
+#: implementation would leave cancellation silently broken on a default 
install,
+#: with the button appearing to work and nothing stopping. This set makes 
inline
+#: execution correct with no cache at all; the cache is what carries a
+#: cancellation across processes for worker execution.
+_CANCELLED_LOCALLY: set[str] = set()
+
+
+def request_cancel(run_id: str) -> None:
+    """
+    Ask a run to stop.
+
+    Cooperative by design: the flag is recorded here and observed by the 
runtime
+    between steps. A run blocked inside a single long model call or query will
+    not notice until that call returns, which is a real limit worth documenting
+    rather than hiding.
+    """
+    from superset.extensions import cache_manager
+
+    _CANCELLED_LOCALLY.add(run_id)
+    try:
+        cache_manager.cache.set(
+            f"{_CANCEL_PREFIX}{run_id}", True, timeout=_CANCEL_TTL_SECONDS
+        )
+    except Exception:  # pylint: disable=broad-except
+        logger.warning("Could not record cancellation for AI run %s", run_id)
+
+
+def is_cancelled(run_id: str) -> bool:
+    """Whether a stop has been requested for this run."""
+    from superset.extensions import cache_manager
+
+    if run_id in _CANCELLED_LOCALLY:
+        return True
+    try:
+        return bool(cache_manager.cache.get(f"{_CANCEL_PREFIX}{run_id}"))
+    except Exception:  # pylint: disable=broad-except
+        # A cache that cannot be read must not make every run appear cancelled;
+        # that would stop all inference the moment the cache went away.
+        return False
+
+
+def clear_cancel(run_id: str) -> None:
+    """Drop a run's cancellation flag."""
+    from superset.extensions import cache_manager
+
+    _CANCELLED_LOCALLY.discard(run_id)
+    try:
+        cache_manager.cache.delete(f"{_CANCEL_PREFIX}{run_id}")
+    except Exception:  # pylint: disable=broad-except
+        logger.debug("Could not clear cancellation flag for AI run %s", run_id)
+
+
+def stream_turn(request: TurnRequest) -> Iterator[StreamEvent]:
+    """
+    Answer a turn, yielding events as they happen.
+
+    This is the primary entry point. Inline execution consumes it directly from
+    inside the streaming response, which means the producer and the reader are
+    the same process by construction — important because Superset runs several
+    web workers, and a turn that published to one process's in-memory queue
+    while the browser's stream landed on another would appear to hang forever.
+
+    Never raises for an operational failure: a failure is an ``error`` event 
and
+    an ``error`` message status, because the caller may already have flushed
+    response headers or may be a worker with no one to report to.
+    """
+    recorder = start_run(
+        run_id=request.run_id,
+        thread_uuid=request.thread_uuid,
+        user_id=request.user_id,
+    )
+    # Shared with ``_run`` so the ``finally`` below can see the runtime's
+    # partial result and whether the message was already written.
+    state: dict[str, Any] = {}
+    try:
+        # Bound here rather than inside ``_run`` so that a run which fails 
before
+        # it has resolved a profile still produces a start and an end, and so
+        # that the runtime can report its own spans without the runtime 
contract
+        # growing a telemetry parameter.
+        with bind_run(recorder):
+            recorder.run_started()
+            yield from _run(request, state)
+    except Exception as ex:  # pylint: disable=broad-except
+        logger.exception("AI turn failed for run %s", request.run_id)
+        recorder.error(ex)
+        recorder.run_ended(outcome=RunOutcome.ERROR)
+        answer, extra = _partial_from_state(state)
+        extra["outcome"] = RunOutcome.ERROR.value
+        _finalise_message(
+            request.assistant_message_uuid,
+            # The generic text rather than the exception: this is persisted and
+            # served back to the browser, so it must not carry internals. The
+            # detail is in the log line above, keyed by run id.
+            content=answer or GENERIC_ERROR_MESSAGE,
+            status=MessageStatus.ERROR,
+            extra=extra,
+        )
+        state["finalised"] = True
+        yield error_event()
+        yield done_event(ok=False)
+    finally:
+        clear_cancel(request.run_id)
+        # A client that stops the run, or simply navigates away, abandons this
+        # generator part-way through. Nothing above will have written the
+        # message, so it would otherwise sit in ``streaming`` with no content
+        # for ever — the user loses both the partial answer and any record that
+        # the turn happened. Persist whatever was produced.
+        _abandon_message(request.assistant_message_uuid, state)
+        # Idempotent, so the ordinary paths above win.
+        recorder.run_ended(outcome=RunOutcome.CANCELLED)
+
+
+def execute_turn(request: TurnRequest) -> RunOutcome:
+    """
+    Answer a turn, publishing events to the event bus.
+
+    Used by worker execution, where the reader is in another process. Shares 
its
+    whole body with :func:`stream_turn` so the two execution modes cannot drift
+    apart in behaviour.
+    """
+    from superset.ai.eventbus import get_event_bus
+
+    bus = get_event_bus()
+    outcome = RunOutcome.SUCCESS
+
+    for event in stream_turn(request):
+        bus.publish(request.run_id, event)
+        if event.type is StreamEventType.ERROR:
+            outcome = RunOutcome.ERROR
+        elif event.type is StreamEventType.CANCELLED:
+            outcome = RunOutcome.CANCELLED
+        elif event.type is StreamEventType.DONE and not 
event.payload.get("ok"):
+            # A run that ended un-ok without an explicit error frame timed out.
+            if outcome is RunOutcome.SUCCESS:
+                outcome = RunOutcome.TIMEOUT
+
+    return outcome
+
+
+def _run(request: TurnRequest, state: dict[str, Any]) -> Iterator[StreamEvent]:
+    """Assemble and drive the run. See :func:`stream_turn` for error policy."""
+    from superset.ai.factories import (
+        get_profiles,
+        get_provider,
+        get_runtime,
+        get_tools_for_profile,
+    )
+    from superset.ai.policy import load_policy_chain
+    from superset.ai.runtime.base import RunRequest
+    from superset.daos.ai import AIChatMessageDAO, AIChatThreadDAO
+
+    recorder = current_run()
+
+    thread = AIChatThreadDAO.find_by_uuid_for_user(request.thread_uuid, 
request.user_id)
+    if thread is None:
+        # The thread vanished between accepting the message and running it.
+        recorder.run_ended(outcome=RunOutcome.ERROR)
+        yield error_event("That conversation is no longer available.")
+        yield done_event(ok=False)
+        return
+
+    profile = get_profiles().get(request.profile_key)
+    tools = get_tools_for_profile(profile)
+    provider = get_provider()
+    runtime = get_runtime(provider)
+    state["runtime"] = runtime
+
+    yield session_event(request.thread_uuid, request.assistant_message_uuid)
+
+    from superset.ai.page_context import render_page_context
+
+    history = _build_history(AIChatMessageDAO.find_for_thread(thread))
+    # Recorded as well as prompted with, so the transcript can show what the
+    # assistant was told about the user's screen. An answer that looks wrong is
+    # usually an answer to a different question than the reader assumed, and 
the
+    # page context is where that difference lives.
+    rendered_context = render_page_context(request.page_context)
+    state["page_context"] = rendered_context
+    system_prompt = _build_system_prompt(tools, rendered_context)
+    model = _resolved_model(provider, request.model, profile)
+
+    recorder.describe(
+        agent_key=profile.key,
+        model=model,
+        question=_latest_question(history),
+    )
+
+    run_request = RunRequest(
+        messages=history,
+        system_prompt=system_prompt,
+        tools=tools,
+        policies=load_policy_chain(),
+        model_alias=profile.model_alias,
+        max_turns=profile.max_turns or _config("AI_AGENT_MAX_TURNS", 20),
+        timeout_seconds=profile.timeout_seconds
+        or _config("AI_AGENT_TIMEOUT_SECONDS", 300),
+        should_cancel=lambda: is_cancelled(request.run_id),
+    )
+
+    _mark_streaming(request.assistant_message_uuid)
+
+    # The runtime is async and this is a synchronous generator, so the async
+    # events are drained into a list per batch rather than bridged with a
+    # thread. Collecting the whole run before yielding would defeat streaming,
+    # so the loop pulls one event at a time from a dedicated event loop.
+    yield from _drain(runtime.run(run_request))
+
+    result = runtime.result
+    outcome = _outcome_of(result)
+    if result.error is not None:
+        # The only place the provider's own words are recorded. They do not go 
on
+        # the message: that is served back to the browser, and a transport 
error
+        # can name internal hosts.
+        logger.warning(
+            "AI run %s failed: %s",
+            request.run_id,
+            result.error,
+        )
+    _finalise_message(
+        request.assistant_message_uuid,
+        content=_terminal_content(result, outcome),
+        status=_status_of(outcome),
+        extra={
+            "outcome": outcome.value,
+            "agent_key": profile.key,
+            "model": model,
+            "tool_calls": result.tool_calls,
+            "turns": result.turns,
+            **_recorded_context(rendered_context),
+        },
+    )
+
+    recorder.run_ended(
+        outcome=outcome,
+        turns=result.turns,
+        answer=result.answer,
+    )
+
+    state["finalised"] = True
+
+    if outcome is RunOutcome.CANCELLED:
+        yield cancelled_event()
+    yield done_event(ok=outcome is RunOutcome.SUCCESS)
+
+
+def _drain(source: AsyncIterator[StreamEvent]) -> Iterator[StreamEvent]:
+    """
+    Pull an async iterator one item at a time from a synchronous caller.
+
+    A single event loop is kept for the whole run and stepped with
+    ``__anext__``, so each event reaches the client as it is produced rather
+    than after the run completes.
+    """
+    loop = asyncio.new_event_loop()
+    try:
+        iterator = source.__aiter__()
+        while True:
+            try:
+                yield loop.run_until_complete(iterator.__anext__())
+            except StopAsyncIteration:
+                return
+    finally:

Review Comment:
   Fixed in 366d65fab4. `_drain` awaits the source iterator's `aclose()` on its 
owning loop before closing the loop, including when the synchronous consumer 
closes early. Cleanup/error regressions verify the generator finally block runs 
and the loop still closes if iterator cleanup raises.



##########
superset-frontend/src/features/ai/hooks/useChatBot.ts:
##########
@@ -0,0 +1,1323 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * @fileoverview Conversation state and the send loop.
+ *
+ * Runs are tracked per conversation, not globally. That is the point of the
+ * structure: a user can start something slow in one conversation, switch to
+ * another and keep working, and come back to find the first still going. A 
single
+ * `isLoading` flag would have made switching away cancel or corrupt the run.
+ *
+ * The server owns the transcript. A finished run is re-read from it rather 
than
+ * assembled from the frames, so the tool calls persisted on the message are 
what
+ * the user sees, and what they see survives a reload.
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { TextAreaRef } from 'antd/es/input/TextArea';
+import { logging } from '@apache-superset/core/utils';
+import { t } from '@apache-superset/core/translation';
+import {
+  type AiAgent,
+  type AiToolCall,
+  type ChatMessageWithMeta,
+  type ChatTab,
+  type CheckpointPayload,
+} from '../types';
+import {
+  AGENT_STORAGE_KEY,
+  ChatRequestAbortedError,
+  ChatStreamEventError,
+  ChatStreamTimeoutError,
+  DEFAULT_AGENT_KEY,
+  DEFAULT_CHAT_AGENT,
+  cancelChatRun,
+  describeRequestError,
+  fetchAgents,
+  fetchSuggestedPrompts,
+  loadStoredAgentKey,
+  normalizeChatAgents,
+  startRun,
+  streamRun,
+  submitFeedback,
+} from './chatRequest';
+import {
+  NEW_CHAT_NAME,
+  createThread,
+  deleteThread as deleteThreadApi,
+  getThread,
+  listThreads,
+  threadToTab,
+  updateThread,
+} from './chatThreadsApi';
+import { buildQuickPrompts } from './quickPrompts';
+import {
+  buildPageContextPayload,
+  usePageContext,
+  type PageContext,
+} from './usePageContext';
+
+/** Cache of the conversation list, so the menu renders before the list 
arrives. */
+export const CHAT_TABS_STORAGE_KEY = 'superset-chat-tabs';
+
+/** Which conversation was last open. */
+export const ACTIVE_TAB_STORAGE_KEY = 'superset-chat-active-tab';
+
+/** Recent inputs, recalled with the arrow keys. */
+export const HISTORY_STORAGE_KEY = 'superset-chat-history';
+
+export { AGENT_STORAGE_KEY } from './chatRequest';
+
+/** How many inputs the arrow-key history keeps. */
+const MAX_INPUT_HISTORY = 50;
+
+/** A conversation title derived from a message is clipped to this. */
+const MAX_TAB_NAME_LENGTH = 30;
+
+export type ChatRunStatus = 'running' | 'cancelling';
+
+/** Shared empty list, so a render with no steps yet keeps a stable identity. 
*/
+const EMPTY_TOOL_CALLS: AiToolCall[] = [];
+
+interface ActiveChatRun {
+  requestId: string;
+  tabId: string;
+  threadId: string;
+  runId?: string;
+  controller: AbortController;
+  isStreaming: boolean;
+  liveThoughts: string;
+  liveToolLog: string;
+  /**
+   * Steps taken so far, as structured records rather than log lines.
+   *
+   * Carried alongside `liveToolLog` so a run in flight can be rendered the 
same
+   * way a finished one is — expandable per step, with the SQL and the rows it
+   * returned — instead of as a wall of text that only becomes legible once the
+   * transcript is re-read from the server.
+   */
+  liveToolCalls: AiToolCall[];
+  /** The page context this run was given, so the live view can show it too. */
+  livePageContext?: string;
+  /**
+   * The answer so far, as the model produces it.
+   *
+   * Rendered directly: the deltas used to be folded into `liveThinking`, which
+   * nothing displayed, so an answer appeared in one piece the moment the run
+   * ended however long it had taken to generate.
+   */
+  liveAnswer: string;
+  liveThinking: string;
+  status: ChatRunStatus;
+  startedAt: number;
+  checkpoint: CheckpointPayload | null;
+}
+
+/**
+ * An identifier for a turn.
+ *
+ * Drawn from `crypto`, not `Math.random`. These become the idempotency key on 
a
+ * turn and the handle used to cancel one, so a value another session could 
guess
+ * is a correctness and a security problem rather than merely a collision risk.
+ */
+const generateId = (): string => {
+  if (typeof crypto.randomUUID === 'function') {
+    return crypto.randomUUID();
+  }
+  // Older engines expose the entropy source without the convenience wrapper.
+  const bytes = new Uint8Array(16);
+  crypto.getRandomValues(bytes);
+  return Array.from(bytes, byte => byte.toString(16).padStart(2, 
'0')).join('');
+};
+
+const createNewTab = (name: string = NEW_CHAT_NAME): ChatTab => ({
+  id: generateId(),
+  name,
+  messages: [],
+  createdAt: Date.now(),
+});
+
+const truncateTabName = (
+  name: string,
+  maxLength: number = MAX_TAB_NAME_LENGTH,
+): string =>
+  name.length <= maxLength ? name : `${name.substring(0, maxLength)}...`;
+
+const readJson = <T>(key: string, fallback: T): T => {
+  try {
+    const stored = localStorage.getItem(key);
+    return stored ? (JSON.parse(stored) as T) : fallback;
+  } catch (caught) {
+    logging.warn(`[ai] could not read ${key}`, caught);
+    return fallback;
+  }
+};
+
+const writeJson = (key: string, value: unknown): void => {
+  try {
+    localStorage.setItem(key, JSON.stringify(value));
+  } catch (caught) {
+    logging.warn(`[ai] could not write ${key}`, caught);
+  }
+};
+
+/**
+ * Reconciles the server's transcript with what is already on screen.
+ *
+ * The server's copy is authoritative — it carries the tool calls — but it is 
not
+ * necessarily complete the moment a run ends, and replacing outright would 
then
+ * erase an answer the user has just read. So anything local that the server 
has
+ * not accounted for is kept, matched by identity first and by role and content
+ * second, which is how a locally-appended turn is recognised once the server
+ * returns its own copy of it under a real uuid.
+ */
+export const mergeMessages = (
+  fromServer: ChatMessageWithMeta[],
+  local: ChatMessageWithMeta[],
+): ChatMessageWithMeta[] => {
+  const serverIds = new Set(fromServer.map(message => message.id));
+  const serverTurns = new Set(
+    fromServer.map(message => `${message.role}:${message.content}`),
+  );
+  const unaccounted = local.filter(
+    message =>
+      !serverIds.has(message.id) &&
+      !serverTurns.has(`${message.role}:${message.content}`),
+  );
+  return [...fromServer, ...unaccounted];
+};
+
+/**
+ * The `page_context` body for one turn.
+ *
+ * Returns undefined when there is nothing to send, so an omitted field is
+ * distinguishable from an empty one.
+ */
+export const buildRequestPageContext = (
+  context: PageContext | undefined,
+  directive?: string,
+): Record<string, unknown> | undefined => {
+  const payload = context ? buildPageContextPayload(context) : undefined;
+  if (!directive) {
+    return payload;
+  }
+  const existing = payload?.helper_directives;
+  return {
+    ...payload,
+    helper_directives: [
+      directive,
+      ...(Array.isArray(existing) ? existing : []),
+    ],
+  };
+};
+
+export interface UseChatBotReturn {
+  // Conversations
+  chatTabs: ChatTab[];
+  activeTabId: string;
+  activeTab: ChatTab | undefined;
+  threadsLoaded: boolean;
+  handleNewChat: () => Promise<string>;
+  handleSelectTab: (tabId: string) => Promise<void>;
+  handleDeleteTab: (tabId: string) => Promise<void>;
+  handleRenameTab: (tabId: string, newName: string) => void;
+  // Messages of the active conversation
+  messages: ChatMessageWithMeta[];
+  // Input
+  inputValue: string;
+  setInputValue: (value: string) => void;
+  handleKeyDown: (event: React.KeyboardEvent) => void;
+  inputRef: React.RefObject<TextAreaRef>;
+  messagesEndRef: React.RefObject<HTMLDivElement>;
+  // The run in flight, if any, for the active conversation
+  isLoading: boolean;
+  isStreamingResponse: boolean;
+  liveThoughts: string;
+  liveToolLog: string;
+  /** Steps taken so far in the run in flight, for the structured live view. */
+  liveToolCalls: AiToolCall[];
+  /** The page context the run in flight was given. */
+  livePageContext?: string;
+  /** The answer so far for the run in flight. */
+  liveAnswer: string;
+  checkpoint: CheckpointPayload | null;
+  activeRunStatus: ChatRunStatus | null;
+  error?: string;
+  // Actions
+  sendMessage: (
+    messageOverride?: string,
+    systemPromptOverride?: string,
+  ) => Promise<void>;
+  handleCancelRun: () => Promise<void>;
+  handleCheckpointContinue: () => void;
+  handleFeedback: (messageId: string, feedback: 'like' | 'dislike') => void;
+  messageFeedback: Record<string, 'like' | 'dislike'>;
+  // Suggestions
+  /** The message whose run just ended; its thought process stays open. */
+  justCompletedId?: string;
+  quickPrompts: string[];
+  loadQuickPrompts: () => void;
+  applyQuickPrompt: (prompt: string) => Promise<void>;
+  // Agent profiles
+  agents: AiAgent[];
+  selectedAgent: string;
+  setSelectedAgent: (key: string) => void;
+  // Page context
+  pageContext: PageContext;
+  includePageContext: boolean;
+  toggleIncludePageContext: () => void;
+}
+
+export const useChatBot = (): UseChatBotReturn => {
+  const [chatTabs, setChatTabs] = useState<ChatTab[]>(() =>
+    readJson<ChatTab[]>(CHAT_TABS_STORAGE_KEY, []).map(tab => ({
+      // The cache is a placeholder for the menu; message bodies are re-read 
from
+      // the server so a stale cache cannot show a conversation that has moved 
on.
+      ...tab,
+      messages: [],
+    })),
+  );
+  const [activeTabId, setActiveTabId] = useState<string>(() => {
+    try {
+      return localStorage.getItem(ACTIVE_TAB_STORAGE_KEY) ?? '';
+    } catch {
+      return '';
+    }
+  });
+  const [threadsLoaded, setThreadsLoaded] = useState(false);
+  const [error, setError] = useState<string | undefined>(undefined);
+
+  const [inputValue, setInputValue] = useState('');
+  const [activeRunsByTab, setActiveRunsByTab] = useState<
+    Record<string, ActiveChatRun>
+  >({});
+  const [quickPrompts, setQuickPrompts] = useState<string[]>([]);
+  const [messageFeedback, setMessageFeedback] = useState<
+    Record<string, 'like' | 'dislike'>
+  >({});
+  const [includePageContext, setIncludePageContext] = useState(true);
+  /**
+   * The assistant message whose run has only just ended.
+   *
+   * Its thought process stays open, because collapsing it the instant the 
answer
+   * lands moves everything below it — the answer the user is mid-sentence 
through
+   * jumps up the panel. Older messages start closed.
+   */
+  const [justCompletedId, setJustCompletedId] = useState<string | undefined>();
+  const [agents, setAgents] = useState<AiAgent[]>([DEFAULT_CHAT_AGENT]);
+  const [selectedAgent, setSelectedAgent] = useState<string>(() =>
+    loadStoredAgentKey(AGENT_STORAGE_KEY),
+  );
+
+  const [messageHistory, setMessageHistory] = useState<string[]>(() =>
+    readJson<string[]>(HISTORY_STORAGE_KEY, []),
+  );
+  const [historyIndex, setHistoryIndex] = useState(-1);
+  const [currentDraft, setCurrentDraft] = useState('');
+
+  const messagesEndRef = useRef<HTMLDivElement>(null);
+  const inputRef = useRef<TextAreaRef>(null);
+
+  /**
+   * The run map and the conversation list are also held in refs, and the refs 
are
+   * the authority.
+   *
+   * The send loop has to ask "is this still my run?" between awaits, and it 
cannot
+   * ask React: a run that starts and fails inside one batch never causes a 
render,
+   * so a ref synced at render time would still be empty and the loop would 
discard
+   * its own result as stale. Writing the ref at the point of mutation removes 
that
+   * window. The callbacks read the refs rather than the state so their 
identities
+   * do not churn on every streamed frame, which would restart effects mid-run.
+   */
+  const activeRunsByTabRef = useRef<Record<string, ActiveChatRun>>({});
+  const chatTabsRef = useRef<ChatTab[]>(chatTabs);
+  const activeTabIdRef = useRef(activeTabId);
+  activeTabIdRef.current = activeTabId;
+
+  const updateRuns = useCallback(
+    (
+      updater: (
+        previous: Record<string, ActiveChatRun>,
+      ) => Record<string, ActiveChatRun>,
+    ) => {
+      activeRunsByTabRef.current = updater(activeRunsByTabRef.current);
+      setActiveRunsByTab(activeRunsByTabRef.current);
+    },
+    [],
+  );
+
+  const updateTabs = useCallback(
+    (updater: (previous: ChatTab[]) => ChatTab[]) => {
+      chatTabsRef.current = updater(chatTabsRef.current);
+      setChatTabs(chatTabsRef.current);
+    },
+    [],
+  );
+
+  /** Resolved when the user answers a checkpoint; see `streamRun`. */
+  const checkpointGateRef = useRef<{ resolve: () => void } | null>(null);
+  const mountedRef = useRef(true);
+  useEffect(
+    () => () => {
+      mountedRef.current = false;
+    },
+    [],
+  );
+
+  const activeTab = chatTabs.find(tab => tab.id === activeTabId);
+  const messages = activeTab?.messages ?? [];
+
+  const activeRun = activeRunsByTab[activeTabId];
+  const isLoading = Boolean(activeRun);
+  const isStreamingResponse = activeRun?.isStreaming ?? false;
+  const liveThoughts = activeRun?.liveThoughts ?? '';
+  const liveToolLog = activeRun?.liveToolLog ?? '';
+  const liveToolCalls = activeRun?.liveToolCalls ?? EMPTY_TOOL_CALLS;
+  const livePageContext = activeRun?.livePageContext;
+  const liveAnswer = activeRun?.liveAnswer ?? '';
+  const checkpoint = activeRun?.checkpoint ?? null;
+  const activeRunStatus = activeRun?.status ?? null;
+
+  const pageContext = usePageContext();
+  const pageContextRef = useRef(pageContext);
+  pageContextRef.current = pageContext;
+
+  const fail = useCallback(async (caught: unknown, fallback: string) => {
+    const message = await describeRequestError(caught, fallback);
+    logging.error('[ai] assistant request failed', caught);
+    if (mountedRef.current) {
+      setError(message);
+    }
+  }, []);
+
+  // -----------------------------------------------------------------------
+  // Persistence of the small things
+  // -----------------------------------------------------------------------
+
+  useEffect(() => {
+    // Only the shell of each conversation is cached; see the initialiser.
+    writeJson(
+      CHAT_TABS_STORAGE_KEY,
+      chatTabs.map(tab => ({ ...tab, messages: [] })),
+    );
+  }, [chatTabs]);
+
+  useEffect(() => {
+    try {
+      localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, activeTabId);
+    } catch (caught) {
+      logging.warn('[ai] could not remember the active conversation', caught);
+    }
+  }, [activeTabId]);
+
+  useEffect(() => {
+    if (messageHistory.length > 0) {
+      writeJson(HISTORY_STORAGE_KEY, messageHistory);
+    }
+  }, [messageHistory]);
+
+  useEffect(() => {
+    try {
+      localStorage.setItem(AGENT_STORAGE_KEY, selectedAgent);
+    } catch (caught) {
+      logging.warn('[ai] could not remember the selected agent', caught);
+    }
+  }, [selectedAgent]);
+
+  // Follows the transcript as it grows, including while a run streams. Guarded
+  // because `scrollIntoView` is absent in environments without a layout 
engine,
+  // and failing to scroll must not take the panel down.
+  useEffect(() => {
+    messagesEndRef.current?.scrollIntoView?.({ behavior: 'smooth' });
+  }, [messages, liveToolLog, liveThoughts]);
+
+  // -----------------------------------------------------------------------
+  // Conversation management
+  // -----------------------------------------------------------------------
+
+  const setMessagesForTab = useCallback(
+    (
+      tabId: string,
+      updater: (previous: ChatMessageWithMeta[]) => ChatMessageWithMeta[],
+    ) => {
+      updateTabs(previous =>
+        previous.map(tab =>
+          tab.id === tabId ? { ...tab, messages: updater(tab.messages) } : tab,
+        ),
+      );
+    },
+    [updateTabs],
+  );
+
+  const refreshThreadMessages = useCallback(
+    async (threadId: string) => {
+      const { thread, messages: threadMessages } = await getThread(threadId);
+      if (!mountedRef.current) {
+        return;
+      }
+      const refreshed = threadToTab(thread, threadMessages);
+      // A locally set title wins: the user may have renamed the conversation
+      // while the request was in flight.
+      updateTabs(previous =>
+        previous.map(tab =>
+          tab.threadId === threadId
+            ? {
+                ...refreshed,
+                name: tab.name || refreshed.name,
+                messages: mergeMessages(refreshed.messages, tab.messages),
+              }
+            : tab,
+        ),
+      );
+    },
+    [updateTabs],
+  );
+
+  const handleNewChat = useCallback(async (): Promise<string> => {
+    try {
+      const thread = await createThread(
+        undefined,
+        selectedAgent === DEFAULT_AGENT_KEY ? undefined : selectedAgent,
+      );
+      const tab = threadToTab(thread);
+      updateTabs(previous => [tab, ...previous]);
+      setActiveTabId(tab.id);
+      activeTabIdRef.current = tab.id;
+      setError(undefined);
+      return tab.id;
+    } catch (caught) {
+      await fail(caught, t('The conversation could not be created.'));
+      // A local tab still lets the user type; the thread is created on send.
+      const tab = createNewTab();
+      updateTabs(previous => [tab, ...previous]);
+      setActiveTabId(tab.id);
+      activeTabIdRef.current = tab.id;
+      return tab.id;
+    }
+  }, [fail, selectedAgent, updateTabs]);
+
+  const handleSelectTab = useCallback(
+    async (tabId: string) => {
+      setActiveTabId(tabId);
+      const tab = chatTabsRef.current.find(candidate => candidate.id === 
tabId);
+      // Messages are fetched on first view, not up front: a user with fifty
+      // conversations should not pay for forty-nine of them.
+      if (tab?.threadId && tab.messages.length === 0) {
+        try {
+          await refreshThreadMessages(tab.threadId);
+        } catch (caught) {
+          await fail(caught, t('The conversation could not be loaded.'));
+        }
+      }
+    },
+    [fail, refreshThreadMessages],
+  );
+
+  const handleDeleteTab = useCallback(
+    async (tabId: string) => {
+      const tab = chatTabsRef.current.find(candidate => candidate.id === 
tabId);
+      if (tab?.threadId) {
+        try {
+          await deleteThreadApi(tab.threadId);

Review Comment:
   Fixed the unsafe deletion in 8f715164fe by rejecting DELETE with 422 while 
an assistant message is pending/streaming. The transcript is removed only after 
the run becomes terminal, so a successful cancel request alone is not mistaken 
for completion. Append/delete use the same thread-row locking path on databases 
that support row locks. API regressions cover pending/streaming preservation 
and deletion after cancellation. Cross-process row-lock contention has not been 
exercised against PostgreSQL/MySQL in this update.



##########
superset/ai/eventbus.py:
##########
@@ -0,0 +1,314 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Carries streamed events from whatever produced them to the HTTP response.
+
+Two implementations, matching the two execution modes. Inline execution needs
+nothing more than an in-process queue. Worker execution needs a shared,
+*replayable* channel — replayable because a browser that loses its connection
+must be able to rejoin a run already in progress, which rules out
+publish/subscribe: a subscriber that was absent when an event was published
+never sees it.
+
+The Redis implementation therefore uses streams, and reuses the cache backend
+that Superset's async-query channel already configures rather than introducing
+a second Redis client to operate.
+"""
+
+from __future__ import annotations
+
+import logging
+import queue
+from abc import ABC, abstractmethod
+from collections.abc import Iterator
+from typing import Any
+
+from superset.ai.events import StreamEvent
+from superset.ai.types import StreamEventType
+from superset.utils import json
+
+logger = logging.getLogger(__name__)
+
+#: Yielded by :meth:`BaseEventBus.consume` when nothing arrived within the poll
+#: interval, so a caller can emit a keep-alive rather than block indefinitely.
+IDLE = None
+
+#: Terminal event types. Seeing one ends consumption, so a reader does not hang
+#: waiting for a producer that has already finished.
+_TERMINAL = frozenset(
+    {StreamEventType.DONE, StreamEventType.ERROR, StreamEventType.CANCELLED}
+)
+
+
+class BaseEventBus(ABC):
+    """A per-run channel of events."""
+
+    @abstractmethod
+    def publish(self, run_id: str, event: StreamEvent) -> None:
+        """Append an event to a run's channel."""
+
+    @abstractmethod
+    def consume(
+        self,
+        run_id: str,
+        timeout_seconds: float,
+        poll_seconds: float = 1.0,
+    ) -> Iterator[StreamEvent | None]:
+        """
+        Yield a run's events until a terminal one arrives or time runs out.
+
+        Yields :data:`IDLE` when a poll interval passes with nothing new, which
+        is the caller's cue to send a keep-alive frame.
+        """
+
+    @abstractmethod
+    def close(self, run_id: str) -> None:
+        """Release any resources held for a run."""
+
+
+class MemoryEventBus(BaseEventBus):
+    """
+    An in-process queue per run.
+
+    Correct only when the producer and the streaming request share a process.
+    Selecting this alongside worker execution would leave every stream silent,
+    which :func:`get_event_bus` refuses to allow.
+    """
+
+    def __init__(self) -> None:
+        self._queues: dict[str, queue.SimpleQueue[StreamEvent]] = {}
+
+    def _queue_for(self, run_id: str) -> queue.SimpleQueue[StreamEvent]:
+        return self._queues.setdefault(run_id, queue.SimpleQueue())
+
+    def publish(self, run_id: str, event: StreamEvent) -> None:
+        self._queue_for(run_id).put(event)
+
+    def consume(
+        self,
+        run_id: str,
+        timeout_seconds: float,
+        poll_seconds: float = 1.0,
+    ) -> Iterator[StreamEvent | None]:
+        import time
+
+        # Deliberately not ``_queue_for``: reading must not create a channel.
+        # This bus lives for the life of the process, so a client polling
+        # unknown run identifiers would otherwise grow the dict without bound.
+        channel = self._queues.get(run_id)
+        deadline = time.monotonic() + timeout_seconds
+
+        while True:
+            remaining = deadline - time.monotonic()
+            if remaining <= 0:
+                return
+            if channel is None:
+                # The producer may not have published yet; look again rather
+                # than deciding the run does not exist. Only report idle if it
+                # is still absent, so a channel that appeared during the wait
+                # is drained on this pass instead of costing an extra tick.
+                channel = self._queues.get(run_id)
+                if channel is None:
+                    yield IDLE
+                    time.sleep(min(poll_seconds, remaining))
+                continue
+            try:
+                # Bounded by whichever is sooner, so a generous poll interval
+                # cannot overshoot the caller's deadline.
+                event = channel.get(timeout=min(poll_seconds, remaining))
+            except queue.Empty:
+                yield IDLE
+                continue
+            yield event
+            if event.type in _TERMINAL:
+                return
+
+    def close(self, run_id: str) -> None:
+        self._queues.pop(run_id, None)
+
+
+class RedisStreamEventBus(BaseEventBus):
+    """
+    A Redis stream per run.
+
+    Replayable by construction: a reconnecting reader starts from the beginning
+    of the stream and catches up, which is what makes worker execution usable
+    from a browser on a flaky connection.
+    """
+
+    def __init__(
+        self,
+        cache: Any,
+        prefix: str = "ai-events-",
+        ttl_seconds: int = 900,
+    ) -> None:
+        self._cache = cache
+        self._prefix = prefix
+        self._ttl = ttl_seconds
+
+    def _stream(self, run_id: str) -> str:
+        return f"{self._prefix}{run_id}"
+
+    def publish(self, run_id: str, event: StreamEvent) -> None:
+        payload = {
+            "data": json.dumps({"type": event.type.value, "payload": 
event.payload})
+        }
+        # A failure to publish must not kill the run that is producing useful
+        # work; the reader will time out and the answer is still persisted.
+        try:
+            self._cache.xadd(self._stream(run_id), payload, "*", 10_000)
+        except Exception:  # pylint: disable=broad-except
+            logger.warning("Could not publish AI event for run %s", run_id)
+
+    def consume(
+        self,
+        run_id: str,
+        timeout_seconds: float,
+        poll_seconds: float = 1.0,
+    ) -> Iterator[StreamEvent | None]:
+        import time
+
+        stream = self._stream(run_id)
+        deadline = time.monotonic() + timeout_seconds
+        last_id = "-"
+
+        while time.monotonic() < deadline:
+            try:
+                entries = self._cache.xrange(stream, last_id, "+", 100)
+            except Exception:  # pylint: disable=broad-except
+                logger.warning("Could not read AI events for run %s", run_id)
+                yield IDLE
+                time.sleep(poll_seconds)
+                continue
+
+            fresh = [entry for entry in entries if _entry_id(entry) != last_id]
+            if not fresh:
+                yield IDLE
+                time.sleep(poll_seconds)
+                continue
+
+            for entry in fresh:
+                last_id = _entry_id(entry)
+                event = _decode(entry)
+                if event is None:
+                    continue
+                yield event
+                if event.type in _TERMINAL:

Review Comment:
   Fixed in 83a8a4b617. Memory and Redis consumers continue through 
`error`/`cancelled` and terminate on `done`, preserving the producer's terminal 
sequence. Failure/cancellation stream regressions, including the worker-failure 
tests that exposed this mismatch, now pass.



##########
superset-frontend/src/features/ai/components/ChatChartEmbed.tsx:
##########
@@ -0,0 +1,542 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * @fileoverview A chart rendered inside a chat message.
+ *
+ * The assistant does not send a chart, it sends a `form_data_key` it stored — 
so
+ * what arrives in the transcript is a reference the client resolves, and the
+ * rendered chart is the real thing, with the real permissions, rather than an
+ * image of one.
+ *
+ * The awkward part is timing: the key can exist before the query behind it has
+ * finished. Rather than show the chart's own "No data" state (which reads as a
+ * broken answer) the component keeps a spinner up and re-renders on a growing
+ * backoff until rows appear, giving up after a bounded number of attempts.
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import {
+  type QueryFormData,
+  StatefulChart,
+  SupersetClient,
+} from '@superset-ui/core';
+import { styled } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { Loading } from '@superset-ui/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { ErrorBoundary } from 'src/components/ErrorBoundary';
+
+const VALID_KEY_PATTERN = /^[a-zA-Z0-9_-]+$/;
+const MIN_HEIGHT = 100;
+const MAX_HEIGHT = 800;
+const DEFAULT_HEIGHT = 300;
+const FETCH_TIMEOUT_MS = 30_000;
+const MAX_RETRIES = 3;
+const RETRY_DELAYS_MS = [500, 1500, 3000];
+
+/** Width used until the container has been measured. */
+const FALLBACK_CHART_WIDTH = 600;
+
+// Backoff for the "waiting for chart data" poll. The delay grows exponentially
+// per attempt up to a ceiling, so a slow query is waited out without hammering
+// the backend.
+const POLL_BASE_DELAY_MS = 1000;
+const POLL_MAX_DELAY_MS = 30_000;
+
+/**
+ * Polling stops after this many attempts.
+ *
+ * A retry re-issues the chart's data request, which will use the results 
cache if
+ * the query has landed but will otherwise execute it. Polling forever would 
keep
+ * re-issuing it, so an unfinished query surfaces the retry control instead.
+ */
+const MAX_POLL_ATTEMPTS = 6;
+
+const getPollDelayMs = (attempt: number): number =>
+  Math.min(POLL_BASE_DELAY_MS * 2 ** attempt, POLL_MAX_DELAY_MS);
+
+export interface ChartEmbedParams {
+  formDataKey: string | null;
+  height: number;
+  title: string | null;
+}
+
+/**
+ * Parse key=value lines from the content of a ```superset-chart fenced block.
+ *
+ * Rules are strict on purpose: the block is model output, so `form_data_key` 
must
+ * match `/^[a-zA-Z0-9_-]+$/` before it reaches a URL, and `height` is clamped.
+ * Unknown keys are ignored so a newer backend can add some without breaking an
+ * older client.
+ */
+export function parseChartEmbedParams(codeText: string): ChartEmbedParams {
+  const result: ChartEmbedParams = {
+    formDataKey: null,
+    height: DEFAULT_HEIGHT,
+    title: null,
+  };
+
+  const lines = codeText
+    .split('\n')
+    .map(line => line.trim())
+    .filter(Boolean);
+
+  lines.forEach(line => {
+    const eqIndex = line.indexOf('=');
+    if (eqIndex <= 0) {
+      return;
+    }
+
+    const key = line.slice(0, eqIndex).trim().toLowerCase();
+    const value = line.slice(eqIndex + 1).trim();
+
+    if (key === 'form_data_key') {
+      if (value && VALID_KEY_PATTERN.test(value)) {
+        result.formDataKey = value;
+      }
+      return;
+    }
+    if (key === 'height') {
+      const parsed = parseInt(value, 10);
+      if (!Number.isNaN(parsed)) {
+        result.height = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, parsed));
+      }
+      return;
+    }
+    if (key === 'title' && value) {
+      result.title = value;
+    }
+  });
+
+  return result;
+}
+
+const ChartContainer = styled.div`
+  border: 1px solid ${({ theme }) => theme.colorBorderSecondary};
+  border-radius: ${({ theme }) => theme.borderRadius}px;
+  overflow: hidden;
+  margin: ${({ theme }) => theme.sizeUnit * 2}px 0;
+  background: ${({ theme }) => theme.colorBgContainer};
+`;
+
+const ChartHeader = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: ${({ theme }) => theme.sizeUnit * 2}px
+    ${({ theme }) => theme.sizeUnit * 3}px;
+  border-bottom: 1px solid ${({ theme }) => theme.colorBorderSecondary};
+  background: ${({ theme }) => theme.colorBgLayout};
+  font-size: ${({ theme }) => theme.fontSizeSM}px;
+`;
+
+const ChartTitle = styled.span`
+  font-weight: ${({ theme }) => theme.fontWeightStrong};
+  color: ${({ theme }) => theme.colorText};
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  flex: 1;
+  min-width: 0;
+`;
+
+const ChartActions = styled.div`
+  display: flex;
+  gap: ${({ theme }) => theme.sizeUnit * 2}px;
+  align-items: center;
+  flex-shrink: 0;
+  margin-left: ${({ theme }) => theme.sizeUnit * 2}px;
+`;
+
+const ActionLink = styled.a`
+  display: inline-flex;
+  align-items: center;
+  gap: ${({ theme }) => theme.sizeUnit / 2}px;
+  font-size: ${({ theme }) => theme.fontSizeSM}px;
+  color: ${({ theme }) => theme.colorPrimary};
+  cursor: pointer;
+  text-decoration: none;
+
+  &:hover {
+    text-decoration: underline;
+  }
+`;
+
+const ActionButton = styled.button`
+  display: inline-flex;
+  align-items: center;
+  gap: ${({ theme }) => theme.sizeUnit / 2}px;
+  font-size: ${({ theme }) => theme.fontSizeSM}px;
+  color: ${({ theme }) => theme.colorTextSecondary};
+  cursor: pointer;
+  background: none;
+  border: none;
+  padding: 2px ${({ theme }) => theme.sizeUnit / 2}px;
+  border-radius: ${({ theme }) => theme.borderRadius}px;
+
+  &:hover {
+    color: ${({ theme }) => theme.colorPrimary};
+    background: ${({ theme }) => theme.colorFillTertiary};
+  }
+`;
+
+const ChartBody = styled.div<{ height: number }>`
+  height: ${({ height }) => height}px;
+  position: relative;
+`;
+
+// Covers the chart while it reports no data, hiding the underlying "No data"
+// state (which looks broken) behind a spinner while refreshing continues.
+const ChartDataOverlay = styled.div`
+  position: absolute;
+  inset: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: ${({ theme }) => theme.sizeUnit * 3}px;
+  background: ${({ theme }) => theme.colorBgContainer};
+  color: ${({ theme }) => theme.colorTextSecondary};
+  font-size: ${({ theme }) => theme.fontSizeSM}px;
+  z-index: 2;
+`;
+
+const CenteredMessage = styled.div<{ height: number }>`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  height: ${({ height }) => height}px;
+  color: ${({ theme }) => theme.colorTextSecondary};
+  font-size: ${({ theme }) => theme.fontSizeSM}px;
+  text-align: center;
+  padding: ${({ theme }) => theme.sizeUnit * 4}px;
+  gap: ${({ theme }) => theme.sizeUnit * 2}px;
+`;
+
+interface ChatChartEmbedProps {
+  formDataKey: string;
+  height?: number;
+  title?: string;
+}
+
+type FetchState =
+  | { status: 'loading' }
+  | { status: 'loaded'; formData: QueryFormData }
+  | { status: 'error'; message: string };
+
+const exploreUrlFor = (formDataKey: string): string =>
+  `/explore/?form_data_key=${encodeURIComponent(formDataKey)}`;
+
+export function ChatChartEmbedInner({
+  formDataKey,
+  height = DEFAULT_HEIGHT,
+  title,
+}: ChatChartEmbedProps) {
+  const [fetchState, setFetchState] = useState<FetchState>({
+    status: 'loading',
+  });
+  const [chartWidth, setChartWidth] = useState(0);
+  const [chartRenderKey, setChartRenderKey] = useState(0);
+  // True while the chart's query results are still unavailable (either an 
empty
+  // result or a cache miss), which is what keeps the overlay up.
+  const [isAwaitingData, setIsAwaitingData] = useState(false);
+  const chartBodyRef = useRef<HTMLDivElement>(null);
+  const pollTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
+
+  // Bumping `chartRenderKey` remounts the chart, which re-issues its data
+  // request. `force` is left off so the results cache is preferred.
+  const scheduleNextPoll = useCallback(() => {
+    if (chartRenderKey >= MAX_POLL_ATTEMPTS) {
+      setIsAwaitingData(false);
+      return;
+    }
+    setIsAwaitingData(true);
+    const delay = getPollDelayMs(chartRenderKey);
+    if (pollTimeoutRef.current) {
+      clearTimeout(pollTimeoutRef.current);
+    }
+    pollTimeoutRef.current = setTimeout(
+      () => setChartRenderKey(key => key + 1),
+      delay,
+    );
+  }, [chartRenderKey]);
+
+  useEffect(
+    () => () => {
+      if (pollTimeoutRef.current) {
+        clearTimeout(pollTimeoutRef.current);
+      }
+    },
+    [],
+  );
+
+  useEffect(() => {
+    const element = chartBodyRef.current;
+    if (!element) {
+      return undefined;
+    }
+
+    const initialWidth = Math.floor(element.getBoundingClientRect().width);
+    if (initialWidth > 0) {
+      setChartWidth(initialWidth);
+    }
+
+    // The panel is resizable by the host, so the chart is measured rather than
+    // given a fixed width.
+    const observer = new ResizeObserver(entries => {
+      const [entry] = entries;
+      if (entry) {
+        const width = Math.floor(entry.contentRect.width);
+        if (width > 0) {
+          setChartWidth(width);
+        }
+      }
+    });
+    observer.observe(element);
+    return () => observer.disconnect();
+  }, [fetchState.status]);
+
+  const exploreUrl = exploreUrlFor(formDataKey);
+
+  const fetchFormData = useCallback(
+    async (attempt: number = 0) => {
+      setFetchState({ status: 'loading' });
+      setIsAwaitingData(false);
+      setChartRenderKey(0);
+
+      const retry = (): boolean => {
+        if (attempt >= MAX_RETRIES) {
+          return false;
+        }
+        const delay = RETRY_DELAYS_MS[attempt] ?? 1000;
+        setTimeout(() => {
+          fetchFormData(attempt + 1);
+        }, delay);
+        return true;
+      };
+
+      try {
+        const controller = new AbortController();
+        const timeoutId = setTimeout(
+          () => controller.abort(),
+          FETCH_TIMEOUT_MS,
+        );
+
+        const response = await SupersetClient.get({
+          endpoint: 
`/api/v1/explore/form_data/${encodeURIComponent(formDataKey)}`,
+          signal: controller.signal,
+        });
+
+        clearTimeout(timeoutId);
+
+        const raw = response.json;
+        const formDataStr =
+          typeof raw === 'string'
+            ? raw
+            : ((raw as { form_data?: string })?.form_data ?? undefined);
+
+        if (!formDataStr) {
+          // The key can be written before the payload is readable, so an empty
+          // body is treated as "not yet" rather than "gone".
+          if (retry()) {
+            return;
+          }
+          setFetchState({
+            status: 'error',
+            message: t('Chart configuration not found or expired.'),
+          });
+          return;
+        }
+
+        const parsed: QueryFormData =
+          typeof formDataStr === 'string'
+            ? JSON.parse(formDataStr)
+            : formDataStr;
+
+        if (!parsed.viz_type) {
+          setFetchState({
+            status: 'error',
+            message: t(
+              'Invalid chart configuration: missing visualization type.',
+            ),
+          });
+          return;
+        }
+
+        // Defaults the chart pipeline requires but a stored form_data may 
omit.
+        if (!parsed.time_range) {
+          parsed.time_range = 'No filter';
+        }
+        if (!parsed.result_format) {
+          parsed.result_format = 'json';
+        }
+        if (!parsed.result_type) {
+          parsed.result_type = 'full';
+        }
+
+        setFetchState({ status: 'loaded', formData: parsed });
+      } catch (caught) {
+        if (retry()) {
+          return;
+        }
+        const errorMessage =
+          caught instanceof DOMException && caught.name === 'AbortError'
+            ? t('Chart loading timed out.')
+            : t('Unable to load chart preview.');
+        setFetchState({ status: 'error', message: errorMessage });
+      }
+    },
+    [formDataKey],
+  );
+
+  useEffect(() => {
+    fetchFormData();
+  }, [fetchFormData]);
+
+  return (
+    <ChartContainer data-test="chat-chart-embed">
+      <ChartHeader>
+        <ChartTitle>{title ?? t('Chart Preview')}</ChartTitle>
+        <ChartActions>
+          {fetchState.status === 'error' && (
+            <ActionButton
+              type="button"
+              onClick={() => {
+                fetchFormData(0);
+              }}
+            >
+              <Icons.ReloadOutlined iconSize="s" /> {t('Retry')}
+            </ActionButton>
+          )}
+          <ActionLink
+            href={exploreUrl}
+            target="_blank"
+            rel="noopener noreferrer"
+          >
+            <Icons.ExpandOutlined iconSize="s" /> {t('Open in Explore')}
+          </ActionLink>
+        </ChartActions>
+      </ChartHeader>
+
+      {fetchState.status === 'loading' && (
+        <CenteredMessage height={height}>
+          <Loading position="inline-centered" size="s" />
+          {t('Loading chart preview...')}
+        </CenteredMessage>
+      )}
+
+      {fetchState.status === 'error' && (
+        <CenteredMessage height={Math.min(height, 120)}>
+          <span>{fetchState.message}</span>
+          <ActionLink
+            href={exploreUrl}
+            target="_blank"
+            rel="noopener noreferrer"
+          >
+            <Icons.ExpandOutlined iconSize="s" /> {t('View in Explore 
instead')}
+          </ActionLink>
+        </CenteredMessage>
+      )}
+
+      {fetchState.status === 'loaded' && (
+        <ChartBody height={height} ref={chartBodyRef}>
+          <StatefulChart
+            key={chartRenderKey}
+            formData={fetchState.formData}
+            width={chartWidth || FALLBACK_CHART_WIDTH}
+            height={height}
+            onLoad={queryData => {
+              const isEmpty = queryData.every(

Review Comment:
   Fixed in fdfa31681d. The embed no longer polls/remounts a chart based on 
whether its successful data array is empty. It uses the shared StatefulChart 
loading/async-query behavior; a zero-row success completes normally. The 
fake-timer regression verifies no remount/retry after an empty successful 
result.



##########
superset-frontend/src/features/ai/AiAssistantPanel.tsx:
##########
@@ -0,0 +1,1150 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * @fileoverview The assistant panel.
+ *
+ * The host owns where this sits and, when docked, how wide it is, so there is 
no
+ * positioning and no resize handle here. What is here is the conversation: the
+ * header, the transcript, what the assistant is doing while it works, and the
+ * composer.
+ *
+ * The centre of the design is that a run is legible while it happens. An 
answer
+ * can take a minute of tool calls, and a spinner for a minute is 
indistinguishable
+ * from a hang, so reasoning streams into a preview, each step appends to a 
tool
+ * log, and a checkpoint stops the run with a countdown the user can act on.
+ */
+
+import {
+  memo,
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
+  useSyncExternalStore,
+} from 'react';
+import type { Dispatch, SetStateAction } from 'react';
+import ReactMarkdown from 'react-markdown';
+import type { Components } from 'react-markdown';
+import { css, keyframes, styled, useTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import type { chat as chatApi } from '@apache-superset/core';
+import {
+  Button,
+  Input,
+  Loading,
+  Tooltip,
+  Typography,
+} from '@superset-ui/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { chat } from 'src/core/chat';
+import ChatAgentSelect from './components/ChatAgentSelect';
+import ChatTabsMenu from './components/ChatTabsMenu';
+import { REMARK_PLUGINS, useChatMarkdown } from './components/chatMarkdown';
+import { ThoughtProcess } from './components/ThoughtProcess';
+import { useChatBot } from './hooks/useChatBot';
+import { AI_ACTION_EVENT, type AiActionEvent } from './hooks/useAIAction';
+import type { PageContext } from './hooks/usePageContext';
+import type { ChatMessageWithMeta, CheckpointPayload } from './types';
+
+/**
+ * How long a checkpoint waits before continuing on its own. A pause that 
blocks
+ * forever is worse than one that resolves optimistically: the user may have
+ * walked away, and the run should not be stranded.
+ */
+export const CHECKPOINT_TIMEOUT_SECONDS = 30;
+
+/**
+ * Closes a code fence the model has not finished writing.
+ *
+ * A streamed answer is parsed on every delta, so a fence arrives in pieces —
+ * "```", then "sql", then the query. Markdown with an odd number of fences
+ * renders the opening backticks literally and then reflows once the closing 
pair
+ * lands, which reads as the answer glitching. Balancing the count keeps each
+ * intermediate state a valid document.
+ */
+export const balanceCodeFences = (text: string): string => {
+  const fences = text.match(/^```/gm)?.length ?? 0;
+  return fences % 2 === 0 ? text : `${text}\n\`\`\``;
+};
+
+/**
+ * Whether a message carries the structured record of how it was answered, as
+ * opposed to only the flat log assembled from stream frames.
+ */
+const hasStructuredThinking = (message: ChatMessageWithMeta): boolean =>
+  Boolean(message.toolCalls?.length || message.thoughts || 
message.pageContext);
+
+/** Milliseconds between typewriter frames, and characters per frame. */
+const TYPEWRITER_INTERVAL_MS = 18;
+const TYPEWRITER_STEP = 3;
+
+/**
+ * The panel's own size as a floating overlay.
+ *
+ * Docked width belongs to the host and is not set here. Floating does need a 
size
+ * from somewhere, though — the floating host only stacks its children in a 
corner
+ * and gives them no dimensions — so these clamp the overlay to the viewport.
+ */
+const FLOATING_WIDTH_PX = 440;
+const FLOATING_MAX_HEIGHT_VH = 70;
+
+/**
+ * The panel surface.
+ *
+ * Positioning is deliberately absent: the host places this, in both modes. 
What is
+ * here is the surface itself — a column that fills whatever box it is given, 
with a
+ * floating size for the mode where the host provides no box.
+ */
+const ChatPanelContainer = styled.div<{ floating: boolean }>`
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+  overflow: hidden;
+  background: ${({ theme }) => theme.colorBgElevated};
+  ${({ floating, theme }) =>
+    floating
+      ? css`
+          width: min(
+            ${FLOATING_WIDTH_PX}px,
+            calc(100vw - ${theme.sizeUnit * 12}px)
+          );
+          height: ${FLOATING_MAX_HEIGHT_VH}vh;
+          border: 1px solid ${theme.colorBorderSecondary};
+          border-radius: ${theme.borderRadiusLG}px;
+          box-shadow: ${theme.boxShadow};
+        `
+      : css`
+          width: 100%;
+          height: 100%;
+        `}
+`;
+
+const ChatHeader = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: ${({ theme }) => theme.sizeUnit * 3}px
+    ${({ theme }) => theme.sizeUnit * 4}px;
+  background: ${({ theme }) => theme.colorBgContainer};
+  border-bottom: 1px solid ${({ theme }) => theme.colorBorderSecondary};
+  font-weight: ${({ theme }) => theme.fontWeightStrong};
+  font-size: ${({ theme }) => theme.fontSizeLG}px;
+  color: ${({ theme }) => theme.colorTextHeading};
+  flex-shrink: 0;
+  gap: ${({ theme }) => theme.sizeUnit * 2}px;
+`;
+
+const HeaderGroup = styled.div`
+  display: flex;
+  align-items: center;
+  min-width: 0;
+`;
+
+const HeaderTitle = styled.span`
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+`;
+
+const ChatMessages = styled.div`
+  flex: 1;
+  min-height: 0;
+  padding: ${({ theme }) => theme.sizeUnit * 4}px;
+  overflow-y: auto;
+
+  &::-webkit-scrollbar {
+    width: 4px;
+  }
+
+  &::-webkit-scrollbar-track {
+    background: ${({ theme }) => theme.colorBgContainer};
+    border-radius: 2px;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background: ${({ theme }) => theme.colorFillSecondary};
+    border-radius: 2px;
+  }
+`;
+
+const MessageBubble = styled.div<{ variant: 'user' | 'assistant' }>`
+  margin-bottom: ${({ theme }) => theme.sizeUnit * 3}px;
+  display: flex;
+  flex-direction: column;
+  align-items: ${({ variant }) =>
+    variant === 'user' ? 'flex-end' : 'flex-start'};
+`;
+
+const MessageContent = styled.div<{ variant: 'user' | 'assistant' }>`
+  max-width: 85%;
+  padding: ${({ theme }) => theme.sizeUnit * 3}px
+    ${({ theme }) => theme.sizeUnit * 4}px;
+  border-radius: ${({ theme }) => theme.borderRadiusLG * 2}px;
+  background: ${({ theme, variant }) =>
+    variant === 'user' ? theme.colorPrimary : theme.colorBgContainer};
+  color: ${({ theme, variant }) =>
+    variant === 'user' ? theme.colorTextLightSolid : theme.colorText};
+  font-size: ${({ theme }) => theme.fontSize}px;
+  line-height: 1.5;
+  border: ${({ theme, variant }) =>
+    variant === 'assistant'
+      ? `1px solid ${theme.colorBorderSecondary}`
+      : 'none'};
+  box-shadow: ${({ theme }) => theme.boxShadowTertiary};
+  overflow-wrap: anywhere;
+
+  p {
+    margin: 0;
+  }
+
+  p:not(:last-child) {
+    margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
+  }
+
+  a {
+    color: ${({ theme, variant }) =>
+      variant === 'user' ? theme.colorTextLightSolid : theme.colorPrimary};
+    text-decoration: underline;
+  }
+
+  code {
+    background: ${({ theme, variant }) =>
+      variant === 'user' ? theme.colorPrimaryActive : theme.colorFillTertiary};
+    padding: 2px ${({ theme }) => theme.sizeUnit * 1.5}px;
+    border-radius: ${({ theme }) => theme.borderRadius}px;
+    font-size: ${({ theme }) => theme.fontSizeSM}px;
+    font-family: ${({ theme }) => theme.fontFamilyCode};
+  }
+
+  pre {
+    background: ${({ theme }) => theme.colorFillQuaternary};
+    padding: ${({ theme }) => theme.sizeUnit * 3}px;
+    border-radius: ${({ theme }) => theme.borderRadius}px;
+    overflow-x: auto;
+    margin: ${({ theme }) => theme.sizeUnit * 2}px 0;
+    border: 1px solid ${({ theme }) => theme.colorBorderSecondary};
+  }
+
+  pre code {
+    background: none;
+    padding: 0;
+  }
+`;
+
+const MessageActions = styled.div`
+  display: flex;
+  gap: ${({ theme }) => theme.sizeUnit}px;
+  margin-top: ${({ theme }) => theme.sizeUnit}px;
+`;
+
+const ActionButton = styled(Button)`
+  &&& {
+    padding: 2px ${({ theme }) => theme.sizeUnit * 1.5}px;
+    height: ${({ theme }) => theme.sizeUnit * 6}px;
+    font-size: ${({ theme }) => theme.fontSizeSM}px;
+  }
+
+  /* The recorded verdict keeps its colour while disabled. Both thumbs lock 
once
+     a rating exists, and the default disabled grey would hide which one the
+     user picked — the state matters more here than the affordance. */
+  &&&.is-active,
+  &&&.is-active:disabled,
+  &&&.is-active[disabled] {
+    color: ${({ theme }) => theme.colorPrimary};
+  }
+`;
+
+const LiveAnswer = styled.div`
+  margin-top: ${({ theme }) => theme.sizeUnit * 2}px;
+  color: ${({ theme }) => theme.colorText};
+  font-size: ${({ theme }) => theme.fontSize}px;
+  line-height: 1.5;
+  overflow-wrap: anywhere;
+
+  p {
+    margin: 0;
+  }
+
+  p:not(:last-child) {
+    margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
+  }
+`;
+
+const thinkingPulse = keyframes`
+  0% {
+    opacity: 0.45;
+  }
+  50% {
+    opacity: 1;
+  }
+  100% {
+    opacity: 0.45;
+  }
+`;
+
+const ThinkingPreview = styled.div<{ isLive?: boolean }>`
+  color: ${({ theme }) => theme.colorTextTertiary};
+  font-size: ${({ theme }) => theme.fontSizeSM}px;
+  white-space: pre-wrap;
+  ${({ isLive }) =>
+    isLive &&
+    css`
+      animation: ${thinkingPulse} 1.8s ease-in-out infinite;
+    `}
+`;
+
+const ThinkingDetails = styled.details`
+  margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
+  color: ${({ theme }) => theme.colorTextTertiary};
+  font-size: ${({ theme }) => theme.fontSizeSM}px;
+
+  summary {
+    cursor: pointer;
+    user-select: none;
+    color: ${({ theme }) => theme.colorTextTertiary};
+    margin-bottom: ${({ theme }) => theme.sizeUnit}px;
+  }
+`;
+
+const CheckpointDivider = styled.div`
+  display: flex;
+  align-items: center;
+  gap: ${({ theme }) => theme.sizeUnit * 3}px;
+  margin: ${({ theme }) => theme.sizeUnit * 4}px 0
+    ${({ theme }) => theme.sizeUnit * 3}px;
+
+  &::before,
+  &::after {
+    content: '';
+    flex: 1;
+    height: 1px;
+    background: ${({ theme }) => theme.colorBorderSecondary};
+  }
+`;
+
+const CountdownBadge = styled.span`
+  font-size: ${({ theme }) => theme.fontSizeSM}px;
+  font-weight: ${({ theme }) => theme.fontWeightStrong};
+  font-variant-numeric: tabular-nums;
+  color: ${({ theme }) => theme.colorTextSecondary};
+  white-space: nowrap;
+`;
+
+const CheckpointContent = styled.div`
+  font-size: ${({ theme }) => theme.fontSizeSM}px;
+  color: ${({ theme }) => theme.colorText};
+  line-height: 1.5;
+`;
+
+const CheckpointTaskList = styled.ul`
+  margin: ${({ theme }) => theme.sizeUnit * 1.5}px 0;
+  padding-left: ${({ theme }) => theme.sizeUnit * 4.5}px;
+  font-size: ${({ theme }) => theme.fontSizeSM}px;
+  color: ${({ theme }) => theme.colorTextSecondary};
+
+  li {
+    margin-bottom: 2px;
+  }
+`;
+
+const CheckpointEstimate = styled.div`
+  font-size: ${({ theme }) => theme.fontSizeSM}px;
+  color: ${({ theme }) => theme.colorTextTertiary};
+  margin-top: ${({ theme }) => theme.sizeUnit}px;
+`;
+
+const CheckpointActions = styled.div`
+  display: flex;
+  gap: ${({ theme }) => theme.sizeUnit * 2}px;
+  margin-top: ${({ theme }) => theme.sizeUnit * 2.5}px;
+`;
+
+const ChatInput = styled.div`
+  padding: ${({ theme }) => theme.sizeUnit * 4}px;
+  border-top: 1px solid ${({ theme }) => theme.colorBorderSecondary};
+  background: ${({ theme }) => theme.colorBgContainer};
+  flex-shrink: 0;
+`;
+
+const InputContainer = styled.div`
+  display: flex;
+  gap: ${({ theme }) => theme.sizeUnit * 2}px;
+  align-items: flex-end;
+`;
+
+const QuickPromptsRow = styled.div<{ hasContent: boolean }>`
+  display: flex;
+  align-items: center;
+  gap: ${({ theme }) => theme.sizeUnit * 2}px;
+  flex-wrap: wrap;
+  margin-bottom: ${({ theme, hasContent }) =>
+    hasContent ? `${theme.sizeUnit * 2.5}px` : '0'};
+  min-height: ${({ theme, hasContent }) =>
+    hasContent ? `${theme.sizeUnit * 6}px` : '0'};
+`;
+
+const QuickPromptChip = styled(Button)`
+  &&& {
+    width: fit-content;
+    max-width: 100%;
+    height: auto;
+    white-space: normal;
+    text-align: left;
+    line-height: 1.35;
+    word-break: break-word;
+  }
+`;
+
+const PageContextRow = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: ${({ theme }) => theme.sizeUnit * 1.5}px;
+`;
+
+const PageContextPill = styled.button<{ isActive: boolean }>`
+  display: inline-flex;
+  align-items: center;
+  gap: ${({ theme }) => theme.sizeUnit * 1.5}px;
+  padding: 3px ${({ theme }) => theme.sizeUnit * 2}px;
+  border-radius: ${({ theme }) => theme.borderRadiusLG}px;
+  border: 1px solid
+    ${({ theme, isActive }) =>
+      isActive ? theme.colorPrimary : theme.colorBorderSecondary};
+  background: ${({ theme, isActive }) =>
+    isActive ? theme.colorPrimaryBg : theme.colorFillQuaternary};
+  color: ${({ theme, isActive }) =>
+    isActive ? theme.colorPrimary : theme.colorTextTertiary};
+  font-size: ${({ theme }) => theme.fontSizeSM}px;
+  cursor: pointer;
+  transition: all ${({ theme }) => theme.motionDurationMid};
+  max-width: ${({ theme }) => theme.sizeUnit * 62}px;
+  white-space: nowrap;
+
+  &:hover {
+    border-color: ${({ theme }) => theme.colorPrimary};
+  }
+`;
+
+const PillLabel = styled.span`
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const PillDot = styled.span<{ isActive: boolean }>`
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  flex-shrink: 0;
+  background: ${({ theme, isActive }) =>
+    isActive ? theme.colorPrimary : theme.colorTextQuaternary};
+  transition: background ${({ theme }) => theme.motionDurationMid};
+`;
+
+const EmptyState = styled.div`
+  text-align: center;
+  color: ${({ theme }) => theme.colorTextSecondary};
+  font-size: ${({ theme }) => theme.fontSizeLG}px;
+  margin-top: ${({ theme }) => theme.sizeUnit * 15}px;
+  padding: 0 ${({ theme }) => theme.sizeUnit * 10}px;
+  line-height: 1.5;
+`;
+
+/**
+ * What the page-context pill says it is sending.
+ *
+ * Naming the specific chart or dashboard is the whole point: "page context" 
tells
+ * the user nothing about whether the assistant can see the thing they are 
asking
+ * about.
+ */
+export const getPageContextLabel = (context: PageContext): string => {
+  switch (context.pageType) {
+    case 'sqllab':
+      return context.sqlContext?.activeEditor?.name || t('SQL Lab');
+    case 'dashboard':
+      return context.dashboardContext?.title
+        ? t('Dashboard: %s', context.dashboardContext.title)
+        : t('Dashboard');
+    case 'explore':
+    case 'chart': {
+      const name =
+        context.chartContext?.chartName ||
+        context.chartContext?.slice?.slice_name;
+      return name ? t('Chart: %s', name) : t('Chart Explorer');
+    }
+    case 'home':
+      return t('Home');
+    default:
+      return context.pathname;
+  }
+};
+
+interface MemoizedChatMessageProps {
+  message: ChatMessageWithMeta;
+  feedback: 'like' | 'dislike' | null;
+  /** Per-message SQL expansion state, present only to invalidate the memo. */
+  sqlBlockExpandState: boolean[] | undefined;
+  createMarkdownComponents: (messageId: string) => Components;
+  /** Keeps this message's thought process open, for the run that just ended. 
*/
+  keepThinkingOpen?: boolean;
+  onFeedback: (messageId: string, feedback: 'like' | 'dislike') => void;
+  onCopy: (content: string) => Promise<void>;
+}
+
+/**
+ * One turn.
+ *
+ * Memoized on content rather than identity because a streaming run re-renders 
the
+ * panel on every frame, and re-parsing the markdown of every earlier message 
each
+ * time made long conversations visibly janky.
+ */
+const MemoizedChatMessage = memo(
+  ({
+    message,
+    feedback,
+    createMarkdownComponents,
+    keepThinkingOpen,
+    onFeedback,
+    onCopy,
+  }: MemoizedChatMessageProps) => {
+    const markdownComponents = createMarkdownComponents(message.id);
+    return (
+      <MessageBubble
+        variant={message.role}
+        data-test="chat-message"
+        data-role={message.role}
+      >
+        <MessageContent variant={message.role}>
+          {message.role === 'assistant' &&
+            // Steps are rendered structurally where the server has sent them,
+            // and as the flat log otherwise. The two are not alternatives so
+            // much as two stages: a turn assembled from stream frames only has
+            // the text, and gains its structure when the transcript is re-read
+            // from the server once the run ends. Any one of the structured
+            // fields is enough — a turn can have page context and no tool 
calls,
+            // or reasoning and no steps.
+            (hasStructuredThinking(message) ? (
+              <ThoughtProcess
+                reasoning={message.thoughts}
+                pageContext={message.pageContext}
+                toolCalls={message.toolCalls}
+                markdownComponents={markdownComponents}
+                // Left open for the turn that has only just finished, so the
+                // section does not collapse and pull the answer up the panel 
the
+                // moment the user starts reading it.
+                defaultOpen={keepThinkingOpen}
+              />
+            ) : (
+              message.thinking && (
+                <ThinkingDetails>
+                  <summary>{t('Thought process')}</summary>
+                  {/* The tool log is markdown too, so a SQL step gets the same
+                      highlighting and "Run in SQL Lab" as SQL in the answer. 
*/}
+                  <ReactMarkdown
+                    components={markdownComponents}
+                    remarkPlugins={REMARK_PLUGINS}
+                  >
+                    {message.thinking}
+                  </ReactMarkdown>
+                </ThinkingDetails>
+              )
+            ))}
+          <ReactMarkdown
+            components={markdownComponents}
+            remarkPlugins={REMARK_PLUGINS}
+          >
+            {message.content}
+          </ReactMarkdown>
+        </MessageContent>
+        {message.role === 'assistant' && (
+          <MessageActions>
+            {/* Both thumbs stay available so a mis-click can be corrected. The
+                recorded verdict is the coloured one, and only it is inert —
+                there is no endpoint to withdraw a rating, so pressing it again
+                would do nothing and should not look like it could. */}
+            <ActionButton
+              buttonStyle="link"
+              icon={<Icons.LikeOutlined iconSize="s" />}
+              onClick={() => onFeedback(message.id, 'like')}
+              className={feedback === 'like' ? 'is-active' : ''}
+              aria-label={t('Good response')}
+              aria-pressed={feedback === 'like'}
+              tooltip={
+                feedback === 'like'
+                  ? t('You marked this a good response')
+                  : t('Good response')
+              }
+              disabled={message.pending || feedback === 'like'}
+            />
+            <ActionButton
+              buttonStyle="link"
+              icon={<Icons.DislikeOutlined iconSize="s" />}
+              onClick={() => onFeedback(message.id, 'dislike')}
+              className={feedback === 'dislike' ? 'is-active' : ''}
+              aria-label={t('Bad response')}
+              aria-pressed={feedback === 'dislike'}
+              tooltip={
+                feedback === 'dislike'
+                  ? t('You marked this a bad response')
+                  : t('Bad response')
+              }
+              disabled={message.pending || feedback === 'dislike'}
+            />
+            <ActionButton
+              buttonStyle="link"
+              icon={<Icons.CopyOutlined iconSize="s" />}
+              onClick={() => {
+                onCopy(message.content).catch(() => {
+                  // Clipboard access can be refused; the button then does
+                  // nothing rather than raising at the user.
+                });
+              }}
+              aria-label={t('Copy to clipboard')}
+              tooltip={t('Copy to clipboard')}
+            />
+          </MessageActions>
+        )}
+      </MessageBubble>
+    );
+  },
+  (previous, next) =>

Review Comment:
   Fixed in 428484e846. The memo comparison includes 
`createMarkdownComponents`, copy and feedback callback identities. A changed 
SQL Lab/editor closure therefore causes the message to render with its current 
actions. All 136 AI frontend tests pass on the current head; I have not run a 
manual browser test of switching SQL Lab editors.



##########
superset-frontend/src/features/ai/hooks/usePageContext.ts:
##########
@@ -0,0 +1,816 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * @fileoverview What the user is looking at, read out of the Redux store.
+ *
+ * The assistant cannot answer "why is this number wrong" without knowing which
+ * chart, query or dashboard "this" refers to, so every page type contributes a
+ * shape describing itself. Everything read here is already on the client; no
+ * request is made to build it.
+ *
+ * Only one page's slice is populated per call, because only one of SQL Lab,
+ * Explore and a dashboard is mounted at a time. Selectors are written against
+ * optional slices for that reason rather than assuming a page's reducers are
+ * registered.
+ */
+
+import { useMemo } from 'react';
+import { useSelector } from 'react-redux';
+import { useLocation } from 'react-router-dom';
+import type { DataMaskStateWithId } from '@superset-ui/core';
+import type { SqlLabRootState } from 'src/SqlLab/types';
+import type { ExplorePageState } from 'src/explore/types';
+import type {
+  DashboardInfo,
+  DashboardLayoutState,
+  LayoutItem,
+  RootState as DashboardRootState,
+  SliceEntitiesState,
+} from 'src/dashboard/types';
+import {
+  CHART_TYPE,
+  MARKDOWN_TYPE,
+  TAB_TYPE,
+} from 'src/dashboard/util/componentTypes';
+import { DASHBOARD_HEADER_ID } from 'src/dashboard/util/constants';
+
+/** At most this many markdown blocks are lifted off a dashboard. */
+const MAX_MARKDOWN_BLOCKS = 10;
+
+/** Per-block ceiling, so one enormous markdown tile cannot fill the prompt. */
+const MAX_MARKDOWN_BLOCK_LENGTH = 25000;
+
+/** Formatted context above this length is considered worth minimising. */
+const LARGE_CONTEXT_THRESHOLD = 5000;
+
+/**
+ * The slices this hook reads.
+ *
+ * Declared locally rather than intersecting the three page root states: those
+ * disagree about the shape of shared keys such as `common`, and an 
intersection
+ * of them is uninhabitable.
+ */
+interface AiPageRootState {
+  sqlLab?: SqlLabRootState['sqlLab'];
+  explore?: ExplorePageState['explore'];
+  /**
+   * `dashboard_title` is spread onto this slice at hydration but is absent 
from
+   * the declared type, so it is named here instead of being read through a 
cast.
+   */
+  dashboardInfo?: DashboardInfo & { dashboard_title?: string };
+  dashboardState?: DashboardRootState['dashboardState'];
+  dashboardLayout?: DashboardLayoutState;
+  sliceEntities?: SliceEntitiesState;
+  nativeFilters?: DashboardRootState['nativeFilters'];
+  dataMask?: DataMaskStateWithId;
+}
+
+/**
+ * Extract chart ID from URL patterns like /explore/?slice_id=123 or 
/chart/123/
+ */
+const extractChartIdFromUrl = (url: string): number | undefined => {
+  // Try to extract from slice_id parameter
+  const sliceIdMatch = url.match(/[?&]slice_id=(\d+)/);
+  if (sliceIdMatch) {
+    return parseInt(sliceIdMatch[1], 10);
+  }
+
+  // Try to extract from chart path
+  const chartPathMatch = url.match(/\/chart\/(\d+)/);
+  if (chartPathMatch) {
+    return parseInt(chartPathMatch[1], 10);
+  }
+
+  return undefined;
+};
+
+/**
+ * Utility function to truncate text with ellipsis
+ */
+export function truncateText(text: string, maxLength: number): string {
+  if (text.length <= maxLength) return text;
+  return `${text.slice(0, maxLength)}...`;
+}
+
+const HELPER_PREFIX = '@helper';
+
+const isHelperDirective = (content: string): boolean =>
+  content.trimStart().toLowerCase().startsWith(HELPER_PREFIX);
+
+export interface PageContext {
+  url: string;
+  pathname: string;
+  pageType: 'sqllab' | 'explore' | 'dashboard' | 'chart' | 'home' | 'other';
+  sqlContext?: {
+    activeEditor?: {
+      sql?: string;
+      database?: string;
+      /** Sent alongside the name so the assistant can run SQL against the
+       * connection the user has selected rather than resolving one by name. */
+      databaseId?: number;
+      schema?: string;
+      catalog?: string;
+      queryLimit?: number;
+      name?: string;
+    };
+    tables?: Array<{
+      name: string;
+      schema?: string;
+      catalog?: string;
+    }>;
+    recentQueries?: Array<{
+      sql?: string;
+      status?: string;
+      executedAt?: number;
+    }>;
+  };
+  chartContext?: {
+    chartId?: string | number;
+    vizType?: string;
+    datasource?: {
+      id?: string | number;
+      name?: string;
+      type?: string;
+      schema?: string;
+      database?: string;
+    };
+    /** A subset of the chart's controls. Values stay `unknown`: the assistant
+     * only ever serialises them, and typing them as `any` would let unchecked
+     * reads back in. */
+    formData?: Record<string, unknown>;
+    chartName?: string;
+    metadata?: {
+      isEditing?: boolean;
+      canOverwrite?: boolean;
+      description?: string;
+    };
+    slice?: {
+      slice_id?: number;
+      slice_name?: string;
+      description?: string;
+    };
+  };
+  dashboardContext?: {
+    /** Sent so the assistant can call dashboard tools against the dashboard 
the
+     * user is actually on. Without it the model has to guess an id or fall 
back
+     * to searching by title. */
+    id?: number;
+    title?: string;
+    activeTabId?: string;
+    activeTabLabel?: string;
+    charts?: Array<{
+      id: number;
+      title?: string;
+    }>;
+    activeFilters?: Array<{
+      name: string;
+      column?: string;
+      filterType?: string;
+      value?: unknown;
+    }>;
+  };
+  pageMarkdown?: Array<{
+    source: string;
+    content: string;
+  }>;
+}
+
+export interface ChatHistoryEntry {
+  role: 'user' | 'assistant';
+  content: string;
+}
+
+/** A named string off an untyped bag, or undefined if it is not one. */
+const readStringField = (
+  bag: Record<string, unknown> | undefined,
+  field: string,
+): string | undefined => {
+  const value = bag?.[field];
+  return typeof value === 'string' && value ? value : undefined;
+};
+
+/** Layout items inside the currently selected tab tree, or all of them when 
the
+ * dashboard has no tabs. */
+const isInActiveTab = (
+  item: LayoutItem,
+  layout: Record<string, LayoutItem>,
+  activeTabs: Set<string>,
+): boolean => {
+  const tabParents = (item.parents ?? []).filter(
+    parent => layout[parent]?.type === TAB_TYPE,
+  );
+  // On dashboards with tabs, include only charts inside the active tab tree.
+  // If there are no tabs or no active tab selected, keep all non-tab charts.
+  if (tabParents.length === 0) {

Review Comment:
   Fixed in fdfa31681d. Visibility filters only tab ancestors: charts with no 
tab parent remain included, while descendants of inactive tabs are excluded. 
The mixed-dashboard regression covers a top-level chart alongside the selected 
tab and passes.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to