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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 56 additions & 71 deletions web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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<UploadFile[]>([])
// Files turned away by the guardrails (too big, wrong type, over the count), shown inline.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -942,9 +950,12 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId:
</div>
)}

{/* 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). */}
<RichChatInput
ref={richInputRef}
className="mx-3 mb-3"
onSubmit={handleSubmit}
placeholder="Ask the agent… (Enter to send, ⌘/Ctrl+Enter for newline)"
onPasteFile={(pasted) => addFiles(Array.from(pasted))}
Expand Down Expand Up @@ -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 (
<SessionTabLabel
label={session.title || truncated || `Chat ${index + 1}`}
onRename={onRename}
/>
)
}

const AgentChatPanel = ({entityId}: {entityId: string}) => {
const scope = useChatScopeKey()
const sessions = useAtomValue(sessionsListAtomFamily(scope))
Expand All @@ -1044,35 +1032,32 @@ const AgentChatPanel = ({entityId}: {entityId: string}) => {
const activeId = sessions.some((s) => s.id === rawActiveId) ? rawActiveId : sessions[0]?.id

return (
<div className="flex h-full min-h-0 w-full flex-col p-3">
<div className="flex h-full min-h-0 w-full flex-col">
<Tabs
type="editable-card"
size="small"
className="flex min-h-0 flex-1 flex-col [&_.ant-tabs-content]:h-full [&_.ant-tabs-content-holder]:min-h-0 [&_.ant-tabs-content-holder]:flex-1 [&_.ant-tabs-nav]:!mb-0 [&_.ant-tabs-nav]:!-mx-3 [&_.ant-tabs-nav]:!px-3 [&_.ant-tabs-tabpane]:h-full"
animated={false}
className="flex min-h-0 flex-1 flex-col [&_.ant-tabs-content]:h-full [&_.ant-tabs-content-holder]:min-h-0 [&_.ant-tabs-content-holder]:flex-1 [&_.ant-tabs-tabpane]:h-full"
activeKey={activeId}
onChange={setActiveSession}
onEdit={(targetKey, action) => {
if (action === "add") addSession()
else if (typeof targetKey === "string") closeSession(targetKey)
}}
tabBarExtraContent={{
right: (
<div className="flex items-center gap-1">
<SessionInspectorButton sessionId={activeId ?? null} />
<SessionHistoryMenu />
</div>
),
}}
items={sessions.map((session, index) => ({
renderTabBar={() => (
<SessionTagBar
sessions={sessions}
activeId={activeId}
onSelect={setActiveSession}
onAdd={addSession}
onClose={closeSession}
onRename={(id, title) => renameSession({id, title})}
extra={
<>
<SessionInspectorButton sessionId={activeId ?? null} />
<SessionHistoryMenu />
</>
}
/>
)}
items={sessions.map((session) => ({
key: session.id,
closable: sessions.length > 1,
label: (
<TabLabel
session={session}
index={index}
onRename={(title) => renameSession({id: session.id, title})}
/>
),
// Bar is rendered by `renderTabBar` (SessionTagBar); the per-item label is unused.
label: null,
children: <AgentConversation entityId={entityId} sessionId={session.id} />,
}))}
/>
Expand Down
19 changes: 10 additions & 9 deletions web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<div
className={`group relative flex items-start ${isUser ? "justify-end" : "justify-start"}`}
className={`group relative flex items-start pb-7 ${isUser ? "justify-end" : "justify-start"}`}
>
<Bubble<React.ReactNode>
placement={isUser ? "end" : "start"}
Expand All @@ -466,7 +467,7 @@ const AgentMessage = ({
content={body}
/>
<div
className={`absolute top-full z-10 flex -translate-y-1/2 items-center gap-1 rounded-md border border-solid border-colorBorderSecondary bg-colorBgElevated px-1 shadow-sm ${
className={`absolute bottom-0 z-10 flex items-center gap-1 rounded-md border border-solid border-colorBorderSecondary bg-colorBgElevated px-1 shadow-sm ${
isUser ? "right-2" : "left-10"
} ${toolbarReveal}`}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -17,20 +28,22 @@ const SessionTabLabel = ({label, onRename}: {label: string; onRename: (next: str
}
return (
<Input
size="small"
autoFocus
value={draft}
onChange={(e) => 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 (
<span
className={className}
onDoubleClick={() => {
setDraft(label)
setEditing(true)
Expand Down
Loading
Loading