diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index b830a890..e866039f 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -57,7 +57,7 @@ import { estimateRenderedTextHeight, RECHECK_VISIBILITY_EVENT } from './bubbles/ import CompactionMarker from './bubbles/CompactionMarker'; import MessageActionBar from './shell/MessageActionBar'; import ToolCallBubble, { ToolPair } from './tool-bubbles/ToolCallBubble'; -import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './tool-bubbles/ToolGroupBubble'; +import ToolGroupBubble, { RenderItem, ToolGroup, ToolGroupEntry, isToolGroup, isToolPair } from './tool-bubbles/ToolGroupBubble'; import ToolUiBubble from './tool-ui/ToolUiBubble'; import AskUiBubble from './tool-ui/AskUiBubble'; import { isShowUiPair, isAskUiPair } from './tool-ui/showUiPayload'; @@ -1058,17 +1058,40 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const renderItems: RenderItem[] = useMemo(() => { const items: RenderItem[] = []; let i = 0; + // Narration that led INTO a tool phase; folds into that phase's group on a finished session. + let leadNotes: typeof activeBranchMessages = []; while (i < activeBranchMessages.length) { const msg = activeBranchMessages[i]; if (msg.role === 'tool_call' || msg.role === 'tool_result') { const group: typeof activeBranchMessages = []; - while ( - i < activeBranchMessages.length && - (activeBranchMessages[i].role === 'tool_call' || - activeBranchMessages[i].role === 'tool_result') - ) { - group.push(activeBranchMessages[i]); - i++; + // On a finished session the whole tool PHASE folds into one quiet row: short narration + // LEADING INTO or BETWEEN tool runs is absorbed (readable on expand), only the final + // answer stays out. While running, narration streams visibly, so the phase never folds live. + const noteMarks: Array<{ afterCall: number; msg: (typeof activeBranchMessages)[number] }> = + leadNotes.map((m) => ({ afterCall: 0, msg: m })); + leadNotes = []; + let callsSoFar = 0; + while (i < activeBranchMessages.length) { + const m = activeBranchMessages[i]; + if (m.role === 'tool_call' || m.role === 'tool_result') { + group.push(m); + if (m.role === 'tool_call') callsSoFar++; + i++; + continue; + } + if (!sessionRunning && m.role === 'assistant') { + let j = i; + while (j < activeBranchMessages.length && activeBranchMessages[j].role === 'assistant') j++; + const next = activeBranchMessages[j]; + if (next && (next.role === 'tool_call' || next.role === 'tool_result')) { + for (let k = i; k < j; k++) { + if (!activeBranchMessages[k].hidden) noteMarks.push({ afterCall: callsSoFar, msg: activeBranchMessages[k] }); + } + i = j; + continue; + } + } + break; } const allCalls = group.filter((m) => m.role === 'tool_call'); @@ -1086,6 +1109,27 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const pairs = allPairs.filter((p) => !isShowUiPair(p) && !isAskUiPair(p)); const calls = pairs.map((p) => p.call); + // Folded narration goes back at its original position among the visible pairs. + const groupEntries: ToolGroupEntry[] | undefined = (() => { + if (noteMarks.length === 0) return undefined; + const entries: ToolGroupEntry[] = []; + let noteIdx = 0; + const noteText = (m: (typeof activeBranchMessages)[number]) => + typeof m.content === 'string' ? m.content : ''; + allPairs.forEach((pair, idx) => { + while (noteIdx < noteMarks.length && noteMarks[noteIdx].afterCall <= idx) { + entries.push({ kind: 'note', id: `note-${noteMarks[noteIdx].msg.id}`, text: noteText(noteMarks[noteIdx].msg) }); + noteIdx++; + } + if (!isShowUiPair(pair) && !isAskUiPair(pair)) entries.push({ kind: 'pair', pair }); + }); + while (noteIdx < noteMarks.length) { + entries.push({ kind: 'note', id: `note-${noteMarks[noteIdx].msg.id}`, text: noteText(noteMarks[noteIdx].msg) }); + noteIdx++; + } + return entries; + })(); + const mcpServers = new Set( calls.map((m) => { const tool = typeof m.content === 'object' ? m.content.tool || '' : ''; @@ -1110,8 +1154,10 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose label, callCount: calls.length, mcpServer, + entries: groupEntries, } satisfies ToolGroup); - } else if (pairs.length <= 2) { + } else if (sessionRunning && pairs.length <= 2 && !groupEntries) { + // Live turns keep bare rows for streaming detail; finished transcripts always rest as the quiet group row. items.push(...pairs); } else if (pairs.length > 0) { const toolNames = new Set( @@ -1125,10 +1171,24 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose pairs, label, callCount: calls.length, + entries: groupEntries, } satisfies ToolGroup); + } else if (noteMarks.length > 0) { + // Phase held only ShowUI/AskUI pairs: narration has no group to fold into, keep it visible. + for (const nm of noteMarks) items.push(nm.msg); } items.push(...showUiPairs); } else { + if (!sessionRunning && msg.role === 'assistant') { + let j = i; + while (j < activeBranchMessages.length && activeBranchMessages[j].role === 'assistant') j++; + const next = activeBranchMessages[j]; + if (next && (next.role === 'tool_call' || next.role === 'tool_result')) { + leadNotes = activeBranchMessages.slice(i, j).filter((m) => !m.hidden); + i = j; + continue; + } + } if (!msg.hidden) { items.push(msg); } @@ -1136,7 +1196,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose } } return items; - }, [activeBranchMessages]); + }, [activeBranchMessages, sessionRunning]); React.useLayoutEffect(() => { const total = renderItems.length; @@ -2290,6 +2350,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose autoFocus={autoFocus} prefillPrompt={prefillPrompt} placeholderOverride={runContext ? 'Ask about this run...' : embedded ? 'Send a message...' : undefined} + quietComposer={embedded} runContext={runContext} onClearRunContext={onClearRunContext} thinkingLevel={session?.thinking_level ?? 'auto'} diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index 6027f1cb..28b2477a 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -48,12 +48,14 @@ interface Props { prefillPrompt?: string; // Replaces the default "Agent, @ for context..." placeholder (e.g. "Ask about this run..."). placeholderOverride?: string; + // Desktop-card composer: rest as input + attach/mic; pickers return on focus. + quietComposer?: boolean; // A workflow run shown as a small removable chip inside the composer. runContext?: WorkflowsRunContext; onClearRunContext?: () => void; } -const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange, onActivityLabelChange, prefillPrompt, placeholderOverride, runContext, onClearRunContext }, ref) => { +const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange, onActivityLabelChange, prefillPrompt, placeholderOverride, quietComposer, runContext, onClearRunContext }, ref) => { const c = useClaudeTokens(); const editorRef = useRef(null); const containerRef = useRef(null); @@ -320,6 +322,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, editorRef={editorRef} generalFileInputRef={generalFileInputRef} embedded={embedded} + quietComposer={quietComposer} isDragOver={isDragOver} isUploading={isUploading} handleDragOver={handleDragOver} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx index 045e199f..4d1100fe 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx @@ -50,6 +50,8 @@ interface Props { isRunning?: boolean; onStop?: () => void; handleSend: () => void; + /** Embedded-card resting look: only attach + mic; pickers come back on focus. */ + restMode?: boolean; } export const ChatInputToolbar: React.FC = (p) => { @@ -58,7 +60,7 @@ export const ChatInputToolbar: React.FC = (p) => { allModelFlat, model, onModelChange, onProviderChange, picker, pendingKinds, pendingPayloadEstimate, thinkingLevel, onThinkingLevelChange, contextEstimate, elementSelection, autoRunMode, ownerId, sessionId, generalFileInputRef, addImageFiles, uploadAndAttachFiles, - hasContent, disabled, isRunning, onStop, handleSend, + hasContent, disabled, isRunning, onStop, handleSend, restMode, } = p; const menuPaperProps = { @@ -94,12 +96,14 @@ export const ChatInputToolbar: React.FC = (p) => { pt: 0, }} > - + {!restMode && ( + + )} = (p) => { pendingPayloadEstimate={pendingPayloadEstimate} /> - {!hideForTrial && ( + {!hideForTrial && !restMode && ( = (p) => { - {contextEstimate && ( + {contextEstimate && !restMode && ( = (p) => { void; handleSend: () => void; + restMode?: boolean; } export const ToolbarActions: React.FC = ({ c, elementSelection, autoRunMode, ownerId, sessionId, generalFileInputRef, - addImageFiles, uploadAndAttachFiles, hasContent, disabled, isRunning, onStop, handleSend, + addImageFiles, uploadAndAttachFiles, hasContent, disabled, isRunning, onStop, handleSend, restMode, }) => { return ( <> - {elementSelection && !autoRunMode && (() => { + {elementSelection && !autoRunMode && !restMode && (() => { const isMySelectMode = elementSelection.selectMode && elementSelection.activeOwnerId === ownerId; return ( diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx index 069f6035..fa317fb5 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx @@ -1,4 +1,4 @@ -import React, { RefObject } from 'react'; +import React, { RefObject, useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; @@ -28,6 +28,7 @@ interface Props { editorRef: RefObject; generalFileInputRef: RefObject; embedded?: boolean; + quietComposer?: boolean; isDragOver: boolean; isUploading: boolean; handleDragOver: (e: React.DragEvent) => void; @@ -106,9 +107,18 @@ interface Props { export const ChatInputView: React.FC = (p) => { const { c } = p; + // Embedded card composers rest as just the input + attach/mic (the frame look); the pickers return on focus, draft text, or any open menu. + const [focusWithin, setFocusWithin] = useState(false); + const restMode = Boolean( + p.quietComposer && !focusWithin && !p.hasContent && !p.modelAnchor && !p.thinkingAnchor && !p.modeAnchor, + ); return ( setFocusWithin(true)} + onBlurCapture={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocusWithin(false); + }} onDragOver={p.handleDragOver} onDragLeave={p.handleDragLeave} onDrop={p.handleDrop} @@ -246,6 +256,7 @@ export const ChatInputView: React.FC = (p) => { = React.memo(({ group, isSessionRunning = const c = useClaudeTokens(); const reveal = useMountReveal(); // JS-driven slide-in; see useMountReveal (was a fragile mount keyframe) const isMcp = !!group.mcpServer; - const [expanded, setExpanded] = useState(isMcp); + // MCP groups auto-expand only WHILE the run is live; a finished transcript rests as the quiet row. + const [expanded, setExpanded] = useState(isMcp && isSessionRunning); + const userToggledRef = React.useRef(false); + React.useEffect(() => { + if (!isSessionRunning && !userToggledRef.current) setExpanded(false); + }, [isSessionRunning]); const completedCount = group.pairs.filter((p) => p.result !== null).length; const pendingCount = group.pairs.filter((p) => p.result === null).length; @@ -126,7 +137,7 @@ const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = {/* Collapsed = the quiet "N tool calls ›" line; the detail card only materializes on expand. */} {!expanded ? ( setExpanded(true)} + onClick={() => { userToggledRef.current = true; setExpanded(true); }} sx={{ display: 'inline-flex', alignItems: 'center', @@ -154,7 +165,7 @@ const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = ) : ( setExpanded(false)} + onClick={() => { userToggledRef.current = true; setExpanded(false); }} sx={{ display: 'flex', alignItems: 'center', @@ -228,16 +239,25 @@ const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = }, }} > - {group.pairs.map((pair) => ( - - ))} + {(group.entries ?? group.pairs.map((pair) => ({ kind: 'pair' as const, pair }))).map((entry) => + entry.kind === 'pair' ? ( + + ) : ( + + {entry.text} + + ), + )} diff --git a/frontend/src/app/pages/AgentChat/tool-ui/WeatherWidget.tsx b/frontend/src/app/pages/AgentChat/tool-ui/WeatherWidget.tsx index 7034c16f..7968f5d7 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/WeatherWidget.tsx +++ b/frontend/src/app/pages/AgentChat/tool-ui/WeatherWidget.tsx @@ -5,7 +5,8 @@ import { useThemeMode } from '@/shared/styles/ThemeContext'; import type { WeatherProps } from './showUiPayload'; function toConditionCode(condition: string | undefined): WeatherConditionCode { - const cond = (condition || '').toLowerCase(); + // "Partly Cloudy with Slight Chance of Showers" is a partly-cloudy scene, not a rain scene: drop the chance-of qualifiers so the leading descriptor wins. + const cond = (condition || '').toLowerCase().replace(/(slight |small )?chance( of)? (showers?|rain|snow|thunderstorms?)/g, ''); if (/thunder|storm/.test(cond)) return 'thunderstorm'; if (/heavy rain|downpour/.test(cond)) return 'heavy-rain'; if (/drizzle/.test(cond)) return 'drizzle'; @@ -27,12 +28,13 @@ function WeatherWidget({ props }: { props: WeatherProps }): React.ReactElement { const forecast: ForecastDay[] = (props.forecast || []).slice(0, 7).map((d) => ({ label: d.day, conditionCode: toConditionCode(d.condition), - tempMin: Math.round(d.low ?? d.high - 8), - tempMax: Math.round(d.high), + tempMin: Math.round(d.low ?? (d.high ?? props.temp) - 8), + tempMax: Math.round(d.high ?? (d.low ?? props.temp) + 8), })); return ( -
+ // 4:3 card; the vendored strip reveals at 245px height and its day icons at 280px, so width must be >= 374 for the full frame look. +
>) - .filter((d) => str(d.day) && num(d.high)) + // Either bound is enough; a "Tonight" entry legitimately has only a low. + .filter((d) => str(d.day) && (num(d.high) || num(d.low))) .slice(0, 7) .map((d) => ({ day: d.day as string, condition: str(d.condition) ? d.condition : undefined, - high: d.high as number, + high: num(d.high) ? d.high : undefined, low: num(d.low) ? d.low : undefined, })) : undefined; diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 41042f74..c4d407e6 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -18,6 +18,7 @@ import { handleApproval, collapseSession, closeSession, + fetchSession, renameSession, } from '@/shared/state/agentsSlice'; import { displayChatTitle, isLegacyAutoName } from '@/shared/state/sessionDisplay'; @@ -698,6 +699,15 @@ const AgentCard: React.FC = ({ const pillLabel = session.turn_label?.label || displayChatTitle(session); const pillRunning = session.status === 'running'; + // Cold-loaded collapsed cards carry no transcript (status frames are slim), so the pill can't pin + // its widget/checklist artifact; hydrate ONCE per card actually on this dashboard, never in a loop. + const pillHydratedRef = React.useRef(false); + React.useEffect(() => { + if (!pillMode || pillHydratedRef.current) return; + pillHydratedRef.current = true; + if ((session.messages || []).length === 0) dispatch(fetchSession(session.id)); + }, [pillMode, session.messages, session.id, dispatch]); + // f7's collapsed state: a session that spawned a browser shows that window under the pill. const spawnedBrowserId = useAppSelector((s) => { for (const bc of Object.values(s.dashboardLayout.browserCards)) { diff --git a/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx index bf5d8302..6d529b94 100644 --- a/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx @@ -26,9 +26,28 @@ function AgentNarratorPill({ label, running, todos, artifact, browserShot, selec const visibleTodos = (todos || []).slice(0, MAX_VISIBLE_TODOS); const hiddenCount = (todos?.length || 0) - visibleTodos.length; const ring = selected || highlighted ? { outline: '2px solid #3b82f6', outlineOffset: '2px' } : undefined; + // One key per ladder state so a state CHANGE remounts the artifact and replays the one-shot entrance; nothing loops. + const artifactKey = artifact ? 'widget' : browserShot ? 'shot' : visibleTodos.length > 0 ? 'todos' : running ? 'thinking' : 'none'; return ( - + {artifact ? ( - + + + ) : browserShot ? ( ) : visibleTodos.length > 0 ? ( ) : running ? (