mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 13:17:40 +02:00
[eric] redesign: widget strip+scene fix, finished-turn tool fold, rest composer, pill hydration
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user