diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx index c950e0728a..ae45e70a50 100644 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx @@ -33,21 +33,20 @@ import type {ClientToolOutputHandler} from "./components/clientTools" import ComposerAttachments from "./components/ComposerAttachments" import QueuedMessages from "./components/QueuedMessages" import SessionHistoryMenu from "./components/SessionHistoryMenu" -import SessionTabLabel from "./components/SessionTabLabel" +import SessionTagBar from "./components/SessionTagBar" import {useAgentChatQueue, type QueuedMessage} from "./hooks/useAgentChatQueue" import {useChatScopeKey} from "./state/scope" import { - type AgentChatSession, + type SessionRunStatus, activeSessionIdAtomFamily, addSessionAtomFamily, closeSessionAtomFamily, persistSessionMessagesAtom, renameSessionAtomFamily, - sessionFirstUserTextAtomFamily, sessionMessagesAtom, sessionsListAtomFamily, setActiveSessionAtomFamily, - setSessionStreamingAtom, + setSessionStatusAtom, } from "./state/sessions" /** A stream error/abort is already surfaced via `useChat`'s `onError` + the in-chat `error` @@ -184,6 +183,7 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: const store = useStore() const persistMessages = useSetAtom(persistSessionMessagesAtom) const switchEntity = useSetAtom(playgroundController.actions.switchEntity) + const setSessionStatus = useSetAtom(setSessionStatusAtom) const [files, setFiles] = useState([]) // Files turned away by the guardrails (too big, wrong type, over the count), shown inline. @@ -275,17 +275,6 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: const busy = status === "submitted" || status === "streaming" - // Publish this client's live streaming state for the session so the Session inspector knows - // THIS tab is the active watcher (inline chat streams over the runner NDJSON, not the - // coordination-plane attach). Clear on unmount so a closed/navigated-away tab stops claiming it. - const setSessionStreaming = useSetAtom(setSessionStreamingAtom) - useEffect(() => { - setSessionStreaming({id: sessionId, streaming: busy}) - }, [sessionId, busy, setSessionStreaming]) - useEffect(() => { - return () => setSessionStreaming({id: sessionId, streaming: false}) - }, [sessionId, setSessionStreaming]) - // Settle a parked client tool (#4920). The dispatcher calls this from a widget (e.g. the connect // widget) with the structured reference; `addToolOutput` matches the part by `toolCallId` on the // last turn and the resume predicate auto-resends. `tool` is only the typed-tools key — matching @@ -358,6 +347,25 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: sendQueued, }) + // Publish this session's run state (single source of truth: drives the tab bar's status dot + // AND the Session inspector's live-watcher signal, which derives "streaming" from `running`). + // Precedence error > awaiting approval > running > idle. Reset to idle on unmount so a closed + // tab keeps no stale dot and stops claiming it's the live watcher. + useEffect(() => { + const status: SessionRunStatus = error + ? "error" + : hitlPending + ? "awaiting" + : busy + ? "running" + : "idle" + setSessionStatus({id: sessionId, status}) + }, [error, hitlPending, busy, sessionId, setSessionStatus]) + useEffect( + () => () => setSessionStatus({id: sessionId, status: "idle"}), + [sessionId, setSessionStatus], + ) + // Consume a pending "Run in playground" request (declared above) via the queue's `submit`, // so it interleaves with HITL approval / queued messages exactly like a manual send. useEffect(() => { @@ -942,9 +950,12 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: )} - {/* Rich markdown composer (Lexical). Enter sends; attachments via header/prefix slots. */} + {/* Rich markdown composer (Lexical). Enter sends; attachments via header/prefix slots. + `mx-3 mb-3` insets it to match the message padding + session-bar gutter (the panel + root has no padding so the session bar can align with the config header). */} addFiles(Array.from(pasted))} @@ -989,37 +1000,14 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: * AgentChatPanel — the agent-generation surface hosted INSIDE the playground (the third * generation arm beside chat and completion). * - * Single view keeps the slice's editable-card session tab bar (design decision D2): parallel - * conversations, add with `+`, close with `×`, double-click to rename. Sessions are app-scoped - * (shared with the rest of the playground) and persist to localStorage, so tabs survive a - * reload; antd keeps visited panes mounted, so switching tabs preserves a session's live - * stream / approval state. Each tab is its own `useChat` driven by `buildAgentRequest` against - * the current `entityId` (so the run always uses the live draft config). - */ -/** - * Tab label, scoped to its own session: subscribes only to that session's first-user-text - * (a stable string), so a streaming conversation doesn't re-render the whole tab bar / every - * mounted pane on each token. + * Single view keeps the slice's session tab bar (design decision D2): parallel conversations, + * add with `+`, close with `×`, double-click to rename — rendered as a row of status-dotted tags + * (`SessionTagBar`) whose bottom edge aligns with the config panel header. Sessions are app-scoped + * (shared with the rest of the playground) and persist to localStorage, so tabs survive a reload; + * antd keeps visited panes mounted (we only swap the bar via `renderTabBar`), so switching tabs + * preserves a session's live stream / approval state. Each tab is its own `useChat` driven by + * `buildAgentRequest` against the current `entityId` (so the run always uses the live draft config). */ -const TabLabel = ({ - session, - index, - onRename, -}: { - session: AgentChatSession - index: number - onRename: (title: string) => void -}) => { - const text = useAtomValue(sessionFirstUserTextAtomFamily(session.id)) - const truncated = text.length > 24 ? `${text.slice(0, 24)}…` : text - return ( - - ) -} - const AgentChatPanel = ({entityId}: {entityId: string}) => { const scope = useChatScopeKey() const sessions = useAtomValue(sessionsListAtomFamily(scope)) @@ -1044,35 +1032,32 @@ const AgentChatPanel = ({entityId}: {entityId: string}) => { const activeId = sessions.some((s) => s.id === rawActiveId) ? rawActiveId : sessions[0]?.id return ( -
+
{ - if (action === "add") addSession() - else if (typeof targetKey === "string") closeSession(targetKey) - }} - tabBarExtraContent={{ - right: ( -
- - -
- ), - }} - items={sessions.map((session, index) => ({ + renderTabBar={() => ( + renameSession({id, title})} + extra={ + <> + + + + } + /> + )} + items={sessions.map((session) => ({ key: session.id, - closable: sessions.length > 1, - label: ( - renameSession({id: session.id, title})} - /> - ), + // Bar is rendered by `renderTabBar` (SessionTagBar); the per-item label is unused. + label: null, children: , }))} /> diff --git a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx index dedfa60a88..441145298b 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx @@ -392,9 +392,10 @@ const AgentMessage = ({ defaultBody ) - // Control toolbar — an X `Actions` row that FLOATS over the bubble's bottom edge. It is - // absolutely positioned (out of flow), so it adds no height: bubbles sit tight with no - // reserved lane, and revealing it only fades opacity — no layout shift either way. + // Control toolbar — an X `Actions` row that sits in a reserved lane BELOW the bubble (the + // `pb-7` on the row), so it never overlays the last content line and never reaches the next + // turn. The lane is always present (stable height), so revealing it only fades opacity — no + // layout shift either way (the scroll engineering is sensitive to hover-driven reflow). // `pointer-events-none` while hidden keeps the invisible buttons unclickable. `Actions` // items carry no `disabled`, so the busy guard lives in the handlers: `onRewind` → // `handleRewind` early-returns while a stream is in flight (copy / view-trace are always @@ -442,13 +443,13 @@ const AgentMessage = ({ ) - // `group relative` → the floating toolbar reveals on hover/focus of the whole message row - // and anchors to the bubble without consuming layout space. The row is a flex that - // justifies the (width-capped) bubble to its side, so the opposite side keeps whitespace — - // agent bubbles hug the left, user bubbles the right, neither spans the full column. + // `group relative` → the toolbar reveals on hover/focus of the whole message row and anchors + // to the reserved lane (`pb-7`) at the row's bottom. The row is a flex that justifies the + // (width-capped) bubble to its side, so the opposite side keeps whitespace — agent bubbles hug + // the left, user bubbles the right, neither spans the full column. return (
placement={isUser ? "end" : "start"} @@ -466,7 +467,7 @@ const AgentMessage = ({ content={body} />
diff --git a/web/oss/src/components/AgentChatSlice/components/SessionTabLabel.tsx b/web/oss/src/components/AgentChatSlice/components/SessionTabLabel.tsx index c6eec35bd8..59e5a22897 100644 --- a/web/oss/src/components/AgentChatSlice/components/SessionTabLabel.tsx +++ b/web/oss/src/components/AgentChatSlice/components/SessionTabLabel.tsx @@ -3,10 +3,21 @@ import {useState} from "react" import {Input} from "antd" /** - * A session tab's label. Double-click to rename inline (commit on Enter/blur). Clicks while - * editing are stopped so they don't also switch tabs. + * A session tab's label. Double-click to rename inline (commit on Enter/blur). While editing, + * the input owns its own pointer + keyboard events (stopped from bubbling) so the surrounding + * tab's activation handler never sees them — otherwise Space couldn't be typed into a name and + * Enter would also switch tabs. `className` styles the resting display span (the tag passes + * `truncate` so a long title clips with an ellipsis). */ -const SessionTabLabel = ({label, onRename}: {label: string; onRename: (next: string) => void}) => { +const SessionTabLabel = ({ + label, + onRename, + className, +}: { + label: string + onRename: (next: string) => void + className?: string +}) => { const [editing, setEditing] = useState(false) const [draft, setDraft] = useState(label) @@ -17,20 +28,22 @@ const SessionTabLabel = ({label, onRename}: {label: string; onRename: (next: str } return ( setDraft(e.target.value)} onPressEnter={commit} onBlur={commit} onClick={(e) => e.stopPropagation()} - className="!w-28 !text-xs" + // Keep typing (Space/Enter) inside the rename input; don't let it reach the tab. + onKeyDown={(e) => e.stopPropagation()} + className="!h-6 !w-28 !px-1 !text-xs" /> ) } return ( { setDraft(label) setEditing(true) diff --git a/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx b/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx new file mode 100644 index 0000000000..1cb9130e29 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx @@ -0,0 +1,162 @@ +import {Plus, X} from "@phosphor-icons/react" +import {Button, Tooltip} from "antd" +import clsx from "clsx" +import {useAtomValue} from "jotai" + +import { + type AgentChatSession, + type SessionRunStatus, + sessionFirstUserTextAtomFamily, + sessionStatusAtomFamily, +} from "../state/sessions" + +import SessionTabLabel from "./SessionTabLabel" + +const STATUS_META: Record = { + running: {dot: "bg-colorInfo", pulse: true, title: "Running"}, + awaiting: {dot: "bg-colorWarning", pulse: true, title: "Waiting for approval"}, + error: {dot: "bg-colorError", pulse: false, title: "Last run failed"}, + idle: {dot: "bg-colorTextQuaternary", pulse: false, title: "Idle"}, +} + +/** A session's run-state dot. Subscribes to just that session's status atom so a streaming + * conversation repaints only its own dot, never the whole bar. */ +const SessionStatusDot = ({sessionId}: {sessionId: string}) => { + const status = useAtomValue(sessionStatusAtomFamily(sessionId)) + const meta = STATUS_META[status] + return ( + + {meta.pulse && ( + + )} + + + ) +} + +interface SessionTagProps { + session: AgentChatSession + index: number + active: boolean + closable: boolean + onSelect: () => void + onClose: () => void + onRename: (title: string) => void +} + +/** One session chip: status dot + truncated label (double-click to rename) + hover close. */ +const SessionTag = ({ + session, + index, + active, + closable, + onSelect, + onClose, + onRename, +}: SessionTagProps) => { + const text = useAtomValue(sessionFirstUserTextAtomFamily(session.id)) + const label = session.title || text || `Chat ${index + 1}` + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + onSelect() + } + }} + className={clsx( + "group flex h-7 max-w-[180px] min-w-0 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-solid px-2 text-xs transition-colors", + active + ? "border-colorBorder bg-colorFillSecondary text-colorText" + : "border-colorBorderSecondary bg-transparent text-colorTextSecondary hover:bg-colorFillTertiary hover:text-colorText", + )} + > + + + {closable && ( +
+ ) +} + +export interface SessionTagBarProps { + sessions: AgentChatSession[] + activeId?: string + onSelect: (id: string) => void + onAdd: () => void + onClose: (id: string) => void + onRename: (id: string, title: string) => void + /** Right-aligned extras (e.g. the session-history menu). */ + extra?: React.ReactNode +} + +/** + * Tag-style session bar for the agent playground. Replaces antd's editable-card tab strip via + * `renderTabBar`, so the panes (and their live `useChat` streams) keep antd's mount semantics + * while the bar reads as a row of chips. The 48px height + bottom border aligns its bottom edge + * with the config panel header on the left. + */ +const SessionTagBar = ({ + sessions, + activeId, + onSelect, + onAdd, + onClose, + onRename, + extra, +}: SessionTagBarProps) => { + const closable = sessions.length > 1 + return ( +
+
+ {sessions.map((session, index) => ( + onSelect(session.id)} + onClose={() => onClose(session.id)} + onRename={(title) => onRename(session.id, title)} + /> + ))} + +
+ {extra &&
{extra}
} +
+ ) +} + +export default SessionTagBar diff --git a/web/oss/src/components/AgentChatSlice/state/sessions.ts b/web/oss/src/components/AgentChatSlice/state/sessions.ts index 9cf3b37a96..581fd05352 100644 --- a/web/oss/src/components/AgentChatSlice/state/sessions.ts +++ b/web/oss/src/components/AgentChatSlice/state/sessions.ts @@ -238,31 +238,6 @@ export const setActiveSessionAtomFamily = atomFamily((key: string) => }), ) -/** - * Live streaming status per session id, keyed by the globally-unique session id (no scope - * dimension). The chat panel writes `true` while its turn is submitted/streaming and `false` - * when it settles. The session inspector reads it to know THIS client is the live watcher of a - * session — that's what drives the inspector's Attach/Detach enablement and the `attached` - * indicator, since an inline chat run streams over the runner NDJSON, not the coordination-plane - * attach. In-memory only (not persisted): it describes the current browser tab, not history. - */ -export const sessionStreamingAtom = atom>({}) - -/** Is THIS browser currently streaming the given session? */ -export const isSessionStreamingAtomFamily = atomFamily((id: string) => - atom((get) => Boolean(get(sessionStreamingAtom)[id])), -) - -/** Set/clear the live-streaming flag for a session id. */ -export const setSessionStreamingAtom = atom( - null, - (get, set, {id, streaming}: {id: string; streaming: boolean}) => { - const cur = get(sessionStreamingAtom) - if (Boolean(cur[id]) === streaming) return - set(sessionStreamingAtom, {...cur, [id]: streaming}) - }, -) - /** Write a session's messages to the persisted store (called when its stream settles). */ export const persistSessionMessagesAtom = atom( null, @@ -304,3 +279,56 @@ export const sessionLabel = ( export const sessionFirstUserTextAtomFamily = atomFamily((id: string) => selectAtom(sessionMessagesAtom, (all) => firstUserText(all[id])), ) + +/** + * Run state of a session's live conversation — the single source of truth for "what is this + * session doing right now", surfaced as the tab bar's status dot AND the session inspector's + * live-watcher signal (see `isSessionStreamingAtomFamily`). + * - running: a turn is streaming / submitted + * - awaiting: paused on a human-in-the-loop approval + * - error: the last run failed + * - idle: nothing in flight (also the default for unvisited / closed sessions) + */ +export type SessionRunStatus = "idle" | "running" | "awaiting" | "error" + +/** + * Canonical per-session run state, keyed by the globally-unique session id (no scope dimension). + * Written by the mounted conversation (from its useChat status / approval / error); everything + * status-related derives from this one record so there's no competing streaming flag to keep in + * sync. In-memory only (not persisted): it describes the current browser tab, not history. + */ +const sessionStatusByIdAtom = atom>({}) + +/** A single session's run state. Defaults to "idle" for sessions with no mounted conversation. + * Backs the tab bar's status dot; reads repaint only when this session's status changes. */ +export const sessionStatusAtomFamily = atomFamily((id: string) => + atom((get) => get(sessionStatusByIdAtom)[id] ?? "idle"), +) + +/** + * Is THIS browser currently streaming the given session? Derived from the run state (`running`). + * The session inspector reads it to know THIS client is the live watcher of a session — that's + * what drives the inspector's Attach/Detach enablement and the `attached` indicator, since an + * inline chat run streams over the runner NDJSON, not the coordination-plane attach. + */ +export const isSessionStreamingAtomFamily = atomFamily((id: string) => + atom((get) => get(sessionStatusByIdAtom)[id] === "running"), +) + +/** Set a session's run state. "idle" is the default, so it's stored as ABSENCE: passing "idle" + * deletes the entry (clear-on-unmount) instead of accumulating idle keys for every closed session. */ +export const setSessionStatusAtom = atom( + null, + (get, set, {id, status}: {id: string; status: SessionRunStatus}) => { + const cur = get(sessionStatusByIdAtom) + if (status === "idle") { + if (!(id in cur)) return + const next = {...cur} + delete next[id] + set(sessionStatusByIdAtom, next) + return + } + if (cur[id] === status) return + set(sessionStatusByIdAtom, {...cur, [id]: status}) + }, +) diff --git a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx index 21684773e9..f8a63cfbdf 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx @@ -166,9 +166,11 @@ const PlaygroundVariantConfigHeader = ({
/>
-
+
{prefix}