[eric] redesign: widget strip+scene fix, finished-turn tool fold, rest composer, pill hydration

This commit is contained in:
ciregenz
2026-07-20 13:48:35 -07:00
parent 6f2bf7d5fb
commit 2a015b83e1
10 changed files with 186 additions and 45 deletions
+71 -10
View File
@@ -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<AgentChatProps> = ({ 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<AgentChatProps> = ({ 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<AgentChatProps> = ({ 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<AgentChatProps> = ({ 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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
}
}
return items;
}, [activeBranchMessages]);
}, [activeBranchMessages, sessionRunning]);
React.useLayoutEffect(() => {
const total = renderItems.length;
@@ -2290,6 +2350,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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'}
@@ -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<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ 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<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -320,6 +322,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
editorRef={editorRef}
generalFileInputRef={generalFileInputRef}
embedded={embedded}
quietComposer={quietComposer}
isDragOver={isDragOver}
isUploading={isUploading}
handleDragOver={handleDragOver}
@@ -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<Props> = (p) => {
@@ -58,7 +60,7 @@ export const ChatInputToolbar: React.FC<Props> = (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<Props> = (p) => {
pt: 0,
}}
>
<ModelControl
c={c}
setModelAnchor={setModelAnchor}
allModelFlat={allModelFlat}
model={model}
/>
{!restMode && (
<ModelControl
c={c}
setModelAnchor={setModelAnchor}
allModelFlat={allModelFlat}
model={model}
/>
)}
<ModelPickerMenu
c={c}
@@ -134,7 +138,7 @@ export const ChatInputToolbar: React.FC<Props> = (p) => {
pendingPayloadEstimate={pendingPayloadEstimate}
/>
{!hideForTrial && (
{!hideForTrial && !restMode && (
<ThinkingLevelControl
c={c}
model={model}
@@ -149,7 +153,7 @@ export const ChatInputToolbar: React.FC<Props> = (p) => {
<Box sx={{ flex: 1 }} />
{contextEstimate && (
{contextEstimate && !restMode && (
<ContextRing
used={contextEstimate.used}
limit={contextEstimate.limit}
@@ -160,6 +164,7 @@ export const ChatInputToolbar: React.FC<Props> = (p) => {
<ToolbarActions
c={c}
restMode={restMode}
elementSelection={elementSelection}
autoRunMode={autoRunMode}
ownerId={ownerId}
@@ -24,15 +24,16 @@ interface Props {
isRunning?: boolean;
onStop?: () => void;
handleSend: () => void;
restMode?: boolean;
}
export const ToolbarActions: React.FC<Props> = ({
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 (
<Tooltip title={isMySelectMode ? 'Exit select mode' : 'Select UI element'}>
@@ -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<HTMLDivElement>;
generalFileInputRef: RefObject<HTMLInputElement>;
embedded?: boolean;
quietComposer?: boolean;
isDragOver: boolean;
isUploading: boolean;
handleDragOver: (e: React.DragEvent) => void;
@@ -106,9 +107,18 @@ interface Props {
export const ChatInputView: React.FC<Props> = (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 (
<Box
ref={p.containerRef}
onFocusCapture={() => 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<Props> = (p) => {
<ChatInputToolbar
c={c}
restMode={restMode}
modeConf={p.modeConf}
modesArr={p.modesArr}
mode={p.mode}
@@ -13,6 +13,10 @@ import { sanitizeSvgString } from '@/shared/sanitizeSvg';
import { parseMcpToolName, getWorkflowToolLabel } from '@/shared/mcpToolMeta';
import ToolCallBubble, { ToolPair } from './ToolCallBubble';
export type ToolGroupEntry =
| { kind: 'pair'; pair: ToolPair }
| { kind: 'note'; id: string; text: string };
export interface ToolGroup {
type: 'tool_group';
id: string;
@@ -20,6 +24,8 @@ export interface ToolGroup {
label: string;
callCount: number;
mcpServer?: string;
/** Pairs interleaved with the folded mid-phase narration; present only when narration was absorbed. */
entries?: ToolGroupEntry[];
}
export type RenderItem = AgentMessage | ToolGroup | ToolPair;
@@ -75,7 +81,12 @@ const ToolGroupBubble: React.FC<Props> = 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<Props> = React.memo(({ group, isSessionRunning =
{/* Collapsed = the quiet "N tool calls " line; the detail card only materializes on expand. */}
{!expanded ? (
<Box
onClick={() => setExpanded(true)}
onClick={() => { userToggledRef.current = true; setExpanded(true); }}
sx={{
display: 'inline-flex',
alignItems: 'center',
@@ -154,7 +165,7 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
</Box>
) : (
<Box
onClick={() => setExpanded(false)}
onClick={() => { userToggledRef.current = true; setExpanded(false); }}
sx={{
display: 'flex',
alignItems: 'center',
@@ -228,16 +239,25 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
},
}}
>
{group.pairs.map((pair) => (
<ToolCallBubble
key={pair.id}
call={pair.call}
result={pair.result}
isPending={pair.result === null && isSessionRunning}
mcpCompact
sessionId={sessionId}
/>
))}
{(group.entries ?? group.pairs.map((pair) => ({ kind: 'pair' as const, pair }))).map((entry) =>
entry.kind === 'pair' ? (
<ToolCallBubble
key={entry.pair.id}
call={entry.pair.call}
result={entry.pair.result}
isPending={entry.pair.result === null && isSessionRunning}
mcpCompact
sessionId={sessionId}
/>
) : (
<Typography
key={entry.id}
sx={{ px: 1.5, py: 0.5, fontSize: '0.78rem', color: c.text.tertiary }}
>
{entry.text}
</Typography>
),
)}
</Box>
</Collapse>
</Box>
@@ -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 (
<div className={`tool-ui-scope${mode === 'dark' ? ' dark' : ''}`} style={{ width: 320 }}>
// 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.
<div className={`tool-ui-scope${mode === 'dark' ? ' dark' : ''}`} style={{ width: 384, maxWidth: '100%' }}>
<AnimatedWeatherWidget
version="3.1"
id={`weather-${props.location}`}
@@ -4,7 +4,7 @@ import { isToolUiComponent } from '@toolui/registry';
export interface WeatherForecastDay {
day: string;
condition?: string;
high: number;
high?: number;
low?: number;
}
@@ -113,12 +113,13 @@ function parseShowUiInput(input: unknown): ShowUiPayload | null {
if (!str(p.location) || !num(p.temp)) return null;
const forecast = Array.isArray(p.forecast)
? (p.forecast as Array<Record<string, unknown>>)
.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;
@@ -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<Props> = ({
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)) {
@@ -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 (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 1 }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 1,
'@keyframes osw-artifact-in': {
from: { opacity: 0, transform: 'translateY(8px) scale(0.98)' },
to: { opacity: 1, transform: 'translateY(0) scale(1)' },
},
'& .osw-artifact': {
animation: 'osw-artifact-in 320ms cubic-bezier(0.2, 0.8, 0.2, 1) both',
},
'@media (prefers-reduced-motion: reduce)': {
'& .osw-artifact': { animation: 'none' },
},
}}
>
<Box
sx={{
display: 'inline-flex',
@@ -53,9 +72,13 @@ function AgentNarratorPill({ label, running, todos, artifact, browserShot, selec
</Box>
{artifact ? (
<ShowUiWidgetView payload={artifact} />
<Box key={artifactKey} className="osw-artifact">
<ShowUiWidgetView payload={artifact} />
</Box>
) : browserShot ? (
<Box
key={artifactKey}
className="osw-artifact"
component="img"
src={browserShot}
alt=""
@@ -63,6 +86,8 @@ function AgentNarratorPill({ label, running, todos, artifact, browserShot, selec
/>
) : visibleTodos.length > 0 ? (
<Box
key={artifactKey}
className="osw-artifact"
sx={{
borderRadius: '16px',
background: GLASS,
@@ -123,6 +148,8 @@ function AgentNarratorPill({ label, running, todos, artifact, browserShot, selec
</Box>
) : running ? (
<Box
key={artifactKey}
className="osw-artifact"
sx={{
display: 'inline-flex',
alignItems: 'center',