@@ -318,6 +321,7 @@ export function ToolCallRow({ tool, isStreaming }: ToolCallRowProps) {
name: s.name || 'tool',
displayName: s.displayName,
input: s.input,
+ inputText: s.inputText,
output: s.output,
status: s.status,
}}
@@ -346,7 +350,7 @@ export function ToolCallRow({ tool, isStreaming }: ToolCallRowProps) {
)}
- {(liveInputView || hasOutput) && (
+ {(liveInputView || liveArgumentText || hasOutput) && (
{expanded && expandedBody}
diff --git a/src/frontend/src/components/tool/ToolRunShell.tsx b/src/frontend/src/components/tool/ToolRunShell.tsx
index c1bd1cd0..9717c376 100644
--- a/src/frontend/src/components/tool/ToolRunShell.tsx
+++ b/src/frontend/src/components/tool/ToolRunShell.tsx
@@ -31,8 +31,8 @@ function formatDuration(totalSec: number): string {
/**
* Total-elapsed counter for a step batch.
*
- * While running it ticks live from the batch start (the model does not stream
- * tool args, so a wall-clock counter is the only progress signal available).
+ * While running it ticks live from the batch start alongside streamed tool
+ * arguments and results when the selected model/provider exposes them.
* Once done it shows a stable span derived from the first→last tool
* timestamps, so a reloaded/historical message renders the same value every
* time instead of drifting with a frozen wall clock.
diff --git a/src/frontend/src/hooks/chatStream.ts b/src/frontend/src/hooks/chatStream.ts
index 3f91c282..e6adbdb3 100644
--- a/src/frontend/src/hooks/chatStream.ts
+++ b/src/frontend/src/hooks/chatStream.ts
@@ -19,7 +19,8 @@ import type { ChatItem, ChatMessage, CitationItem, EvolutionSummary, MessageSegm
*
* Send, regenerate/edit-resend, reconnect replay (follow), batch cancel-and-resume, and
* autonomous loop (loop start/resume/follow) **all** go through this one processor: the same
- * event vocabulary (content/thinking/tool_call/tool_result/meta/…), the same stripping
+ * event vocabulary (content/thinking/tool_call_start/tool_call_delta/tool_call/tool_result/meta/…),
+ * the same stripping
* state machine, and the same bubble rendering pipeline.
*
* Path-specific events (e.g. the autonomous loop's loop_started/loop_plan/…) are intercepted via
@@ -56,8 +57,8 @@ function applyDesignPickEvent(chatId: string, obj: Record) {
}
/**
- * Handle one `subagent_event`: attach the sub-agent's internal thinking/tool_call/tool_result/
- * content sub-steps under the call_subagent tool card that spawned it.
+ * Handle one `subagent_event`: attach the sub-agent's internal thinking/tool_call_delta/
+ * tool_call/tool_result/content sub-steps under the call_subagent tool card that spawned it.
*
* Association prefers the backend-provided `parent_tool_id` (ActingToolCallIdMiddleware
* guarantees accuracy); when missing, falls back to "the most recent call_subagent card".
@@ -96,6 +97,14 @@ function applySubagentEvent(toolCalls: ToolCall[], eo: Record):
const input = (eo.input === null || eo.input === undefined) ? undefined : eo.input;
// No status included → an existing matched step keeps its status (success/error is not reset to running)
upsertToolStep(norm(eo.tool_id), norm(eo.tool_name), input !== undefined ? { input } : {}, 'running');
+ } else if (subType === 'tool_call_delta') {
+ const delta = norm(eo.arguments_delta);
+ if (delta) {
+ const tid = norm(eo.tool_id);
+ const si = tid ? steps.findIndex((x) => x.kind === 'tool' && x.toolId === tid) : -1;
+ const inputText = (si >= 0 ? steps[si].inputText || '' : '') + delta;
+ upsertToolStep(tid, norm(eo.tool_name), { inputText }, 'running');
+ }
} else if (subType === 'tool_result') {
const status: SubagentStep['status'] = norm(eo.status) === 'error' ? 'error' : 'success';
const patch: Partial = { status };
@@ -724,7 +733,7 @@ export async function processChatStream(resp: Response, opts: ChatStreamOptions)
return;
}
- if (ontologyRevisionTool && (eventType === 'tool_use' || eventType === 'tool_call' || eventType === 'tool_start')) {
+ if (ontologyRevisionTool && (eventType === 'tool_use' || eventType === 'tool_call_start' || eventType === 'tool_call' || eventType === 'tool_start')) {
const revision = ensureOntologyRevision();
revision.toolPending = false;
const eventToolId = getEventToolId(eventObj);
@@ -755,6 +764,34 @@ export async function processChatStream(resp: Response, opts: ChatStreamOptions)
return;
}
+ if (ontologyRevisionTool && eventType === 'tool_call_delta') {
+ const revision = ensureOntologyRevision();
+ revision.toolPending = false;
+ const eventToolId = getEventToolId(eventObj);
+ const delta = typeof eventObj.arguments_delta === 'string' ? eventObj.arguments_delta : '';
+ const index = eventToolId
+ ? revision.toolCalls.findIndex((tool) => normalizeToolId(tool.id) === eventToolId)
+ : -1;
+ if (index < 0) {
+ revision.toolCalls = [...revision.toolCalls, {
+ id: eventToolId || `ontology_tool_${Date.now()}_${revision.toolCalls.length}`,
+ name: getEventToolRawName(eventObj) || t('工具调用'),
+ inputText: delta,
+ status: 'running',
+ timestamp: Date.now(),
+ scope: 'ontology_revision',
+ }];
+ } else if (delta) {
+ revision.toolCalls[index] = {
+ ...revision.toolCalls[index],
+ inputText: (revision.toolCalls[index].inputText || '') + delta,
+ status: 'running',
+ };
+ }
+ appendOrUpdate(true, allCitations);
+ return;
+ }
+
if (ontologyRevisionTool && (eventType === 'tool_result' || eventType === 'tool_end')) {
const revision = ensureOntologyRevision();
revision.toolPending = false;
@@ -802,7 +839,7 @@ export async function processChatStream(resp: Response, opts: ChatStreamOptions)
return;
}
- if (eventType === 'tool_use' || eventType === 'tool_call' || eventType === 'tool_start') {
+ if (eventType === 'tool_use' || eventType === 'tool_call_start' || eventType === 'tool_call' || eventType === 'tool_start') {
const eventToolId = getEventToolId(eventObj);
const existingIndex = eventToolId ? toolCalls.findIndex((tool) => normalizeToolId(tool.id) === eventToolId) : -1;
const toolInput = eventObj.input ?? eventObj.args ?? eventObj.tool_args ?? eventObj.arguments;
@@ -827,6 +864,37 @@ export async function processChatStream(resp: Response, opts: ChatStreamOptions)
return;
}
+ if (eventType === 'tool_call_delta') {
+ const eventToolId = getEventToolId(eventObj);
+ const delta = typeof eventObj.arguments_delta === 'string' ? eventObj.arguments_delta : '';
+ const index = eventToolId
+ ? toolCalls.findIndex((tool) => normalizeToolId(tool.id) === eventToolId)
+ : -1;
+ if (index < 0) {
+ toolCalls.push({
+ id: eventToolId || `tool_${Date.now()}_${toolCalls.length}`,
+ name: getEventToolRawName(eventObj) || t('工具调用'),
+ inputText: delta,
+ status: 'running',
+ timestamp: Date.now(),
+ });
+ deferredThinkingText = deferThinkingTextFragmentBeforeTool(
+ segments,
+ enableThinking,
+ deferredThinkingText,
+ );
+ segments.push({ type: 'tool', toolIndex: toolCalls.length - 1 });
+ } else if (delta) {
+ toolCalls[index] = {
+ ...toolCalls[index],
+ inputText: (toolCalls[index].inputText || '') + delta,
+ status: 'running',
+ };
+ }
+ appendOrUpdate(true);
+ return;
+ }
+
if (eventType === 'tool_result' || eventType === 'tool_end') {
const toolIndex = findToolCallIndex(eventObj);
const status: 'success' | 'error' = obj.error ? 'error' : 'success';
diff --git a/src/frontend/src/hooks/useLoopMode.ts b/src/frontend/src/hooks/useLoopMode.ts
index c9623c03..ac065357 100644
--- a/src/frontend/src/hooks/useLoopMode.ts
+++ b/src/frontend/src/hooks/useLoopMode.ts
@@ -3,6 +3,7 @@ import { t } from '../i18n';
import { createLoop, startLoop, resumeLoop } from '../api';
import { useChatStore } from '../stores';
import { isThinkingMode } from '../stores/chatStore';
+import { useModelCapabilitiesStore } from '../stores/modelCapabilitiesStore';
import { useLoopStore } from '../stores/loopStore';
import type { LoopPlanReq } from '../stores/loopStore';
import { processChatStream } from './chatStream';
@@ -141,7 +142,16 @@ export async function sendLoopMode(
// boolean — the backend sets reasoning_effort accordingly.
const chatMode = useChatStore.getState().chatMode;
const enableThinking = isThinkingMode(chatMode);
- const resp = await startLoop(loop.loop_id, { enable_thinking: enableThinking, chat_mode: chatMode }, ac.signal);
+ // worker 模型跟随用户在会话里选定的模型(与普通聊天同源),不再永远默认模型
+ const modelCaps = useModelCapabilitiesStore.getState();
+ const selectedModelProviderId = modelCaps.capabilities.user_model_switch_enabled
+ ? modelCaps.selectedModelProviderId
+ : null;
+ const resp = await startLoop(loop.loop_id, {
+ enable_thinking: enableThinking,
+ chat_mode: chatMode,
+ ...(selectedModelProviderId ? { model_provider_id: selectedModelProviderId } : {}),
+ }, ac.signal);
if (!resp.ok) throw new Error(t('循环启动失败: {status}', { status: resp.status }));
const outcome = await processLoopStream(resp, currentChatId, enableThinking);
// The unified stream processor digests AbortError into a normal wrap-up (the bubble is
@@ -190,7 +200,15 @@ export async function continueLoop(
try {
const chatMode = useChatStore.getState().chatMode;
const enableThinking = isThinkingMode(chatMode);
- const resp = await resumeLoop(loopId, { enable_thinking: enableThinking, chat_mode: chatMode }, ac.signal);
+ const modelCaps = useModelCapabilitiesStore.getState();
+ const selectedModelProviderId = modelCaps.capabilities.user_model_switch_enabled
+ ? modelCaps.selectedModelProviderId
+ : null;
+ const resp = await resumeLoop(loopId, {
+ enable_thinking: enableThinking,
+ chat_mode: chatMode,
+ ...(selectedModelProviderId ? { model_provider_id: selectedModelProviderId } : {}),
+ }, ac.signal);
if (!resp.ok) throw new Error(t('循环启动失败: {status}', { status: resp.status }));
const outcome = await processLoopStream(resp, targetId, enableThinking);
if (outcome.aborted) useLoopStore.getState().finishLivePlan('cancelled');
diff --git a/src/frontend/src/i18n/en/chat.ts b/src/frontend/src/i18n/en/chat.ts
index 3d9e976b..c2b9ff82 100644
--- a/src/frontend/src/i18n/en/chat.ts
+++ b/src/frontend/src/i18n/en/chat.ts
@@ -36,6 +36,10 @@ export const CHAT_DICT: Record = {
'正在拆解目标为需求清单…': 'Breaking the goal into a requirement checklist…',
'评审中': 'Reviewing',
'只读评审子智能体正在核验真实产出': 'Read-only reviewer subagent is verifying the actual output',
+ '运行中追加指令(下一轮开工前生效,无需取消重来)': 'Add an instruction mid-run (applies before the next iteration, no need to cancel)',
+ '追加': 'Send',
+ '指令已排队,下一轮开工时生效': 'Instruction queued; it takes effect when the next iteration starts',
+ '追加指令失败:{msg}': 'Failed to queue instruction: {msg}',
'⛔ 已取消': '⛔ Cancelled',
'批量执行模式': 'Batch Mode',
'批量执行模式:描述要批量处理的对象与任务,AI 会自动生成可确认的执行计划': 'Batch mode: describe objects and tasks, AI will generate a confirmable execution plan',
diff --git a/src/frontend/src/i18n/en/panels.ts b/src/frontend/src/i18n/en/panels.ts
index 65b5b002..59f6c251 100644
--- a/src/frontend/src/i18n/en/panels.ts
+++ b/src/frontend/src/i18n/en/panels.ts
@@ -79,6 +79,8 @@ export const PANELS_DICT: Record = {
'随模型输出实时更新': 'Updates live with the model output',
'收起右侧面板': 'Collapse right panel',
'展开右侧面板': 'Expand right panel',
+ '全屏': 'Full screen',
+ '退出全屏': 'Exit full screen',
'暂无本体校验结果': 'No ontology validation result yet',
'本体校验开始后,结果会在这里实时显示。': 'Results will stream here when ontology validation starts.',
'右侧面板': 'Right panel',
diff --git a/src/frontend/src/stores/canvasStore.ts b/src/frontend/src/stores/canvasStore.ts
index 0ea61786..efa504a4 100644
--- a/src/frontend/src/stores/canvasStore.ts
+++ b/src/frontend/src/stores/canvasStore.ts
@@ -20,6 +20,7 @@ export type RightSidebarView = 'file' | 'ontology' | 'empty';
interface CanvasState {
isOpen: boolean;
+ isFullscreen: boolean;
activeView: RightSidebarView;
artifact: CanvasArtifact | null;
ontologyTarget: OntologyPanelTarget | null;
@@ -29,6 +30,8 @@ interface CanvasState {
openOntology: (target: OntologyPanelTarget) => void;
openSidebar: () => void;
closeCanvas: () => void;
+ setCanvasFullscreen: (isFullscreen: boolean) => void;
+ toggleCanvasFullscreen: () => void;
resetSidebar: () => void;
/** Update artifact metadata without re-triggering content reload */
updateArtifact: (patch: Partial) => void;
@@ -36,6 +39,7 @@ interface CanvasState {
export const useCanvasStore = create((set) => ({
isOpen: false,
+ isFullscreen: false,
activeView: 'empty',
artifact: null,
ontologyTarget: null,
@@ -54,9 +58,14 @@ export const useCanvasStore = create((set) => ({
openSidebar: () => set({ isOpen: true }),
// Keep the selected content while collapsed so the top-right toggle can
// restore the same view. Chat/panel changes call resetSidebar explicitly.
- closeCanvas: () => set({ isOpen: false }),
+ closeCanvas: () => set({ isOpen: false, isFullscreen: false }),
+ setCanvasFullscreen: (isFullscreen) => set({ isFullscreen }),
+ toggleCanvasFullscreen: () => set((state) => ({
+ isFullscreen: !state.isFullscreen,
+ })),
resetSidebar: () => set({
isOpen: false,
+ isFullscreen: false,
activeView: 'empty',
artifact: null,
ontologyTarget: null,
diff --git a/src/frontend/src/styles/canvas.css b/src/frontend/src/styles/canvas.css
index d903d4d1..0c5103fb 100644
--- a/src/frontend/src/styles/canvas.css
+++ b/src/frontend/src/styles/canvas.css
@@ -6,6 +6,29 @@
width: 58%;
}
+.jx-canvasPanelSlot {
+ min-width: 0;
+ border-left: 1px solid rgba(15, 23, 42, .08);
+ background: #fff;
+}
+
+.jx-appMainLayout.is-canvasFullscreen .jx-canvasPanelSlot {
+ width: 100% !important;
+ max-width: none !important;
+ flex: 1 1 100% !important;
+}
+
+.jx-appMainLayout.is-canvasFullscreen .jx-canvasPanelSlot > .jx-canvas,
+.jx-appMainLayout.is-canvasFullscreen .jx-canvasPanelSlot > .jx-rightSidebar {
+ width: 100% !important;
+ min-width: 0 !important;
+ max-width: none !important;
+}
+
+.jx-appMainLayout.is-canvasFullscreen .jx-canvas-dragHandle {
+ display: none;
+}
+
.jx-rightSidebarSlot > .jx-rightSidebar {
width: 100%;
max-width: none;
@@ -20,11 +43,138 @@
display: flex;
flex-direction: column;
overflow: hidden;
- border-left: 1px solid var(--color-border);
+ border-left: 0;
background: var(--bg);
box-shadow: none;
}
+/* ── Browser-style workspace tabs ── */
+.jx-canvasTabs {
+ display: flex;
+ min-height: 48px;
+ padding: 8px 10px;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+ overflow: hidden;
+ border-bottom: 1px solid rgba(15, 23, 42, .08);
+ background: rgba(255, 255, 255, .98);
+}
+
+.jx-canvasTab {
+ display: flex;
+ width: clamp(132px, 34%, 220px);
+ min-width: 0;
+ height: 32px;
+ padding: 0 6px 0 10px;
+ align-items: center;
+ gap: 7px;
+ border: 0;
+ border-radius: 8px;
+ background: rgba(15, 23, 42, .055);
+ color: var(--color-text-secondary);
+ box-shadow: inset 0 0 0 1px rgba(15, 23, 42, .025);
+}
+
+.jx-canvasTab:focus-visible {
+ outline: 2px solid var(--color-primary-disabled);
+ outline-offset: 1px;
+}
+
+.jx-canvasTab-icon {
+ display: inline-flex;
+ width: 18px;
+ height: 18px;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ color: var(--color-primary);
+ font-size: 14px;
+}
+
+.jx-canvasTab-icon img {
+ width: 17px;
+ height: 17px;
+}
+
+.jx-canvasTab-title {
+ min-width: 0;
+ flex: 1;
+ overflow: hidden;
+ color: var(--color-text);
+ font-size: 12.5px;
+ font-weight: 600;
+ line-height: 1;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.jx-canvasTab-close {
+ display: inline-grid;
+ width: 22px;
+ height: 22px;
+ padding: 0;
+ place-items: center;
+ flex: 0 0 auto;
+ border: 0;
+ border-radius: 5px;
+ background: transparent;
+ color: var(--color-text-tertiary);
+ font-size: 11px;
+ cursor: pointer;
+ transition: background .15s ease, color .15s ease;
+}
+
+.jx-canvasTab-close:hover,
+.jx-canvasTab-close:focus-visible {
+ outline: none;
+ background: var(--color-bg-gray);
+ color: var(--color-text);
+}
+
+.jx-canvasTab-close:focus-visible {
+ box-shadow: 0 0 0 2px var(--color-primary-disabled);
+}
+
+.jx-canvasTabs-spacer {
+ min-width: 8px;
+ height: 100%;
+ flex: 1;
+}
+
+.jx-canvasTabs-actions {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ flex: 0 0 auto;
+}
+
+.jx-canvasTabs-action {
+ display: inline-grid;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ place-items: center;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--color-text-tertiary);
+ font-size: 15px;
+ cursor: pointer;
+ transition: background .15s ease, color .15s ease;
+}
+
+.jx-canvasTabs-action:hover,
+.jx-canvasTabs-action:focus-visible {
+ outline: none;
+ background: var(--color-bg-gray);
+ color: var(--color-text);
+}
+
+.jx-canvasTabs-action:focus-visible {
+ box-shadow: 0 0 0 2px var(--color-primary-disabled);
+}
+
.jx-rightSidebar-body {
min-height: 0;
flex: 1;
@@ -158,7 +308,7 @@
display: flex;
flex-direction: column;
background: #fff;
- border-left: 1px solid rgba(15, 23, 42, .07);
+ border-left: 0;
box-shadow: -4px 0 24px rgba(18, 109, 255, .05);
overflow: hidden;
/* 入场/出场由 App.tsx 的 motion SlidePanel 接管(CSS 入场动画已删,避免双播)。
@@ -192,11 +342,6 @@
width: 900px;
}
-.jx-canvas--expanded {
- width: 80vw;
- min-width: 680px;
-}
-
/* ── 内容加载完成淡入(docx / text 等渲染器就绪时) ── */
.jx-canvas-fadeIn {
--fadeInUp-distance: 6px;
@@ -244,7 +389,7 @@
align-items: center;
justify-content: space-between;
gap: 12px;
- padding: 10px 14px;
+ padding: 6px 14px;
flex-shrink: 0;
border-bottom: 1px solid rgba(15, 23, 42, .06);
background: rgba(255, 255, 255, .92);
@@ -260,8 +405,8 @@
}
.jx-canvas-fileIcon {
- width: 34px;
- height: 34px;
+ width: 30px;
+ height: 30px;
display: flex;
align-items: center;
justify-content: center;
@@ -270,9 +415,9 @@
.jx-canvas-fileMeta {
display: flex;
- flex-direction: column;
+ align-items: center;
min-width: 0;
- gap: 1px;
+ flex: 1;
}
.jx-canvas-fileName {
@@ -285,13 +430,6 @@
line-height: 1.3;
}
-.jx-canvas-fileSize {
- font-size: 11px;
- color: var(--color-text-tertiary);
- font-family: var(--font-family-number);
- line-height: 1.2;
-}
-
/* ── Header actions ── */
.jx-canvas-header-actions {
display: flex;
diff --git a/src/frontend/src/styles/chat.css b/src/frontend/src/styles/chat.css
index 9cddd593..cf91523b 100644
--- a/src/frontend/src/styles/chat.css
+++ b/src/frontend/src/styles/chat.css
@@ -35,6 +35,28 @@
}
.jx-appMainLayout{
position:relative;
+ flex-direction:row;
+ min-width:0;
+}
+.jx-primaryPane{
+ position:relative;
+ display:flex;
+ flex:1 1 0;
+ min-width:0;
+ height:100%;
+ flex-direction:column;
+ overflow:hidden;
+ transition:flex-basis var(--motion-duration-slow) var(--motion-ease-brand-out),
+ opacity var(--motion-duration-fast) var(--motion-ease-standard);
+}
+.jx-primaryPane.is-canvasOpen .jx-chatTopbar{
+ padding-right:16px;
+}
+.jx-appMainLayout.is-canvasFullscreen .jx-primaryPane{
+ width:0;
+ flex:0 0 0;
+ opacity:0;
+ pointer-events:none;
}
.jx-rightSidebarToggle.ant-btn{
position:absolute;
diff --git a/src/frontend/src/styles/mobile.css b/src/frontend/src/styles/mobile.css
index b95a3c11..cd933c8e 100644
--- a/src/frontend/src/styles/mobile.css
+++ b/src/frontend/src/styles/mobile.css
@@ -537,6 +537,32 @@
overflow:hidden !important;
}
+ .jx-primaryPane{
+ width:100%;
+ max-width:100%;
+ flex:1 1 auto;
+ }
+
+ .jx-canvasPanelSlot{
+ position:absolute;
+ inset:0;
+ z-index:50;
+ width:100% !important;
+ max-width:none !important;
+ height:100%;
+ }
+
+ .jx-canvasPanelSlot > .jx-canvas,
+ .jx-canvasPanelSlot > .jx-rightSidebar{
+ width:100% !important;
+ min-width:0 !important;
+ max-width:none !important;
+ }
+
+ .jx-canvasTabs-fullscreen{
+ display:none;
+ }
+
.jx-appShell > .jx-sider{
position:fixed !important;
inset:0 auto 0 0;
diff --git a/src/frontend/src/styles/tool.css b/src/frontend/src/styles/tool.css
index 758e9772..de2cd981 100644
--- a/src/frontend/src/styles/tool.css
+++ b/src/frontend/src/styles/tool.css
@@ -194,13 +194,36 @@
/* ── Code/command view inside the running tool card ── */
.jx-tcr-liveCode {
+ --ce-code-bg: var(--color-bg-gray);
+ --ce-code-border: var(--color-border);
+ --ce-line-num: var(--color-text-placeholder);
+ --ce-line-border: rgba(71, 84, 103, 0.12);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
+ background: var(--color-bg-gray);
}
.jx-tcr-liveCode .jx-ce-codeWrap {
max-height: 260px;
}
+.jx-tcr-liveCode .jx-ce-codeBar {
+ background: var(--color-fill-hover);
+ border-bottom-color: rgba(71, 84, 103, 0.12);
+}
+.jx-tcr-liveCode .jx-ce-langDot {
+ background: var(--color-fill-heavy) !important;
+}
+.jx-tcr-liveCode .jx-ce-code,
+.jx-tcr-liveCode .jx-ce-code .hljs,
+.jx-tcr-liveCode .jx-ce-code .hljs * {
+ color: var(--color-text-secondary) !important;
+}
+.jx-tcr-liveCode .jx-ce-codeWrap::-webkit-scrollbar-thumb {
+ background: rgba(71, 84, 103, 0.16);
+}
+.jx-tcr-liveCode .jx-ce-codeWrap::-webkit-scrollbar-thumb:hover {
+ background: rgba(71, 84, 103, 0.28);
+}
/* ── Elapsed timer (running tool card + pending indicator) ── */
.jx-tcr-timer,
diff --git a/src/frontend/src/types.ts b/src/frontend/src/types.ts
index 224d12f8..fe503b66 100755
--- a/src/frontend/src/types.ts
+++ b/src/frontend/src/types.ts
@@ -158,6 +158,8 @@ export interface SubagentStep {
name?: string;
displayName?: string;
input?: any;
+ /** Incremental JSON argument text while the model is constructing the call. */
+ inputText?: string;
output?: any;
status?: 'running' | 'success' | 'error';
// When kind === 'thinking' | 'content': the accumulated text
@@ -169,6 +171,8 @@ export interface ToolCall {
name: string;
displayName?: string;
input?: any;
+ /** Incremental JSON argument text retained for the live tool-call view. */
+ inputText?: string;
output?: any;
status?: 'pending' | 'running' | 'success' | 'error';
timestamp?: number;
diff --git a/src/frontend/src/utils/codeExecParser.ts b/src/frontend/src/utils/codeExecParser.ts
index e7979e8a..de195547 100644
--- a/src/frontend/src/utils/codeExecParser.ts
+++ b/src/frontend/src/utils/codeExecParser.ts
@@ -38,6 +38,91 @@ export function extractCodeFromInput(
};
}
+/**
+ * Decode the available prefix of one JSON string field without requiring the
+ * outer tool-arguments object to be complete yet. Function-call argument
+ * deltas commonly contain escaped newlines (``\\n``); decoding them here lets
+ * a streamed Write/Edit body render as real multi-line code before ToolCallEnd.
+ */
+function extractPartialJsonString(raw: string, field: string): string | null {
+ const match = new RegExp(`"${field}"\\s*:\\s*"`).exec(raw);
+ if (!match || match.index === undefined) return null;
+
+ let index = match.index + match[0].length;
+ let output = '';
+ while (index < raw.length) {
+ const char = raw[index];
+ if (char === '"') return output;
+ if (char !== '\\') {
+ output += char;
+ index += 1;
+ continue;
+ }
+
+ // A trailing backslash is an incomplete escape in the current delta. It
+ // will be decoded after the next fragment arrives.
+ if (index + 1 >= raw.length) return output;
+ const escape = raw[index + 1];
+ const simpleEscapes: Record = {
+ '"': '"',
+ '\\': '\\',
+ '/': '/',
+ b: '\b',
+ f: '\f',
+ n: '\n',
+ r: '\r',
+ t: '\t',
+ };
+ if (escape in simpleEscapes) {
+ output += simpleEscapes[escape];
+ index += 2;
+ continue;
+ }
+ if (escape === 'u') {
+ const hex = raw.slice(index + 2, index + 6);
+ if (hex.length < 4 || !/^[0-9a-fA-F]{4}$/.test(hex)) return output;
+ output += String.fromCharCode(Number.parseInt(hex, 16));
+ index += 6;
+ continue;
+ }
+
+ // Keep an unknown escape readable while the model is still producing an
+ // incomplete argument rather than hiding the remainder of the block.
+ output += escape;
+ index += 2;
+ }
+ return output;
+}
+
+/** Extract a live code/command preview from incomplete function-call JSON. */
+export function extractCodeFromStreamingArgs(
+ toolName: string,
+ argumentsText: string,
+): { code: string; language: string } | null {
+ if (!argumentsText) return null;
+
+ try {
+ const complete = extractCodeFromInput(toolName, JSON.parse(argumentsText));
+ if (complete.code) return complete;
+ } catch {
+ // Expected while arguments are still streaming; decode the target field
+ // directly from the incomplete JSON prefix below.
+ }
+
+ if (toolName === 'bash') {
+ const code = extractPartialJsonString(argumentsText, 'command');
+ return code === null ? null : { code, language: 'bash' };
+ }
+ if (toolName === 'Write' || toolName === 'Edit') {
+ const codeField = toolName === 'Write' ? 'content' : 'new_string';
+ const code = extractPartialJsonString(argumentsText, codeField);
+ if (code === null) return null;
+ const filePath = extractPartialJsonString(argumentsText, 'file_path') ?? '';
+ return { code, language: langFromPath(filePath) };
+ }
+ return null;
+}
+
/** Map a file path's extension to a highlight.js language id. */
const _EXT_LANG: Record = {
py: 'python', js: 'javascript', mjs: 'javascript', cjs: 'javascript',