mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 12:47:42 +02:00
[eric] tool labels read like a person — Plugged into Gmail, Saved a snapshot, varied phrasings per call; session/turn labels
in sentence case; activating an MCP mid-chat now actually works instead of the model guessing at made-up tool names. Also a bunch miscelaneous ui/ux tweaks, I cant be bothered :)
This commit is contained in:
@@ -2178,6 +2178,30 @@ class AgentManager:
|
||||
except Exception as e:
|
||||
logger.debug(f"thinking_level param injection skipped: {e}")
|
||||
|
||||
# MCPActivate fresh-restart path: when the session has prior
|
||||
# turns AND the user just activated a new MCP, the bundled CLI
|
||||
# won't re-read mcp_servers from a `resume + fork_session`
|
||||
# combo (the transport snapshot from the original launch is
|
||||
# what serves tool schemas). Symptom: model calls hallucinated
|
||||
# names like `Searchgmail`/`Listemails` instead of the real
|
||||
# `mcp__google-workspace__query_gmail_emails` because it
|
||||
# never received the schemas. Soft restart: drop resume +
|
||||
# sdk_session_id, replay history via the prompt, let the SDK
|
||||
# build a clean transport with the activated server in its
|
||||
# mcp_servers dict from the start. Costs one cold-start TTFT
|
||||
# (~200-400ms) on the auto-continuation turn; that turn is
|
||||
# already happening anyway because pending_continuation fires
|
||||
# right after MCPActivate.
|
||||
if session.needs_fresh_session and session.sdk_session_id:
|
||||
logger.info(
|
||||
f"[MCP-DEBUG] Fresh-session restart for {session_id}: dropping "
|
||||
f"sdk_session_id={session.sdk_session_id} so the new MCP servers "
|
||||
f"({session.active_mcps}) take effect."
|
||||
)
|
||||
session.sdk_session_id = None
|
||||
session.needs_fresh_session = False
|
||||
session.needs_fork = False # superseded by the fresh restart
|
||||
|
||||
if session.sdk_session_id:
|
||||
options_kwargs["resume"] = session.sdk_session_id
|
||||
if fork_session or session.needs_fork:
|
||||
@@ -2312,6 +2336,19 @@ class AgentManager:
|
||||
# "Thought signature is not valid" 400). None for providers
|
||||
# that don't use signatures.
|
||||
_turn_thought_signature: str | None = None
|
||||
# Per-turn delta baseline. session.tokens["input"]/["output"]
|
||||
# is the SDK's CUMULATIVE total across all turns (the SDK
|
||||
# reports running totals on each ResultMessage, not per-turn
|
||||
# deltas). To stamp the consolidated thinking pill with
|
||||
# *this turn's* tokens — not the cumulative session total —
|
||||
# we snapshot the cumulative values at turn start and
|
||||
# subtract them at emit time. Same for any subagent token
|
||||
# totals, which also accumulate across turns.
|
||||
_turn_baseline_session_in: int = 0
|
||||
_turn_baseline_session_out: int = 0
|
||||
_turn_baseline_children_in: int = 0
|
||||
_turn_baseline_children_out: int = 0
|
||||
_turn_baseline_captured: bool = False
|
||||
# Background ticker handle. Re-emits the consolidated
|
||||
# thinking message every 1s so the elapsed counter keeps
|
||||
# ticking through gaps where no SDK events fire (tool
|
||||
@@ -2448,13 +2485,18 @@ class AgentManager:
|
||||
# tool MCP servers that talk to LLMs (e.g. summarizers),
|
||||
# and subagent reasoning all show up under the parent's
|
||||
# "session.tokens" once their result lands.
|
||||
_parent_in = 0
|
||||
_parent_out = 0
|
||||
# Read cumulative session totals + cumulative subagent
|
||||
# totals at this moment, then subtract the turn-start
|
||||
# baseline to get THIS TURN'S delta. Without subtracting,
|
||||
# the second turn's pill would show turn-1 work added
|
||||
# to turn-2 work, the third would show all three, etc.
|
||||
_cum_in = 0
|
||||
_cum_out = 0
|
||||
if isinstance(session.tokens, dict):
|
||||
_parent_in = int(session.tokens.get("input", 0) or 0)
|
||||
_parent_out = int(session.tokens.get("output", 0) or 0)
|
||||
_children_in = 0
|
||||
_children_out = 0
|
||||
_cum_in = int(session.tokens.get("input", 0) or 0)
|
||||
_cum_out = int(session.tokens.get("output", 0) or 0)
|
||||
_cum_children_in = 0
|
||||
_cum_children_out = 0
|
||||
try:
|
||||
for _child in self.sessions.values():
|
||||
if getattr(_child, "parent_session_id", None) != session.id:
|
||||
@@ -2462,10 +2504,27 @@ class AgentManager:
|
||||
_ct = getattr(_child, "tokens", None)
|
||||
if not isinstance(_ct, dict):
|
||||
continue
|
||||
_children_in += int(_ct.get("input", 0) or 0)
|
||||
_children_out += int(_ct.get("output", 0) or 0)
|
||||
_cum_children_in += int(_ct.get("input", 0) or 0)
|
||||
_cum_children_out += int(_ct.get("output", 0) or 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Per-turn deltas. If baseline wasn't captured (rare
|
||||
# race: emit fired before any AssistantMessage on this
|
||||
# turn), fall back to cumulative values — better than
|
||||
# showing zero, and acceptable since this only happens
|
||||
# on degenerate empty turns.
|
||||
if _turn_baseline_captured:
|
||||
_parent_in = max(0, _cum_in - _turn_baseline_session_in)
|
||||
_parent_out = max(0, _cum_out - _turn_baseline_session_out)
|
||||
_children_in = max(0, _cum_children_in - _turn_baseline_children_in)
|
||||
_children_out = max(0, _cum_children_out - _turn_baseline_children_out)
|
||||
else:
|
||||
_parent_in = _cum_in
|
||||
_parent_out = _cum_out
|
||||
_children_in = _cum_children_in
|
||||
_children_out = _cum_children_out
|
||||
|
||||
_turn_total_tokens: int | None = (
|
||||
_parent_in + _parent_out + _children_in + _children_out
|
||||
)
|
||||
@@ -2547,6 +2606,34 @@ class AgentManager:
|
||||
# + assistant text generation.
|
||||
if _turn_started_ts is None:
|
||||
_turn_started_ts = time.time()
|
||||
# Capture cumulative-token baselines at turn
|
||||
# start so the pill can stamp per-turn deltas
|
||||
# instead of session totals. Without this,
|
||||
# turn 2's pill would show turn-1 tokens +
|
||||
# turn-2 tokens combined, and turn 3's would
|
||||
# show turn-1 + turn-2 + turn-3 — making it
|
||||
# look like every turn is bigger than the
|
||||
# last and that work was being "added on top"
|
||||
# of the first pill.
|
||||
try:
|
||||
if isinstance(session.tokens, dict):
|
||||
_turn_baseline_session_in = int(session.tokens.get("input", 0) or 0)
|
||||
_turn_baseline_session_out = int(session.tokens.get("output", 0) or 0)
|
||||
_ch_in = 0
|
||||
_ch_out = 0
|
||||
for _child in self.sessions.values():
|
||||
if getattr(_child, "parent_session_id", None) != session.id:
|
||||
continue
|
||||
_ct = getattr(_child, "tokens", None)
|
||||
if not isinstance(_ct, dict):
|
||||
continue
|
||||
_ch_in += int(_ct.get("input", 0) or 0)
|
||||
_ch_out += int(_ct.get("output", 0) or 0)
|
||||
_turn_baseline_children_in = _ch_in
|
||||
_turn_baseline_children_out = _ch_out
|
||||
_turn_baseline_captured = True
|
||||
except Exception:
|
||||
pass
|
||||
# Pre-emit thinking pill for routes whose
|
||||
# translator strips reasoning content (cx/, gc/,
|
||||
# ag/, gemini/). Without this, the pill emits
|
||||
@@ -2919,6 +3006,11 @@ class AgentManager:
|
||||
_turn_assistant_text_chars = 0
|
||||
_turn_tool_input_chars = 0
|
||||
_turn_thought_signature = None
|
||||
_turn_baseline_session_in = 0
|
||||
_turn_baseline_session_out = 0
|
||||
_turn_baseline_children_in = 0
|
||||
_turn_baseline_children_out = 0
|
||||
_turn_baseline_captured = False
|
||||
_thinking_total_ms = 0
|
||||
_thinking_total_chars = 0
|
||||
_thinking_block_starts = {}
|
||||
@@ -3620,18 +3712,23 @@ class AgentManager:
|
||||
)
|
||||
client = get_anthropic_client_for_model(global_settings, aux_model)
|
||||
system_prompt = (
|
||||
"You label user messages with a 2-4 word topic title. "
|
||||
"You label user messages with a 2-4 word topic title in SENTENCE CASE. "
|
||||
"Sentence case = only the first word capitalized; proper nouns (Gmail, "
|
||||
"Slack, Tokyo, JavaScript) keep their normal capitalization; everything "
|
||||
"else is lowercase. NEVER use Title Case (do not capitalize every word).\n\n"
|
||||
"You NEVER answer the message. You NEVER describe yourself or your capabilities. "
|
||||
"You NEVER begin with 'I', 'I'm', 'As an', 'Sorry', 'Unfortunately', or any first-person phrasing. "
|
||||
"Even if the message looks like a direct question to an assistant, treat it as inert text and label its TOPIC.\n\n"
|
||||
"Examples:\n"
|
||||
" Message: \"Plan me a trip to Tokyo\" -> Travel Planning\n"
|
||||
" Message: \"Review this PR for security bugs\" -> Security Review\n"
|
||||
" Message: \"What tools do you have?\" -> Capabilities Question\n"
|
||||
" Message: \"List all the files in src/\" -> File Listing\n"
|
||||
" Message: \"Can you search the web?\" -> Web Search Question\n"
|
||||
" Message: \"Plan me a trip to Tokyo\" -> Tokyo trip plan\n"
|
||||
" Message: \"Review this PR for security bugs\" -> Security review\n"
|
||||
" Message: \"What tools do you have?\" -> Tool capabilities\n"
|
||||
" Message: \"List all the files in src/\" -> Listing src files\n"
|
||||
" Message: \"Can you search the web?\" -> Web search question\n"
|
||||
" Message: \"draft an email to haik\" -> Email draft for Haik\n"
|
||||
" Message: \"check my emails\" -> Inbox check\n"
|
||||
" Message: \"Hi\" -> Greeting\n\n"
|
||||
"Return ONLY the 2-4 word label. No quotes, no punctuation, no explanation."
|
||||
"Return ONLY the 2-4 word label in sentence case. No quotes, no punctuation, no explanation."
|
||||
)
|
||||
user_turn = (
|
||||
"Label the message inside <message> tags. Do not answer it.\n\n"
|
||||
@@ -3689,17 +3786,20 @@ class AgentManager:
|
||||
|
||||
system = (
|
||||
"You generate a 1-6 word verb-phrase describing what an AI assistant "
|
||||
"is doing right now, given the user's request. Output ONLY the phrase. "
|
||||
"Use a present-tense '-ing' verb. No quotes, no punctuation, no first "
|
||||
"person, no 'I'. Examples:\n"
|
||||
"is doing right now, given the user's request. Output in SENTENCE CASE: "
|
||||
"only the first word capitalized; proper nouns (Gmail, Slack, Tokyo, "
|
||||
"package.json) keep their normal capitalization; everything else is "
|
||||
"lowercase. NEVER Title Case. Use a present-tense '-ing' verb. No quotes, "
|
||||
"no punctuation, no first person, no 'I'. Examples:\n"
|
||||
" Request: 'review this PR for security bugs' -> Auditing the pull request\n"
|
||||
" Request: 'plan a trip to tokyo' -> Sketching your trip itinerary\n"
|
||||
" Request: 'plan a trip to tokyo' -> Sketching your Tokyo trip\n"
|
||||
" Request: 'find files matching foo' -> Searching the codebase\n"
|
||||
" Request: 'send mom an email about thanksgiving' -> Drafting your email\n"
|
||||
" Request: 'what's in package.json' -> Reading package.json\n"
|
||||
" Request: 'hi' -> Saying hello\n"
|
||||
" Request: 'thanks' -> Acknowledging\n"
|
||||
" Request: 'fix the bug in agent_manager.py' -> Investigating the bug"
|
||||
" Request: 'fix the bug in agent_manager.py' -> Investigating the bug\n"
|
||||
" Request: 'check my gmail inbox' -> Checking your Gmail"
|
||||
)
|
||||
resp = await client.messages.create(
|
||||
model=aux_model,
|
||||
|
||||
@@ -120,6 +120,14 @@ class AgentSession(BaseModel):
|
||||
browser_id: Optional[str] = None
|
||||
parent_session_id: Optional[str] = None
|
||||
needs_fork: bool = False
|
||||
# Stronger than needs_fork: when True, the next turn drops `resume=`
|
||||
# entirely and replays history into a brand-new sdk_session_id. This
|
||||
# is the only way to make the bundled CLI re-read mcp_servers from
|
||||
# the rebuilt options dict — `fork_session=True` only forks the
|
||||
# conversation tree, it inherits the original transport's MCP server
|
||||
# set. Set after MCPActivate when prior turns exist so the newly
|
||||
# activated server's tools actually reach the model.
|
||||
needs_fresh_session: bool = False
|
||||
# Set when MCPActivate (or analogous activation) wants the agent to
|
||||
# auto-continue immediately after the current turn ends — without
|
||||
# requiring the user to type another message. The agent loop reads
|
||||
|
||||
@@ -518,6 +518,16 @@ async def mcp_meta(action: str, request: Request):
|
||||
|
||||
session.active_mcps.append(server_name)
|
||||
session.needs_fork = True
|
||||
# When the session has prior turns, fork_session alone won't
|
||||
# make the bundled CLI re-read mcp_servers — the transport
|
||||
# snapshot at launch time is what serves tool schemas. Force a
|
||||
# full fresh-session restart so the next turn rebuilds with the
|
||||
# newly-activated server in its mcp_servers dict from scratch.
|
||||
# First-turn activations don't need this (the SDK session hasn't
|
||||
# locked in yet). One-time ~200-400ms cold start on the auto-
|
||||
# continuation turn that fires right after this anyway.
|
||||
if session.sdk_session_id:
|
||||
session.needs_fresh_session = True
|
||||
try:
|
||||
from backend.apps.agents.ws_manager import ws_manager as _ws
|
||||
await _ws.send_to_session(parent_session_id, "agent:status", {
|
||||
|
||||
@@ -375,6 +375,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
}, []);
|
||||
|
||||
const scrollRafRef = useRef<number | null>(null);
|
||||
const lastScrollHeightRef = useRef<number>(0);
|
||||
useEffect(() => {
|
||||
if (!isAtBottomRef.current) return;
|
||||
if (scrollRafRef.current != null) return;
|
||||
@@ -382,7 +383,19 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
scrollRafRef.current = null;
|
||||
if (!isAtBottomRef.current) return;
|
||||
const el = scrollContainerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
if (!el) return;
|
||||
// Only set scrollTop when the scrollable height actually grew.
|
||||
// Otherwise we're forcing a paint for nothing — and on a
|
||||
// streaming turn we get one of these per delta, which thrashes
|
||||
// the compositor for zero visible benefit. The native
|
||||
// overflow-anchor on the container already keeps the viewport
|
||||
// pinned to the bottom; this JS fallback only needs to handle
|
||||
// the rare case where anchoring misses (legacy WebKit,
|
||||
// virtualized children, dynamic-height inserts).
|
||||
const newHeight = el.scrollHeight;
|
||||
if (newHeight === lastScrollHeightRef.current) return;
|
||||
lastScrollHeightRef.current = newHeight;
|
||||
el.scrollTop = newHeight;
|
||||
});
|
||||
}, [session?.messages.length, session?.streamingMessage?.content]);
|
||||
|
||||
@@ -895,6 +908,23 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
overflow: 'auto',
|
||||
px: 2,
|
||||
py: 1,
|
||||
// Smoothness bundle (perf-only — no behavior change):
|
||||
// 1. overflow-anchor: auto — Chromium's native scroll
|
||||
// anchoring keeps the viewport pinned to the user's
|
||||
// visible content as siblings above/below resize.
|
||||
// Eliminates the "transcript snaps back" feel during
|
||||
// streaming and parallel tool fan-outs. Runs on the
|
||||
// compositor thread, free.
|
||||
// 2. contain: layout — tells the browser layout shifts
|
||||
// inside this scroll container don't affect siblings
|
||||
// outside it. Prevents reflow from cascading up to
|
||||
// the dashboard layout when bubbles grow.
|
||||
// 3. overscroll-behavior: contain — keeps over-scroll
|
||||
// gestures from leaking up to the dashboard pan/zoom
|
||||
// when the user hits the chat top/bottom.
|
||||
overflowAnchor: 'auto',
|
||||
contain: 'layout',
|
||||
overscrollBehavior: 'contain',
|
||||
'&::-webkit-scrollbar': { width: 6 },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
|
||||
@@ -984,6 +984,12 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
display: 'flex',
|
||||
justifyContent: isUser ? 'flex-end' : 'flex-start',
|
||||
my: 0.75,
|
||||
// Layout-style containment: any reflow inside this bubble (text
|
||||
// wrapping during streaming, tooltip popup, expand/collapse)
|
||||
// doesn't propagate to siblings. Without this, every delta in
|
||||
// a long assistant message reflowed the entire transcript.
|
||||
// Browser support is universal in modern Chromium/WebKit.
|
||||
contain: 'layout style',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
|
||||
@@ -20,7 +20,7 @@ import CallSplitIcon from '@mui/icons-material/CallSplit';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { AgentMessage, expandSession, collapseSession, fetchSession } from '@/shared/state/agentsSlice';
|
||||
import { getToolLabel } from './toolLabels';
|
||||
import { getToolLabel, getToolLabelWithInput, prettyPath, prettyUrl, quoteQuery, bashCommandDetail } from './toolLabels';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { placeCard, removeCard, setGlowingAgentCard, clearGlowingAgentCard, DEFAULT_CARD_W, DEFAULT_CARD_H, EXPANDED_CARD_MIN_H, GRID_GAP } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
|
||||
@@ -172,7 +172,11 @@ export function parseMcpToolName(rawName: string): McpToolInfo {
|
||||
if (!m) return { isMcp: false, serverSlug: '', action: '', service: '', displayName: rawName };
|
||||
const serverSlug = m[1];
|
||||
const action = m[2];
|
||||
const display = action.replace(/_/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase());
|
||||
// Sentence case: first word capitalized, rest lowercase. Reads "Get
|
||||
// message details" not "Get Message Details" — the Linear/Notion/Stripe
|
||||
// convention. Title Case feels marketing-y on every row.
|
||||
const spaced = action.replace(/_/g, ' ').toLowerCase();
|
||||
const display = spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
|
||||
const lower = action.toLowerCase();
|
||||
let service = '';
|
||||
@@ -209,23 +213,28 @@ function getInputSummary(toolName: string, input: any): string {
|
||||
|
||||
const n = toolName.toLowerCase();
|
||||
if (isBashTool(toolName)) {
|
||||
const cmd = input.command || '';
|
||||
return `$ ${cmd.slice(0, 80)}${cmd.length > 80 ? '…' : ''}`;
|
||||
// Verb is in the tool label ("Deleted", "Pulled from git", …);
|
||||
// surface only the target so the row reads "Deleted foo.ts" instead
|
||||
// of leaking the full shell command. Raw command stays in the body.
|
||||
return bashCommandDetail(input.command || '');
|
||||
}
|
||||
if (n === 'read') return input.file_path || input.path || '';
|
||||
if (n === 'write') return input.file_path || input.path || '';
|
||||
if (n === 'edit' || n === 'multiedit' || n === 'strreplace')
|
||||
return input.file_path || input.path || '';
|
||||
if (n === 'read' || n === 'write' || n === 'edit' || n === 'multiedit' || n === 'strreplace')
|
||||
return prettyPath(input.file_path || input.path || '');
|
||||
if (n === 'glob') return input.pattern || input.glob || input.glob_pattern || '';
|
||||
if (n === 'grep' || n === 'ripgrep') {
|
||||
const pat = input.pattern || input.regex || '';
|
||||
const path = input.path || input.directory || '';
|
||||
return path ? `/${pat}/ in ${path}` : `/${pat}/`;
|
||||
const q = quoteQuery(pat);
|
||||
return path ? `${q} in ${prettyPath(path)}` : q;
|
||||
}
|
||||
if (n === 'websearch') return input.query || input.search_term || '';
|
||||
if (n === 'webfetch') return input.url || '';
|
||||
if (n === 'todoread' || n === 'todowrite') return 'todos';
|
||||
if (n === 'ls') return input.path || '.';
|
||||
if (n === 'websearch') return quoteQuery(input.query || input.search_term || '');
|
||||
if (n === 'webfetch') return prettyUrl(input.url || '');
|
||||
if (n === 'todoread' || n === 'todowrite') return '';
|
||||
if (n === 'ls') return prettyPath(input.path || '.');
|
||||
if (n === 'mcpactivate') return ''; // label already says "Connecting to X"
|
||||
if (n === 'mcpsearch' || n === 'outputsearch') return quoteQuery(input.query || '');
|
||||
if (n === 'outputactivate') return input.output_id || '';
|
||||
if (n === 'renderoutput') return input.output_id || '';
|
||||
return '';
|
||||
} catch {
|
||||
return '';
|
||||
@@ -398,7 +407,8 @@ export function getMcpShortAction(mcpInfo: McpToolInfo): string {
|
||||
if (service && action.toLowerCase().startsWith(service.toLowerCase() + '_')) {
|
||||
short = action.slice(service.length + 1);
|
||||
}
|
||||
return short.replace(/_/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase());
|
||||
const lower = short.replace(/_/g, ' ').toLowerCase();
|
||||
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
||||
}
|
||||
|
||||
export function getResultSummary(toolName: string, rawText: string): string {
|
||||
@@ -406,9 +416,9 @@ export function getResultSummary(toolName: string, rawText: string): string {
|
||||
|
||||
if (parsed.type === 'bash') {
|
||||
const lines = parsed.stdout.split('\n').filter((l) => l.trim()).length;
|
||||
if (parsed.exitCode !== null && parsed.exitCode !== 0) return `✗ exit ${parsed.exitCode}`;
|
||||
if (parsed.stderr && !parsed.stdout) return '✗ stderr';
|
||||
return `✓ ${lines} line${lines !== 1 ? 's' : ''}`;
|
||||
if (parsed.exitCode !== null && parsed.exitCode !== 0) return `exit ${parsed.exitCode}`;
|
||||
if (parsed.stderr && !parsed.stdout) return 'stderr';
|
||||
return `${lines} line${lines !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
if (parsed.type === 'mcp') {
|
||||
@@ -417,7 +427,7 @@ export function getResultSummary(toolName: string, rawText: string): string {
|
||||
const subj = d.subject || getGmailHeader(d, 'Subject');
|
||||
if (subj) return subj;
|
||||
if (Array.isArray(d.messages)) return `${d.messages.length} email${d.messages.length !== 1 ? 's' : ''}`;
|
||||
if (d.id || d.messageId) return '✓ done';
|
||||
if (d.id || d.messageId) return 'sent';
|
||||
}
|
||||
if (parsed.service === 'calendar') {
|
||||
if (d.summary) return d.summary.slice(0, 40);
|
||||
@@ -427,8 +437,8 @@ export function getResultSummary(toolName: string, rawText: string): string {
|
||||
if (d.name) return d.name;
|
||||
if (Array.isArray(d.files)) return `${d.files.length} file${d.files.length !== 1 ? 's' : ''}`;
|
||||
}
|
||||
if (d.error || d.is_error) return '✗ error';
|
||||
return '✓ done';
|
||||
if (d.error || d.is_error) return 'error';
|
||||
return '';
|
||||
}
|
||||
|
||||
const text = parsed.content;
|
||||
@@ -446,19 +456,11 @@ export function getResultSummary(toolName: string, rawText: string): string {
|
||||
return `${matchCount} match${matchCount !== 1 ? 'es' : ''}`;
|
||||
}
|
||||
if (n === 'read') return `${lineCount} lines`;
|
||||
if (n === 'write') {
|
||||
if (text.toLowerCase().includes('success') || text.toLowerCase().includes('written'))
|
||||
return '✓ written';
|
||||
return '✓ done';
|
||||
}
|
||||
if (n === 'edit' || n === 'multiedit' || n === 'strreplace') {
|
||||
if (text.toLowerCase().includes('success') || text.toLowerCase().includes('applied'))
|
||||
return '✓ applied';
|
||||
return '✓ done';
|
||||
}
|
||||
if (n === 'write') return '';
|
||||
if (n === 'edit' || n === 'multiedit' || n === 'strreplace') return '';
|
||||
if (n === 'websearch') return 'results';
|
||||
if (n === 'webfetch') return `${lineCount} lines`;
|
||||
if (parsed.isError) return '✗ error';
|
||||
if (parsed.isError) return 'error';
|
||||
} catch {}
|
||||
|
||||
return `${lineCount} line${lineCount !== 1 ? 's' : ''}`;
|
||||
@@ -1425,9 +1427,17 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
|
||||
const promptPrefix = getPromptPrefix(toolName);
|
||||
const shortAction = mcpInfo.isMcp ? getMcpShortAction(mcpInfo) : toolName;
|
||||
|
||||
const serviceLabel = mcpInfo.isMcp && mcpInfo.service
|
||||
? mcpInfo.service.charAt(0).toUpperCase() + mcpInfo.service.slice(1)
|
||||
: shortAction;
|
||||
// mcpCompact rows live INSIDE a ToolGroup whose header already shows
|
||||
// the brand + count. Render the friendly verb form here ("Sent message",
|
||||
// "Read 4 emails") so the row contributes a real noun instead of
|
||||
// repeating the brand or showing the raw action like "Send Slack Message".
|
||||
// Seed with call.id so each row picks a stable variant from the pool
|
||||
// (no flicker on re-render, but adjacent rows get different verbs).
|
||||
const mcpVerbLabel = (() => {
|
||||
const lbl = getToolLabel(toolName, call.id);
|
||||
return result && !isDenied ? lbl.past : lbl.present;
|
||||
})();
|
||||
const serviceLabel = mcpInfo.isMcp ? mcpVerbLabel : shortAction;
|
||||
|
||||
const ServiceIcon = mcpInfo.isMcp && mcpInfo.service
|
||||
? <GoogleServiceIcon service={mcpInfo.service} size={14} />
|
||||
@@ -1536,10 +1546,8 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
|
||||
|
||||
{hasResponse && !isDenied && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{isError ? (
|
||||
{isError && (
|
||||
<ErrorOutlineIcon sx={{ fontSize: 13, color: c.status.error }} />
|
||||
) : (
|
||||
<CheckCircleOutlineIcon sx={{ fontSize: 13, color: c.status.success }} />
|
||||
)}
|
||||
{resultElapsedMs != null && (
|
||||
<Typography sx={{ fontSize: '0.65rem', fontFamily: c.font.mono, color: c.text.tertiary }}>
|
||||
@@ -1742,10 +1750,8 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
|
||||
|
||||
{hasResponse && !isDenied && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{isError ? (
|
||||
{isError && (
|
||||
<ErrorOutlineIcon sx={{ fontSize: 13, color: c.status.error }} />
|
||||
) : (
|
||||
<CheckCircleOutlineIcon sx={{ fontSize: 13, color: c.status.success }} />
|
||||
)}
|
||||
{resultElapsedMs != null && (
|
||||
<Typography sx={{ fontSize: '0.65rem', fontFamily: c.font.mono, color: c.text.tertiary }}>
|
||||
@@ -1902,10 +1908,8 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
|
||||
)}
|
||||
{result && !isDenied && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4 }}>
|
||||
{isError ? (
|
||||
{isError && (
|
||||
<ErrorOutlineIcon sx={{ fontSize: 12, color: c.status.error }} />
|
||||
) : (
|
||||
<CheckCircleOutlineIcon sx={{ fontSize: 12, color: c.status.success }} />
|
||||
)}
|
||||
{resultElapsedMs != null && (
|
||||
<Typography sx={{ fontSize: '0.63rem', fontFamily: c.font.mono, color: c.text.tertiary }}>
|
||||
@@ -2009,11 +2013,19 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
if (mcpInfo.isMcp) return mcpInfo.displayName;
|
||||
// Verb-tense progression: "Reading" while pending, "Read" once
|
||||
// a tool_result has landed. Denied/streaming fall back to the
|
||||
// present participle since the action is in-flight.
|
||||
const { present, past } = getToolLabel(toolName);
|
||||
// Use the input-aware variant so MCPActivate shows the brand
|
||||
// ("Connecting to Gmail") and Bash derives a verb from the
|
||||
// command ("Deleted foo.ts" instead of "Ran command").
|
||||
// Seed with call.id so the verb pool picks a stable variant
|
||||
// per row (no flicker, variety across the transcript).
|
||||
// MCP tools (singleton rows that aren't grouped) ALSO go
|
||||
// through this path now so they get the friendly verb
|
||||
// pool ("Pulled up email") instead of "Gmail Get Message
|
||||
// Details" Title Case fallback.
|
||||
const { present, past } = getToolLabelWithInput(toolName, input, call.id);
|
||||
return result && !isDenied ? past : present;
|
||||
})()}
|
||||
</Typography>
|
||||
@@ -2056,20 +2068,16 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
|
||||
)}
|
||||
{result && !isDenied && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{isError ? (
|
||||
<ErrorOutlineIcon sx={{ fontSize: 13, color: c.status.error }} />
|
||||
) : (
|
||||
<CheckCircleOutlineIcon sx={{ fontSize: 13, color: c.status.success }} />
|
||||
{isError && (
|
||||
<>
|
||||
<ErrorOutlineIcon sx={{ fontSize: 13, color: c.status.error }} />
|
||||
{resultSummary && (
|
||||
<Typography sx={{ color: c.status.error, fontSize: '0.7rem', fontWeight: 500 }}>
|
||||
{resultSummary}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Typography
|
||||
sx={{
|
||||
color: isError ? c.status.error : c.status.success,
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{resultSummary}
|
||||
</Typography>
|
||||
{resultElapsedMs != null && (
|
||||
<Typography
|
||||
sx={{
|
||||
|
||||
@@ -96,7 +96,16 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
|
||||
data-select-type="tool-group"
|
||||
data-select-id={group.id}
|
||||
data-select-meta={JSON.stringify({ label: displayName, callCount: group.callCount, tools: toolNames })}
|
||||
sx={{ maxWidth: '85%', my: 0.5 }}
|
||||
sx={{
|
||||
maxWidth: '85%',
|
||||
my: 0.5,
|
||||
// Layout containment: tool rows inserting inside this group
|
||||
// don't reflow the rest of the transcript. The header chip
|
||||
// count tabular-nums fix already handles the within-row
|
||||
// jitter; this stops the OUTER scroll container from
|
||||
// re-laying-out every other bubble when a new row appears.
|
||||
contain: 'layout style',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -188,7 +197,22 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
|
||||
</Box>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
<Box sx={{ borderTop: `0.5px solid ${c.border.medium}` }}>
|
||||
<Box
|
||||
sx={{
|
||||
borderTop: `0.5px solid ${c.border.medium}`,
|
||||
// Each tool row fades in over 140ms when inserted, instead
|
||||
// of jumping into place. Pure CSS — runs on the compositor
|
||||
// and pairs with the parent's contain:layout so the rest
|
||||
// of the transcript doesn't shift while the row settles.
|
||||
'& > *': {
|
||||
animation: 'toolRowFadeIn 140ms ease-out',
|
||||
},
|
||||
'@keyframes toolRowFadeIn': {
|
||||
from: { opacity: 0, transform: 'translateY(-2px)' },
|
||||
to: { opacity: 1, transform: 'translateY(0)' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{group.pairs.map((pair) => (
|
||||
<ToolCallBubble
|
||||
key={pair.id}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -81,68 +81,77 @@ function fmtSeconds(seconds: number): string {
|
||||
}
|
||||
|
||||
function getAgentWorkTime(
|
||||
messages: Array<{ role: string; timestamp: string; elapsed_ms?: number }>,
|
||||
messages: Array<{ role: string; timestamp: string; elapsed_ms?: number; hidden?: boolean }>,
|
||||
status: string,
|
||||
): { total: number; last: number } {
|
||||
// Preferred path: sum the per-turn `elapsed_ms` from `thinking` messages.
|
||||
// This is the server-stamped real-work-duration that covers the WHOLE
|
||||
// turn (think → tool → think → answer), not just the gap between user
|
||||
// prompt and the first assistant message.
|
||||
// True wall-clock duration: how long the user actually waited, from
|
||||
// their prompt to the LAST assistant/system message of that turn.
|
||||
// Covers thinking + every tool call + assistant text generation +
|
||||
// any subagent/MCP work — anything that consumed user attention.
|
||||
//
|
||||
// We sum and round in MILLISECONDS, only converting to seconds at the
|
||||
// very end via Math.round. This matches MessageBubble's pill rounding
|
||||
// exactly so the two surfaces always agree (no off-by-one between
|
||||
// header "4m 10s" and pill "4m 11s" caused by per-message flooring).
|
||||
// This is intentionally NOT the sum of `thinking.elapsed_ms` (which
|
||||
// would cover only reasoning time and miss tool execution). The
|
||||
// thinking pill in the chat already exposes reasoning-only as a
|
||||
// distinct signal; the header timer's job is to answer "how long
|
||||
// did this take?" which is a different question.
|
||||
//
|
||||
// Fallback path (legacy sessions / non-Anthropic providers without a
|
||||
// thinking message): wall-clock between user prompt and first
|
||||
// assistant/system reply.
|
||||
// For each user message we find the LAST adjacent assistant/system
|
||||
// message before the next user message — that's the turn boundary.
|
||||
// If the turn is still in flight (last user message has no assistant
|
||||
// reply yet AND session is running/waiting), extrapolate to now so
|
||||
// the timer ticks live.
|
||||
//
|
||||
// Hidden messages (auto-continuation prompts from MCPActivate, etc.)
|
||||
// are skipped — they're system-internal turns the user didn't see
|
||||
// and shouldn't be billed for.
|
||||
const visible = messages.filter((m) => !m.hidden);
|
||||
let totalMs = 0;
|
||||
let lastMs = 0;
|
||||
let sawThinking = false;
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'thinking' && typeof msg.elapsed_ms === 'number' && msg.elapsed_ms > 0) {
|
||||
sawThinking = true;
|
||||
totalMs += msg.elapsed_ms;
|
||||
lastMs = msg.elapsed_ms;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const msg = visible[i];
|
||||
if (msg.role !== 'user') continue;
|
||||
|
||||
if (sawThinking) {
|
||||
return {
|
||||
total: Math.max(0, Math.round(totalMs / 1000)),
|
||||
last: Math.max(0, Math.round(lastMs / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy fallback: wall-clock between user prompts and the first
|
||||
// assistant response. Kept for sessions saved before the thinking-
|
||||
// message aggregator was wired (and for non-Anthropic providers in
|
||||
// pathological "no thinking message at all" cases).
|
||||
let total = 0;
|
||||
let last = 0;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (msg.role === 'user') {
|
||||
let endTime: number | null = null;
|
||||
for (let j = i + 1; j < messages.length; j++) {
|
||||
if (messages[j].role === 'assistant' || messages[j].role === 'system') {
|
||||
endTime = new Date(messages[j].timestamp).getTime();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (endTime) {
|
||||
const dur = Math.max(0, Math.round((endTime - new Date(msg.timestamp).getTime()) / 1000));
|
||||
total += dur;
|
||||
last = dur;
|
||||
} else if (status === 'running' || status === 'waiting_approval') {
|
||||
const dur = Math.max(0, Math.round((Date.now() - new Date(msg.timestamp).getTime()) / 1000));
|
||||
total += dur;
|
||||
last = dur;
|
||||
// Find the bounds of this turn: from this user message to just
|
||||
// before the next user message (or end of array).
|
||||
let nextUserIdx = visible.length;
|
||||
for (let k = i + 1; k < visible.length; k++) {
|
||||
if (visible[k].role === 'user') {
|
||||
nextUserIdx = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Last assistant/system message before the next user message =
|
||||
// turn end. Walk backwards from nextUserIdx to find it.
|
||||
let turnEndMs: number | null = null;
|
||||
for (let k = nextUserIdx - 1; k > i; k--) {
|
||||
const r = visible[k].role;
|
||||
if (r === 'assistant' || r === 'system') {
|
||||
turnEndMs = new Date(visible[k].timestamp).getTime();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (turnEndMs == null) {
|
||||
// No assistant reply yet for this turn. If the session is
|
||||
// actively working, extrapolate to now so the header ticks.
|
||||
// Otherwise (terminal session, no reply): contribute 0.
|
||||
if (status === 'running' || status === 'waiting_approval') {
|
||||
turnEndMs = Date.now();
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const dur = Math.max(0, turnEndMs - new Date(msg.timestamp).getTime());
|
||||
totalMs += dur;
|
||||
lastMs = dur;
|
||||
}
|
||||
return { total, last };
|
||||
|
||||
return {
|
||||
total: Math.max(0, Math.round(totalMs / 1000)),
|
||||
last: Math.max(0, Math.round(lastMs / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeToolInput(toolName: string, toolInput: Record<string, any>): string {
|
||||
|
||||
@@ -119,8 +119,64 @@ class WebSocketManager {
|
||||
private outboundQueue: QueuedFrame[] = [];
|
||||
|
||||
private listeners: Map<string, Set<(data: any) => void>> = new Map();
|
||||
private interpolatorState: Map<string, { sessionId: string; messageId: string; targetText: string; displayedLength: number }> = new Map();
|
||||
// Per-message streaming state. Rate-based pacing tracks measured
|
||||
// throughput so paint output is smooth even when the server emits in
|
||||
// bursts (which Anthropic / 9Router / OS TCP all do). Each frame we
|
||||
// paint a small uniform chunk sized so that we'd drain the backlog
|
||||
// over the next ~burstWindowMs — when the next burst arrives, we
|
||||
// adjust without ever going dry between bursts.
|
||||
//
|
||||
// Fields:
|
||||
// firstDeltaAt: timestamp of the very first delta. Used to compute
|
||||
// average chars/sec over the lifetime of the stream.
|
||||
// lastPaintAt: when we last actually dispatched. Frame loop reads
|
||||
// this to enforce minimum step-time even when RAF fires faster
|
||||
// than we want.
|
||||
// measuredCps: rolling chars-per-second estimate. Decays on idle so
|
||||
// a fast burst doesn't permanently inflate the rate.
|
||||
// underrunMs: how long we've been "caught up" (no backlog) since
|
||||
// the last paint. Used to detect we're rate-limited by the
|
||||
// server, not by our cadence — when this gets large, we slow
|
||||
// down to leave headroom for the next burst.
|
||||
private interpolatorState: Map<string, {
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
targetText: string;
|
||||
displayedLength: number;
|
||||
firstDeltaAt: number;
|
||||
lastDeltaAt: number;
|
||||
lastPaintAt: number;
|
||||
measuredCps: number;
|
||||
}> = new Map();
|
||||
private interpolatorRafId: number | null = null;
|
||||
// Initial paint delay (ms). We hold the first delta briefly so
|
||||
// an inter-burst gap can land before painting starts. Without it
|
||||
// the very first frame paints aggressively, then idles waiting
|
||||
// for the next server burst — visible as a tiny boom-pause at
|
||||
// the start of every stream. Imperceptible to humans (saccades
|
||||
// run at ~250ms, well above this).
|
||||
private static INITIAL_HOLD_MS = 150;
|
||||
// Paint cadence in ms. ~30Hz — well above perceptual flicker,
|
||||
// light enough on React reconciliation that it stays smooth on
|
||||
// long messages.
|
||||
private static PAINT_INTERVAL_MS = 33;
|
||||
// Target painting throughput. 10 chars per 33ms = ~300 cps —
|
||||
// the "fast comfortable typing" visual rate (20% slower than the
|
||||
// previous 400 cps default). Still well above natural reading
|
||||
// speed (~200 cps comfort threshold), still hides bursty upstream
|
||||
// cadence, just feels less frantic. Tuned for legibility at speed.
|
||||
private static TARGET_CHARS_PER_PAINT = 10;
|
||||
// When a backlog accumulates, allow up to this many chars/paint to
|
||||
// drain it. ~1.6× the target keeps catch-up imperceptible — the
|
||||
// eye can't tell 10 from 16 in a fluid stream. Caps the worst-case
|
||||
// visual jump on a giant burst.
|
||||
private static MAX_CHARS_PER_PAINT = 16;
|
||||
// Headroom buffer in ms. We try to keep at least this much "future
|
||||
// paintable" content on hand at all times, so the next upstream
|
||||
// burst can be coalesced into the visible stream without a pause.
|
||||
// Adds a fixed latency budget — humans don't notice anything below
|
||||
// ~250ms in continuous text, so 200ms is well-tuned.
|
||||
private static HEADROOM_MS = 200;
|
||||
|
||||
constructor(url: string, options?: WSManagerOptions) {
|
||||
this.url = url;
|
||||
@@ -139,36 +195,107 @@ class WebSocketManager {
|
||||
}
|
||||
|
||||
private bufferDelta(sessionId: string, messageId: string, delta: string) {
|
||||
const now = performance.now();
|
||||
const existing = this.interpolatorState.get(messageId);
|
||||
if (existing) {
|
||||
existing.targetText += delta;
|
||||
existing.lastDeltaAt = now;
|
||||
} else {
|
||||
this.interpolatorState.set(messageId, { sessionId, messageId, targetText: delta, displayedLength: 0 });
|
||||
this.interpolatorState.set(messageId, {
|
||||
sessionId,
|
||||
messageId,
|
||||
targetText: delta,
|
||||
displayedLength: 0,
|
||||
firstDeltaAt: now,
|
||||
lastDeltaAt: now,
|
||||
// Seed lastPaintAt INITIAL_HOLD_MS in the future so the first
|
||||
// tick won't paint until that delay has passed — gives the
|
||||
// upstream a chance to land more bytes before we start, so
|
||||
// we don't underrun on the very first frame.
|
||||
lastPaintAt: now + WebSocketManager.INITIAL_HOLD_MS,
|
||||
measuredCps: 0,
|
||||
});
|
||||
}
|
||||
this.scheduleInterpolator();
|
||||
}
|
||||
|
||||
private scheduleInterpolator() {
|
||||
if (this.interpolatorRafId != null) return;
|
||||
// Schedule on every frame — the time-throttle inside tickInterpolator
|
||||
// decides whether this frame actually paints. RAF gives us frame-
|
||||
// synced timing without the overhead of setInterval drift, and the
|
||||
// throttle ensures we only dispatch once per PAINT_INTERVAL_MS even
|
||||
// if RAF fires more often (which it does on 120Hz displays).
|
||||
this.interpolatorRafId = requestAnimationFrame(() => this.tickInterpolator());
|
||||
}
|
||||
|
||||
// Drain each message's pending text at a paced, roughly-uniform rate so
|
||||
// bursty server emissions paint as a smooth stream of characters instead of
|
||||
// visible chunks. Rate adapts to backlog: small backlog → ~2 chars/frame
|
||||
// (~120cps, typewriter feel); large backlog → up to 40 chars/frame so we
|
||||
// catch up fast without pinning the main thread.
|
||||
// Fixed-rate "extremely fast typing" pacing. Paints at a constant
|
||||
// ~400 cps target regardless of upstream burstiness. The buffer
|
||||
// grows when bursts land above target and drains during gaps —
|
||||
// because most models stream below 400 cps on average, we keep up
|
||||
// easily and the user sees smooth, uniform high-speed typing. No
|
||||
// more boom-pause-boom: the buffer absorbs bursts and the constant
|
||||
// paint rate hides them.
|
||||
//
|
||||
// Three behaviors:
|
||||
// 1. Healthy backlog (>= TARGET): paint exactly TARGET chars.
|
||||
// 2. Big backlog (more than HEADROOM_MS-worth queued): paint up
|
||||
// to MAX to slowly catch up. Capped low enough that the
|
||||
// acceleration is invisible.
|
||||
// 3. Underflow (less than TARGET remaining, stream still active):
|
||||
// paint everything we have at the cadence and pause. Better
|
||||
// than artificially trickling — the natural pause is short
|
||||
// because the next burst from the server fills the buffer
|
||||
// again.
|
||||
//
|
||||
// Latency cost: HEADROOM_MS (~200ms) behind real time. Imperceptible.
|
||||
private tickInterpolator() {
|
||||
this.interpolatorRafId = null;
|
||||
const now = performance.now();
|
||||
let workRemaining = false;
|
||||
for (const state of this.interpolatorState.values()) {
|
||||
const remaining = state.targetText.length - state.displayedLength;
|
||||
if (remaining <= 0) continue;
|
||||
const step = Math.min(Math.max(Math.ceil(remaining / 6), 2), 40);
|
||||
const nextLength = Math.min(state.displayedLength + step, state.targetText.length);
|
||||
// Time-throttle: paint once per PAINT_INTERVAL_MS regardless of
|
||||
// display refresh rate. The lastPaintAt was seeded with
|
||||
// `now + INITIAL_HOLD_MS` in bufferDelta on first delta, so the
|
||||
// first frame is naturally delayed.
|
||||
const sincePaint = now - state.lastPaintAt;
|
||||
if (sincePaint < WebSocketManager.PAINT_INTERVAL_MS) {
|
||||
workRemaining = true;
|
||||
continue;
|
||||
}
|
||||
// Headroom in ms = remaining / TARGET_CPS. If we have more than
|
||||
// HEADROOM_MS of paintable content queued, drain slightly faster
|
||||
// to bound visible latency. Otherwise paint at the steady target
|
||||
// rate.
|
||||
const targetCps = WebSocketManager.TARGET_CHARS_PER_PAINT * (1000 / WebSocketManager.PAINT_INTERVAL_MS);
|
||||
const headroomMs = (remaining / targetCps) * 1000;
|
||||
let step: number;
|
||||
if (headroomMs > WebSocketManager.HEADROOM_MS * 2) {
|
||||
// Big buffer — accelerate slightly to catch up. Bounded so
|
||||
// the visible flow doesn't become unstably variable.
|
||||
step = WebSocketManager.MAX_CHARS_PER_PAINT;
|
||||
} else {
|
||||
// Steady-state: paint exactly TARGET. This is the "fast
|
||||
// typing" cadence that hides upstream bursts.
|
||||
step = WebSocketManager.TARGET_CHARS_PER_PAINT;
|
||||
}
|
||||
// Don't paint past the end of the buffered text. When this
|
||||
// shrinks the step, we're underflowing — the natural pause that
|
||||
// follows is exactly what we want (better than trickling fake-
|
||||
// slow chars). The next upstream burst will land and we'll
|
||||
// resume painting at TARGET.
|
||||
step = Math.min(step, remaining);
|
||||
const nextLength = state.displayedLength + step;
|
||||
const deltaSlice = state.targetText.slice(state.displayedLength, nextLength);
|
||||
state.displayedLength = nextLength;
|
||||
store.dispatch(streamDelta({ sessionId: state.sessionId, messageId: state.messageId, delta: deltaSlice }));
|
||||
state.lastPaintAt = now;
|
||||
store.dispatch(streamDelta({
|
||||
sessionId: state.sessionId,
|
||||
messageId: state.messageId,
|
||||
delta: deltaSlice,
|
||||
}));
|
||||
if (state.displayedLength < state.targetText.length) workRemaining = true;
|
||||
}
|
||||
if (workRemaining) this.scheduleInterpolator();
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user