From 493b01ea404c8d1b7a4d4ec7268d7dcc361cf721 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Thu, 13 Aug 2026 23:22:40 +0200 Subject: [PATCH 1/5] feat(chat): live status line, smart auto-scroll everywhere, operator persona Three pieces of dev-testing feedback on the operator chat: The live activity area was a scrolling wall of identical httpCalls rows (some spinning forever when their completion event never pairs up). End-user live mode is now one honest status line - "Thinking..." or "Using {tool}..." from the latest toolTrace evidence, plus a tool-call count. Failed tasks and cascade traces still break through to the full view; the debug surface keeps the classic step list. New locale key in 11 languages. Scrolling up during generation no longer snaps back down: the operator chat and the test-chat drawer now use the same smart auto-scroll as the main panel (follow only at bottom), and all three surfaces show a centered scroll-to-bottom arrow with a new-content pulse while scrolled up. The operator prompt gains a personality-and-formatting section: friendly but precise, Markdown for anything beyond a short reply, an overview line ahead of longer answers, and a handful of signpost emojis - road signs, not decoration. Applies to newly provisioned operators. --- .../chat/__tests__/chat-activity.test.tsx | 92 ++++++++++++++++++- src/components/chat/chat-activity.tsx | 43 +++++++++ src/components/chat/chat-drawer.tsx | 47 ++++++++-- src/components/chat/chat-panel.tsx | 4 +- src/components/operator/operator-chat.tsx | 51 ++++++++-- src/i18n/locales/ar.json | 3 +- src/i18n/locales/de.json | 3 +- src/i18n/locales/en.json | 3 +- src/i18n/locales/es.json | 3 +- src/i18n/locales/fr.json | 3 +- src/i18n/locales/hi.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/ko.json | 3 +- src/i18n/locales/pt.json | 3 +- src/i18n/locales/th.json | 3 +- src/i18n/locales/zh.json | 3 +- src/lib/operator/system-prompt.ts | 23 ++++- 17 files changed, 261 insertions(+), 32 deletions(-) diff --git a/src/components/chat/__tests__/chat-activity.test.tsx b/src/components/chat/__tests__/chat-activity.test.tsx index e070f46f5..b6b537b78 100644 --- a/src/components/chat/__tests__/chat-activity.test.tsx +++ b/src/components/chat/__tests__/chat-activity.test.tsx @@ -39,7 +39,26 @@ describe("ChatActivity", () => { }, ]; + // End-user live mode is now a single status line — no step fraction, no + // rows. The classic Processing bar lives on the debug surface. renderWithProviders(); + expect(screen.getByTestId("chat-activity-live-status")).toBeInTheDocument(); + expect(screen.getByText(/Thinking/)).toBeInTheDocument(); + expect(screen.queryByText("0/5")).not.toBeInTheDocument(); + }); + + it("debug surface keeps the classic processing bar while live", () => { + const events: PipelineEvent[] = [ + { + type: "task_start", + taskType: "ai.labs.rules", + taskId: "1", + index: 0, + timestamp: Date.now(), + }, + ]; + + renderWithProviders(); expect(screen.getByText(/Processing…/)).toBeInTheDocument(); expect(screen.getByText("0/5")).toBeInTheDocument(); }); @@ -116,6 +135,8 @@ describe("ChatActivity", () => { }); it("renders details and supports tool tracing, expansion, and copying details", async () => { + // Details are a resting-state affordance in end-user mode now — live shows + // only the status line. const user = userEvent.setup(); const events: PipelineEvent[] = [ { @@ -140,7 +161,7 @@ describe("ChatActivity", () => { }, ]; - renderWithProviders(); + renderWithProviders(); // Since isLive is true, it should start expanded expect(screen.getByText("llm")).toBeInTheDocument(); @@ -302,3 +323,72 @@ describe("ChatActivity — summary metrics follow the filtered list", () => { expect(screen.getByText(/\b2 steps/)).toBeInTheDocument(); }); }); + +/** + * End-user live mode is one status line: what the agent is doing right now. + * The wall of internal step rows (dozens of identical httpcalls entries, some + * spinning forever when their completion never pairs up) is debug-surface-only. + */ +describe("ChatActivity — end-user live status line", () => { + const start = (taskType: string, index: number): PipelineEvent => ({ + type: "task_start", + taskType, + taskId: String(index), + index, + timestamp: Date.now(), + }); + + it("shows Thinking before any tool call, and never the step rows", () => { + const events: PipelineEvent[] = [ + start("ai.labs.httpcalls", 0), + { ...start("ai.labs.httpcalls", 0), type: "task_complete", durationMs: 1 }, + start("ai.labs.httpcalls", 1), + ]; + + renderWithProviders(); + + expect(screen.getByTestId("chat-activity-live-status")).toBeInTheDocument(); + expect(screen.getByText(/Thinking/)).toBeInTheDocument(); + // No row list, no expander — the httpcalls wall must not render live. + expect(screen.queryByTestId("chat-activity-toggle")).not.toBeInTheDocument(); + expect(screen.queryByText("httpCalls")).not.toBeInTheDocument(); + }); + + it("names the tool currently in use once a toolTrace event arrives", () => { + const events: PipelineEvent[] = [ + start("ai.labs.httpcalls", 0), + { + ...start("ai.labs.httpcalls", 0), + type: "task_complete", + durationMs: 2, + toolTrace: [ + { type: "tool_call", tool: "readAgent", arguments: "{}" }, + { type: "tool_result", tool: "readAgent", result: "ok" }, + ], + }, + start("ai.labs.httpcalls", 1), + ]; + + renderWithProviders(); + + expect(screen.getByText(/readAgent/)).toBeInTheDocument(); + expect(screen.getByText(/1 tool call/)).toBeInTheDocument(); + }); + + it("a failed task breaks through the minimal line to the full view", () => { + const events: PipelineEvent[] = [ + start("ai.labs.langchain", 0), + { + ...start("ai.labs.langchain", 0), + type: "task_failed", + errorType: "timeout", + errorSummary: "provider timed out", + }, + ]; + + renderWithProviders(); + + expect(screen.queryByTestId("chat-activity-live-status")).not.toBeInTheDocument(); + expect(screen.getByTestId("chat-activity-toggle")).toBeInTheDocument(); + }); +}); diff --git a/src/components/chat/chat-activity.tsx b/src/components/chat/chat-activity.tsx index 1cafcfa6e..3a5158d6b 100644 --- a/src/components/chat/chat-activity.tsx +++ b/src/components/chat/chat-activity.tsx @@ -204,6 +204,17 @@ export function ChatActivity({ events, isLive, totalSteps, showInternalSteps = f const shouldPulse = isLive && hasRunning; + // The tool the agent is on right now — best live evidence is the last + // tool_call in the most recent event carrying a toolTrace. Null until the + // first tool call: the turn is purely "thinking" until then. + const currentTool = useMemo(() => { + for (let i = events.length - 1; i >= 0; i--) { + const calls = events[i]?.toolTrace?.filter((e) => e.type === "tool_call"); + if (calls?.length) return calls[calls.length - 1]!.tool; + } + return null; + }, [events]); + // Auto-expand if tool calls, cascade steps, or errors are present useEffect(() => { if (toolCallCount > 0 || cascadeSteps.length > 0 || rawTasks.some((t) => t.status === "error")) { @@ -215,6 +226,38 @@ export function ChatActivity({ events, isLive, totalSteps, showInternalSteps = f if (!showInternalSteps && !isLive && tasks.length === 0 && cascadeSteps.length === 0) return null; if (rawTasks.length === 0 && cascadeSteps.length === 0) return null; + // End-user LIVE mode: a scrolling wall of internal step rows (an + // OpenAPI-provisioned agent's pipeline is dozens of identical httpcalls + // rows, and a row whose completion event never pairs up spins forever) says + // nothing a user can act on. Show one honest status line instead — what the + // agent is doing right now — and keep the step list for the debug surface + // and the resting summary. Two things still break through to the full view + // mid-turn, because they ARE actionable: a failed task (its classified + // error must not hide behind a cheerful spinner) and a model-cascade trace + // (escalations are the point of watching one). + const hasErrorTask = rawTasks.some((task) => task.status === "error"); + if (!showInternalSteps && isLive && !hasErrorTask && cascadeSteps.length === 0) { + return ( +
+
+
+ + + {currentTool + ? t("chat.activity.usingTool", "Using {{tool}}…", { tool: currentTool }) + : t("chat.thinking", "Thinking...")} + + {toolCallCount > 0 && ( + + {t("chat.activity.toolCallsCount", "{{count}} tool calls", { count: toolCallCount })} + + )} +
+
+
+ ); + } + return (
s.isThinking); const startConversation = useStartConversation(); - const messagesEndRef = useRef(null); // Attachment staging shared between the drop zone (drawer body) and the // input's picker/paste — one staging area, same as the main panel. @@ -89,10 +90,19 @@ export function ChatDrawer() { (files) => void staging.stageFiles(files), ); - // Auto-scroll to bottom on new messages - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); + // Smart auto-scroll: follows new content only while the user is AT the + // bottom; scrolled up, the view stays put and a centered arrow offers the + // way back down. + const { + scrollRef, + showScrollFab, + hasNewContent, + scrollToBottom, + handleScroll, + } = useSmartAutoScroll({ + deps: [messages, isThinking, isProcessing], + bottomThreshold: 80, + }); const handleNewConversation = useCallback(() => { if (!agentId) return; @@ -206,7 +216,8 @@ export function ChatDrawer() { {/* Chat messages */} {(showChat || (step === "idle" && conversationId)) && ( <> -
+
+
{messages.length === 0 ? (
@@ -227,11 +238,31 @@ export function ChatDrawer() { {t("chat.thinking")}
)} -
)}
+ {/* Centered scroll-to-bottom arrow — only while scrolled up */} + {showScrollFab && ( + + )} +
+ {/* Quick replies */} diff --git a/src/components/chat/chat-panel.tsx b/src/components/chat/chat-panel.tsx index cd768eb40..9754feab4 100644 --- a/src/components/chat/chat-panel.tsx +++ b/src/components/chat/chat-panel.tsx @@ -527,7 +527,7 @@ export function ChatPanel({ embedded = false }: { embedded?: boolean } = {}) { {showScrollFab && ( + )}
{/* Pending attachment chips (staged, uploading, errored) */} diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index bee5d46b3..718c5a422 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -726,7 +726,8 @@ "toolCall": "استدعاء أداة", "toolResult": "النتيجة", "error": "حدث خطأ", - "toolCallsCount": "{{count}} من استدعاءات الأدوات" + "toolCallsCount": "{{count}} من استدعاءات الأدوات", + "usingTool": "جارٍ استخدام {{tool}}…" }, "context": { "conversationId": "معرف المحادثة", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 6584b0bf7..8d032a6fb 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -694,7 +694,8 @@ "toolCall": "Werkzeugaufruf", "toolResult": "Ergebnis", "error": "Fehler aufgetreten", - "toolCallsCount": "{{count}} Tool-Aufrufe" + "toolCallsCount": "{{count}} Tool-Aufrufe", + "usingTool": "Verwendet {{tool}} …" }, "context": { "conversationId": "Gesprächs-ID", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index dc391f4c1..46b72f771 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -723,7 +723,8 @@ "toolCall": "Tool call", "toolResult": "Result", "error": "Error occurred", - "toolCallsCount": "{{count}} tool calls" + "toolCallsCount": "{{count}} tool calls", + "usingTool": "Using {{tool}}…" }, "context": { "conversationId": "Conversation ID", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3f7fc8486..be8f05794 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -702,7 +702,8 @@ "toolCall": "Llamada a herramienta", "toolResult": "Resultado", "error": "Se produjo un error", - "toolCallsCount": "{{count}} llamadas a herramientas" + "toolCallsCount": "{{count}} llamadas a herramientas", + "usingTool": "Usando {{tool}}…" }, "context": { "conversationId": "ID de conversación", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 0d00857b6..c749ff49d 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -702,7 +702,8 @@ "toolCall": "Appel d’outil", "toolResult": "Résultat", "error": "Une erreur s'est produite", - "toolCallsCount": "{{count}} appels d'outils" + "toolCallsCount": "{{count}} appels d'outils", + "usingTool": "Utilise {{tool}}…" }, "context": { "conversationId": "ID conversation", diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 50258544a..4891b7da7 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -694,7 +694,8 @@ "toolCall": "उपकरण कॉल", "toolResult": "परिणाम", "error": "त्रुटि हुई", - "toolCallsCount": "{{count}} टूल कॉल" + "toolCallsCount": "{{count}} टूल कॉल", + "usingTool": "{{tool}} का उपयोग हो रहा है…" }, "context": { "conversationId": "बातचीत ID", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7ebf67834..aca58b68e 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -694,7 +694,8 @@ "toolCall": "ツール呼び出し", "toolResult": "結果", "error": "エラーが発生しました", - "toolCallsCount": "{{count}} 件のツール呼び出し" + "toolCallsCount": "{{count}} 件のツール呼び出し", + "usingTool": "{{tool}} を使用中…" }, "context": { "conversationId": "会話ID", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 1da780c33..88baf87f9 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -694,7 +694,8 @@ "toolCall": "도구 호출", "toolResult": "결과", "error": "오류가 발생했습니다", - "toolCallsCount": "도구 호출 {{count}}건" + "toolCallsCount": "도구 호출 {{count}}건", + "usingTool": "{{tool}} 사용 중…" }, "context": { "conversationId": "대화 ID", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 6b6ca7460..97e53bd2c 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -702,7 +702,8 @@ "toolCall": "Chamada de ferramenta", "toolResult": "Resultado", "error": "Ocorreu um erro", - "toolCallsCount": "{{count}} chamadas de ferramentas" + "toolCallsCount": "{{count}} chamadas de ferramentas", + "usingTool": "Usando {{tool}}…" }, "context": { "conversationId": "ID da conversa", diff --git a/src/i18n/locales/th.json b/src/i18n/locales/th.json index af5ebdb75..69f57ee1d 100644 --- a/src/i18n/locales/th.json +++ b/src/i18n/locales/th.json @@ -694,7 +694,8 @@ "toolCall": "การเรียกใช้เครื่องมือ", "toolResult": "ผลลัพธ์", "error": "เกิดข้อผิดพลาด", - "toolCallsCount": "เรียกใช้เครื่องมือ {{count}} ครั้ง" + "toolCallsCount": "เรียกใช้เครื่องมือ {{count}} ครั้ง", + "usingTool": "กำลังใช้ {{tool}}…" }, "context": { "conversationId": "รหัสบทสนทนา", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c9d5f6723..f26f84b22 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -694,7 +694,8 @@ "toolCall": "工具调用", "toolResult": "结果", "error": "发生错误", - "toolCallsCount": "{{count}} 次工具调用" + "toolCallsCount": "{{count}} 次工具调用", + "usingTool": "正在使用 {{tool}}…" }, "context": { "conversationId": "对话 ID", diff --git a/src/lib/operator/system-prompt.ts b/src/lib/operator/system-prompt.ts index 947e782d1..3b988016e 100644 --- a/src/lib/operator/system-prompt.ts +++ b/src/lib/operator/system-prompt.ts @@ -259,6 +259,27 @@ const BODY_HOW_TO_WORK = `How to work: - Be brief. An administrator wants the finding, not a narration of your steps. - When something is outside what you can see, say what you would need.`; +/** + * Tone and presentation. The chat surfaces render Markdown (GFM, no raw + * HTML), so the prompt should actively use it — an unformatted wall of status + * output was one of the first things dev-testing flagged. + */ +const BODY_STYLE = `Personality and formatting: +- Be friendly and approachable, and precise underneath it: warm in tone, exact + in content. Plain, confident language — no filler, no exclamation-mark + enthusiasm about routine facts. +- Your answers render as Markdown. For anything beyond a short reply, use it: + headings to separate concerns, bullet lists for enumerations, tables when + comparing agents, versions, or environments, and backticks around ids, + versions, endpoint paths, and config field names. +- Start a longer answer (multiple sections, or any multi-step diagnosis) with a + one- or two-line overview of the finding, then the detail beneath it. +- Use a few emojis as signposts where they genuinely aid scanning — ✅ healthy, + ⚠️ needs attention, ❌ broken or failed, 💡 suggestion. A handful per answer + at most: they are road signs, not decoration. +- Short question, short answer: one sentence needs no headings, no emoji, and + no overview.`; + /** * Appended only when writes are granted. * @@ -276,7 +297,7 @@ const BODY_MAKING_CHANGES = `When you change something: /** Default editable body for a granted endpoint set — the role and style. */ export function buildOperatorPromptBody(endpoints: readonly string[]): string { - const sections = [BODY_ROLE, BODY_ARCHITECTURE, BODY_APP_CONTEXT, BODY_HOW_TO_WORK]; + const sections = [BODY_ROLE, BODY_ARCHITECTURE, BODY_APP_CONTEXT, BODY_STYLE, BODY_HOW_TO_WORK]; if (grantsWriteCapability(endpoints)) sections.push(buildAuthoringSection(endpoints), BODY_MAKING_CHANGES); return sections.join("\n\n"); } From 45448c1d1744db967e75fad8acb3ecbb3a4243b4 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Thu, 13 Aug 2026 23:32:15 +0200 Subject: [PATCH 2/5] fix(review): drawer opens scrolled to bottom + ar/fr label corrections CodeRabbit round on the UX PR: the drawer smart-scroll deps now include isOpen (the scroll container mounts only when the drawer opens, so an existing transcript opened at the TOP with no scroll control), pinned by a test that observes the jsdom scrollTop fallback; the Arabic tool-call count becomes a complete count-neutral label; the French usingTool phrase stops reading as an imperative addressed at the user. --- .../chat/__tests__/chat-drawer.test.tsx | 37 +++++++++++++++++++ src/components/chat/chat-drawer.tsx | 5 ++- src/i18n/locales/ar.json | 2 +- src/i18n/locales/fr.json | 2 +- 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/components/chat/__tests__/chat-drawer.test.tsx b/src/components/chat/__tests__/chat-drawer.test.tsx index 5ddaeea33..6a2a9cfa8 100644 --- a/src/components/chat/__tests__/chat-drawer.test.tsx +++ b/src/components/chat/__tests__/chat-drawer.test.tsx @@ -328,3 +328,40 @@ describe("ChatDrawer — attachments", () => { await waitFor(() => expect(sendBtn).toBeEnabled()); }); }); + +describe("ChatDrawer — opens scrolled to the bottom", () => { + it("an existing transcript mounts at the bottom, not the top", async () => { + // jsdom has no scrollTo, so the hook falls back to scrollTop = scrollHeight + // — give every element a height so that assignment is observable. + const proto = window.HTMLElement.prototype; + const original = Object.getOwnPropertyDescriptor(proto, "scrollHeight"); + Object.defineProperty(proto, "scrollHeight", { configurable: true, get: () => 500 }); + try { + useChatStore.getState().setSelectedAgent("agent-1", "Agent One"); + useChatStore.getState().setConversationId("conv-s1"); + for (let i = 0; i < 5; i++) { + useChatStore.getState().addMessage({ + id: `m-${i}`, + role: i % 2 ? "agent" : "user", + content: `message ${i}`, + timestamp: Date.now(), + }); + } + useChatDrawerStore.setState({ + isOpen: true, + agentId: "agent-1", + agentName: "Agent One", + step: "ready", + errorMessage: null, + }); + + const { container } = renderWithProviders(); + const scroller = container.querySelector('[aria-live="polite"]') as HTMLElement; + expect(scroller).toBeTruthy(); + await waitFor(() => expect(scroller.scrollTop).toBe(500)); + } finally { + if (original) Object.defineProperty(proto, "scrollHeight", original); + else delete (proto as unknown as Record).scrollHeight; + } + }); +}); diff --git a/src/components/chat/chat-drawer.tsx b/src/components/chat/chat-drawer.tsx index 170d59a8a..3b37944a3 100644 --- a/src/components/chat/chat-drawer.tsx +++ b/src/components/chat/chat-drawer.tsx @@ -100,7 +100,10 @@ export function ChatDrawer() { scrollToBottom, handleScroll, } = useSmartAutoScroll({ - deps: [messages, isThinking, isProcessing], + // isOpen: the scroll container mounts only when the drawer opens, so + // without it an existing transcript opens scrolled to the TOP with no + // way down but manual scrolling. + deps: [messages, isThinking, isProcessing, isOpen], bottomThreshold: 80, }); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 718c5a422..790523b54 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -726,7 +726,7 @@ "toolCall": "استدعاء أداة", "toolResult": "النتيجة", "error": "حدث خطأ", - "toolCallsCount": "{{count}} من استدعاءات الأدوات", + "toolCallsCount": "عدد استدعاءات الأدوات: {{count}}", "usingTool": "جارٍ استخدام {{tool}}…" }, "context": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c749ff49d..0d338c6c9 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -703,7 +703,7 @@ "toolResult": "Résultat", "error": "Une erreur s'est produite", "toolCallsCount": "{{count}} appels d'outils", - "usingTool": "Utilise {{tool}}…" + "usingTool": "Utilisation de {{tool}}…" }, "context": { "conversationId": "ID conversation", From 7f4c60cb9938cf1b6ad8f76ab1a21267f57627cb Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Fri, 14 Aug 2026 00:05:54 +0200 Subject: [PATCH 3/5] feat(chat): the operator-style live status line in the main chat and drawer The main Chat page and the test-chat drawer now render the same "Thinking..." / "Using {tool}..." live status line the operator chat shows, driven by the turn's live pipeline events; the old dots indicator covers only the gap before the first event arrives. One component, one behavior, all three chat surfaces. --- .../chat/__tests__/chat-panel.test.tsx | 19 +++++++++++++++++++ src/components/chat/chat-drawer.tsx | 12 ++++++++++-- src/components/chat/chat-panel.tsx | 15 +++++++++++---- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/components/chat/__tests__/chat-panel.test.tsx b/src/components/chat/__tests__/chat-panel.test.tsx index ed942f992..756dcf09d 100644 --- a/src/components/chat/__tests__/chat-panel.test.tsx +++ b/src/components/chat/__tests__/chat-panel.test.tsx @@ -697,3 +697,22 @@ describe("ChatPanel", () => { }); }); }); + +describe("ChatPanel — shared live status line", () => { + it("shows the operator-style status line once turn events arrive", () => { + useChatStore.getState().setSelectedAgent("agent1", "Test Agent"); + useChatStore.getState().setConversationId("conv1"); + useChatStore.getState().addMessage({ id: "u1", role: "user", content: "hi", timestamp: Date.now() }); + useChatStore.getState().setProcessing(true); + useDebugStore.setState({ + currentTurnEvents: [ + { type: "task_start", taskType: "ai.labs.httpcalls", taskId: "1", index: 0, timestamp: Date.now() }, + ], + }); + + renderWithProviders(); + + expect(screen.getByTestId("chat-activity-live-status")).toBeInTheDocument(); + expect(screen.getByText(/Thinking/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/chat/chat-drawer.tsx b/src/components/chat/chat-drawer.tsx index 3b37944a3..11c59c1b4 100644 --- a/src/components/chat/chat-drawer.tsx +++ b/src/components/chat/chat-drawer.tsx @@ -11,9 +11,11 @@ import { type ReadyAttachment, } from "@/hooks/use-attachment-staging"; import { ChatMessage } from "./chat-message"; +import { ChatActivity } from "./chat-activity"; import { FileDropOverlay, PendingAttachmentChip } from "./attachment-chip"; import { StreamingToggle } from "./streaming-toggle"; import { DebugDrawer as DebugPanel } from "@/components/debugger/debug-drawer"; +import { useDebugStore } from "@/hooks/use-debug-events"; import { InputHint } from "@/components/chat/input-hint"; import { useSmartAutoScroll } from "@/hooks/use-smart-auto-scroll"; import { cn } from "@/lib/utils"; @@ -79,6 +81,7 @@ export function ChatDrawer() { const conversationId = useChatStore((s) => s.conversationId); const isProcessing = useChatStore((s) => s.isProcessing); const isThinking = useChatStore((s) => s.isThinking); + const currentTurnEvents = useDebugStore((s) => s.currentTurnEvents); const startConversation = useStartConversation(); @@ -235,12 +238,17 @@ export function ChatDrawer() { {messages.map((msg) => ( ))} - {isThinking && ( + {/* Live status — the same "Thinking…" / "Using {tool}…" + line the operator and main chat show; the plain + indicator covers the gap before the first event. */} + {(isProcessing || isThinking) && currentTurnEvents.length > 0 ? ( + + ) : isThinking ? (
{t("chat.thinking")}
- )} + ) : null}
)}
diff --git a/src/components/chat/chat-panel.tsx b/src/components/chat/chat-panel.tsx index 9754feab4..4eaf68ea7 100644 --- a/src/components/chat/chat-panel.tsx +++ b/src/components/chat/chat-panel.tsx @@ -24,6 +24,7 @@ import { } from "@/hooks/use-attachment-staging"; import { FileDropOverlay, PendingAttachmentChip } from "./attachment-chip"; import { ChatMessage } from "./chat-message"; +import { ChatActivity } from "./chat-activity"; import { ChatHistory } from "./chat-history"; import { StreamingToggle } from "./streaming-toggle"; import { DebugDrawer } from "@/components/debugger/debug-drawer"; @@ -499,10 +500,16 @@ export function ChatPanel({ embedded = false }: { embedded?: boolean } = {}) { ))} - {/* Inline thinking / tool-use indicator — replaces old activity card */} - {(isProcessing || isThinking) && ( - - )} + {/* Live status — the SAME line the operator chat shows: + "Thinking…" / "Using {tool}…" + tool-call count, from the + turn's live events. The dots indicator covers only the gap + before the first event arrives. */} + {(isProcessing || isThinking) && + (currentTurnEvents.length > 0 ? ( + + ) : ( + + ))} {/* Rerun button — shown when last message is an error */} {showRerun && !isProcessing && ( From 137ef23215b04e68a2126dba999cea38de32f8fe Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Fri, 14 Aug 2026 00:21:27 +0200 Subject: [PATCH 4/5] feat(groups): paste and drag-drop files into the discussion input The group-discussion input already had a picker, chips, dedupe and byte caps over its inline-base64 attachment refs - what it lacked vs. the chat surfaces was clipboard paste and drag-drop. Both now feed the same serialized staging queue (caps and dedupe apply unchanged), with the shared drop overlay; continuations keep rejecting attachments, so paste and drop are inert there too. --- .../__tests__/discussion-input.test.tsx | 54 ++++++++++++++++++- src/components/groups/discussion-input.tsx | 29 +++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/components/groups/__tests__/discussion-input.test.tsx b/src/components/groups/__tests__/discussion-input.test.tsx index 0466ef666..f32fc99f6 100644 --- a/src/components/groups/__tests__/discussion-input.test.tsx +++ b/src/components/groups/__tests__/discussion-input.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { screen } from "@testing-library/react"; +import { createEvent, fireEvent, screen } from "@testing-library/react"; import { renderWithProviders, userEvent } from "@/test/test-utils"; import { DiscussionInput } from "@/components/groups/discussion-input"; @@ -177,3 +177,55 @@ describe("DiscussionInput", () => { }); }); }); + +describe("DiscussionInput — paste and drag-drop", () => { + const pasteFile = (target: HTMLElement, file: File) => { + const pasteEvent = createEvent.paste(target); + Object.defineProperty(pasteEvent, "clipboardData", { + value: { files: [file], types: ["Files"], getData: () => "" }, + }); + fireEvent(target, pasteEvent); + }; + + it("pasting a file stages it as an attachment chip", async () => { + renderWithProviders(); + + pasteFile( + screen.getByTestId("discussion-input"), + new File(["png-bytes"], "screenshot.png", { type: "image/png" }), + ); + + expect(await screen.findByTestId("discussion-attachments")).toHaveTextContent("screenshot.png"); + }); + + it("dropping a file on the input area stages it, with the overlay shown mid-drag", async () => { + renderWithProviders(); + + const form = screen.getByTestId("discussion-input").closest("form")!; + const dataTransfer = { + files: [new File(["doc"], "notes.txt", { type: "text/plain" })], + types: ["Files"], + }; + fireEvent.dragEnter(form, { dataTransfer }); + expect(screen.getByTestId("file-drop-overlay")).toBeInTheDocument(); + fireEvent.drop(form, { dataTransfer }); + + expect(await screen.findByTestId("discussion-attachments")).toHaveTextContent("notes.txt"); + expect(screen.queryByTestId("file-drop-overlay")).not.toBeInTheDocument(); + }); + + it("a continuation ignores paste and drop — the backend rejects attachments there", async () => { + renderWithProviders(); + + pasteFile( + screen.getByTestId("discussion-input"), + new File(["x"], "late.png", { type: "image/png" }), + ); + fireEvent.dragEnter(screen.getByTestId("discussion-input").closest("form")!, { + dataTransfer: { files: [], types: ["Files"] }, + }); + + expect(screen.queryByTestId("file-drop-overlay")).not.toBeInTheDocument(); + expect(screen.queryByTestId("discussion-attachments")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/groups/discussion-input.tsx b/src/components/groups/discussion-input.tsx index 971dcee3f..1ecf802d9 100644 --- a/src/components/groups/discussion-input.tsx +++ b/src/components/groups/discussion-input.tsx @@ -5,6 +5,8 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { AccessibleDialog } from "@/components/ui/accessible-dialog"; import { cn } from "@/lib/utils"; +import { filesFromClipboard, useFileDrop } from "@/hooks/use-attachment-staging"; +import { FileDropOverlay } from "@/components/chat/attachment-chip"; import { MAX_ATTACHMENT_BYTES } from "@/lib/api/attachments"; import { MAX_GROUP_ATTACHMENTS, @@ -239,6 +241,24 @@ export function DiscussionInput({ onSubmit, isLoading, disabled, mode = "new", d [stageFiles], ); + // Paste (screenshots via Ctrl/Cmd+V) and drag-drop feed the SAME serialized + // staging queue as the picker — the caps, dedupe and encoding all apply. + const handlePasteFiles = useCallback( + (e: React.ClipboardEvent) => { + const files = filesFromClipboard(e); + if (!files.length || !canAttach || disabled || isLoading) return; + e.preventDefault(); + void addFiles(files); + }, + [canAttach, disabled, isLoading, addFiles], + ); + const { isDragOver, dropHandlers } = useFileDrop( + canAttach && !disabled && !isLoading, + (files) => { + void addFiles(files); + }, + ); + const charCount = question.length; // The backend caps the question at MAX_QUESTION_CHARS and fans it out to every // member in every phase, so this is a real ceiling rather than a tuning knob. @@ -275,7 +295,12 @@ export function DiscussionInput({ onSubmit, isLoading, disabled, mode = "new", d return ( <> -
+ + {isDragOver && } {attachments.length > 0 && (
    {attachments.map((a) => ( @@ -364,6 +389,7 @@ export function DiscussionInput({ onSubmit, isLoading, disabled, mode = "new", d handleSubmit(); } }} + onPaste={handlePasteFiles} data-testid="discussion-input" /> {/* Expand button */} @@ -432,6 +458,7 @@ export function DiscussionInput({ onSubmit, isLoading, disabled, mode = "new", d handleSubmit(); } }} + onPaste={handlePasteFiles} data-testid="discussion-input-expanded" />
    From ceb2bb4c0612cc5718012fa67e1f136864c6c732 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Fri, 14 Aug 2026 00:47:14 +0200 Subject: [PATCH 5/5] fix(review): debug-turn boundary at send start + panel FAB out of the scroller Findings from the adversarial pass over the recent commits, all verified: The main chat enforced its debug-turn boundary only on the two handled SSE endings (done/error frames) - a connection drop or thrown stream error left currentTurnEvents populated, and the new live status line rendered the NEXT turn over the previous turn''s tools, counts and error walls, surviving agent switches indefinitely. Both send paths now finalize any leftover turn at send START (the operator store''s pattern), and the no-done safety net finalizes too. Pinned by a leak-regression test; the task_failed test moved its assertions to the finalized turn history accordingly. The main panel''s scroll-to-bottom FAB lived INSIDE the overflow container, so it scrolled away with the content - exactly when it was needed. Same wrapper restructure the operator and drawer got. The smart-scroll hook also treats a freshly mounted container as at-bottom, so reopening the drawer after scrolling up no longer lands at the top (the vacuous open-scroll test now opens AFTER mount, exercising the isOpen dep for real). Polish: singular plural forms for the tool-call count in all pluralizing locales (full CLDR category sets - "1 tool calls" is gone), the drawer follows the growing live status line, BODY_STYLE is pinned by the prompt tests, and a stale test comment is corrected. --- .../chat/__tests__/chat-activity.test.tsx | 3 +- .../chat/__tests__/chat-drawer.test.tsx | 10 +++- src/components/chat/chat-drawer.tsx | 5 +- src/components/chat/chat-panel.tsx | 9 ++- .../__tests__/use-chat-sse-handling.test.tsx | 57 ++++++++++++++++++- src/hooks/use-chat.ts | 18 +++++- src/hooks/use-smart-auto-scroll.ts | 15 +++++ src/i18n/locales/ar.json | 6 ++ src/i18n/locales/de.json | 2 + src/i18n/locales/en.json | 2 + src/i18n/locales/es.json | 3 + src/i18n/locales/fr.json | 3 + src/i18n/locales/hi.json | 2 + src/i18n/locales/ja.json | 2 + src/i18n/locales/ko.json | 2 + src/i18n/locales/pt.json | 3 + src/i18n/locales/th.json | 2 + src/i18n/locales/zh.json | 2 + .../operator/__tests__/system-prompt.test.ts | 3 + 19 files changed, 141 insertions(+), 8 deletions(-) diff --git a/src/components/chat/__tests__/chat-activity.test.tsx b/src/components/chat/__tests__/chat-activity.test.tsx index b6b537b78..1ce9b3a8c 100644 --- a/src/components/chat/__tests__/chat-activity.test.tsx +++ b/src/components/chat/__tests__/chat-activity.test.tsx @@ -163,7 +163,8 @@ describe("ChatActivity", () => { renderWithProviders(); - // Since isLive is true, it should start expanded + // Auto-expands because the toolTrace makes toolCallCount > 0 — the + // expansion effect, not liveness, drives it expect(screen.getByText("llm")).toBeInTheDocument(); expect(screen.getByText("unknown")).toBeInTheDocument(); diff --git a/src/components/chat/__tests__/chat-drawer.test.tsx b/src/components/chat/__tests__/chat-drawer.test.tsx index 6a2a9cfa8..38badc728 100644 --- a/src/components/chat/__tests__/chat-drawer.test.tsx +++ b/src/components/chat/__tests__/chat-drawer.test.tsx @@ -347,8 +347,12 @@ describe("ChatDrawer — opens scrolled to the bottom", () => { timestamp: Date.now(), }); } + // Mount CLOSED first: the real drawer stays mounted in the layout and + // only the isOpen dep makes the effect fire on open — rendering with + // isOpen pre-set would pass even with that dep deleted, because a fresh + // mount runs its effect unconditionally. useChatDrawerStore.setState({ - isOpen: true, + isOpen: false, agentId: "agent-1", agentName: "Agent One", step: "ready", @@ -356,6 +360,10 @@ describe("ChatDrawer — opens scrolled to the bottom", () => { }); const { container } = renderWithProviders(); + const { act } = await import("@testing-library/react"); + act(() => { + useChatDrawerStore.setState({ isOpen: true }); + }); const scroller = container.querySelector('[aria-live="polite"]') as HTMLElement; expect(scroller).toBeTruthy(); await waitFor(() => expect(scroller.scrollTop).toBe(500)); diff --git a/src/components/chat/chat-drawer.tsx b/src/components/chat/chat-drawer.tsx index 11c59c1b4..932044837 100644 --- a/src/components/chat/chat-drawer.tsx +++ b/src/components/chat/chat-drawer.tsx @@ -105,8 +105,9 @@ export function ChatDrawer() { } = useSmartAutoScroll({ // isOpen: the scroll container mounts only when the drawer opens, so // without it an existing transcript opens scrolled to the TOP with no - // way down but manual scrolling. - deps: [messages, isThinking, isProcessing, isOpen], + // way down but manual scrolling. currentTurnEvents.length: the live + // status line grows the content without any message change. + deps: [messages, isThinking, isProcessing, isOpen, currentTurnEvents.length], bottomThreshold: 80, }); diff --git a/src/components/chat/chat-panel.tsx b/src/components/chat/chat-panel.tsx index 4eaf68ea7..bc8909a5a 100644 --- a/src/components/chat/chat-panel.tsx +++ b/src/components/chat/chat-panel.tsx @@ -471,10 +471,14 @@ export function ChatPanel({ embedded = false }: { embedded?: boolean } = {}) { )} - {/* Messages */} + {/* Messages — the FAB must be a SIBLING of the scroller, anchored to + this wrapper: absolutely positioned inside an overflow container it + would scroll away with the content, vanishing exactly when the + user has scrolled up and needs it. */} +
    {!selectedAgentId ? ( @@ -529,6 +533,7 @@ export function ChatPanel({ embedded = false }: { embedded?: boolean } = {}) {
    )} +
    {/* Scroll-to-bottom FAB with new content pulse */} {showScrollFab && ( diff --git a/src/hooks/__tests__/use-chat-sse-handling.test.tsx b/src/hooks/__tests__/use-chat-sse-handling.test.tsx index faa141753..9665d3435 100644 --- a/src/hooks/__tests__/use-chat-sse-handling.test.tsx +++ b/src/hooks/__tests__/use-chat-sse-handling.test.tsx @@ -70,7 +70,12 @@ describe("use-chat handleSSEEvent — task_failed / error-JSON handling", () => await result.current.mutateAsync({ message: "hi" }); }); - const events = useDebugStore.getState().currentTurnEvents; + // The stream ended without a done frame, so the safety net finalizes the + // debug turn (turn-boundary fix) — the events live in the turn history + // now, not the live set. + const finalized = useDebugStore.getState().turns; + expect(finalized).toHaveLength(1); + const events = finalized[0]!.events; const failed = events.find((e) => e.type === "task_failed"); expect(failed).toBeDefined(); expect(failed?.taskId).toBe("task-42"); @@ -158,3 +163,53 @@ describe("use-chat handleSSEEvent — canonical snapshot text on done", () => { expect(agentMessage?.content).toBe("Structured answer"); }); }); + +describe("use-chat — turn boundary on abnormal stream endings", () => { + beforeEach(() => { + useChatStore.getState().reset(); + useDebugStore.getState().reset(); + h.frames = []; + useChatStore.setState({ + selectedAgentId: "agent1", + conversationId: "conv1", + streamingEnabled: true, + }); + }); + + it("a turn that ends without a done frame does not leak its events into the next turn", async () => { + // Turn 1: a task event arrives, then the stream just closes — no done, no + // error frame (connection drop). Without the boundary fix the events stay + // in currentTurnEvents and the NEXT turn's live status line opens showing + // this turn's tools. + h.frames = [ + { + type: "task_complete", + data: JSON.stringify({ + taskId: "t1", + taskType: "ai.labs.httpcalls", + index: 0, + durationMs: 3, + toolTrace: [{ type: "tool_call", tool: "leakedTool", arguments: "{}" }], + }), + }, + ]; + + const { result } = renderHook(() => useSendMessage(), { wrapper }); + await act(async () => { + await result.current.mutateAsync({ message: "first" }); + }); + + expect(useDebugStore.getState().currentTurnEvents).toHaveLength(0); + + // Turn 2 starts: the live set must be empty before its own events arrive. + h.frames = [{ type: "done", data: JSON.stringify({ conversationState: "READY" }) }]; + await act(async () => { + await result.current.mutateAsync({ message: "second" }); + }); + expect( + useDebugStore.getState().currentTurnEvents.some( + (e) => e.toolTrace?.some((tr) => tr.tool === "leakedTool"), + ), + ).toBe(false); + }); +}); diff --git a/src/hooks/use-chat.ts b/src/hooks/use-chat.ts index bcefd33fc..6802eab9d 100644 --- a/src/hooks/use-chat.ts +++ b/src/hooks/use-chat.ts @@ -431,6 +431,16 @@ export function useSendMessage() { // TanStack Query blocks all subsequent .mutate() calls. const abort = new AbortController(); + // Turn boundary at turn START, not only on the happy endings: a + // previous turn that ended without a done/error frame (connection + // drop, thrown stream error) leaves its events in the debug store, + // and the live status line renders straight off that array — the new + // turn would open showing the previous turn's tools and keep a + // contaminated count throughout. finalizeTurn() moves any leftovers + // into the turn history (preserving the trace) and clears the live + // set; it is a no-op when the previous turn ended cleanly. + useDebugStore.getState().finalizeTurn(); + const events = sendMessageStreaming( "production", selectedAgentId, @@ -459,12 +469,18 @@ export function useSendMessage() { } } // Safety-net: if the stream ended without a done event - // (e.g. connection drop), finalize it here. + // (e.g. connection drop), finalize it here — the debug turn too, so + // the next turn's live status line does not open on this turn's + // events. if (store.getState().isProcessing) { store.getState().finishStreaming(); + useDebugStore.getState().finalizeTurn(); } } else { // --- Non-streaming path — pass context for secret input --- + // Same turn boundary as the streaming path: stale events from an + // abnormally-ended streamed turn must not render over this one. + useDebugStore.getState().finalizeTurn(); // Add a placeholder typing indicator while waiting for the response const typingId = `agent-typing-${Date.now()}`; state.addMessage({ diff --git a/src/hooks/use-smart-auto-scroll.ts b/src/hooks/use-smart-auto-scroll.ts index 8803a5606..b91490271 100644 --- a/src/hooks/use-smart-auto-scroll.ts +++ b/src/hooks/use-smart-auto-scroll.ts @@ -52,10 +52,25 @@ export function useSmartAutoScroll({ } }, [bottomThreshold]); + // The element the tracking state belongs to. The hook can outlive its + // scroll container (the drawer unmounts its children while closed but stays + // mounted itself); without this, closing while scrolled up leaves + // isNearBottomRef=false, and REOPENING lands the fresh container at the TOP + // because the stale ref routes the effect to the new-content branch. + const trackedElRef = useRef(null); + // Trigger auto-scroll on dependency updates if user is near bottom useEffect(() => { const el = scrollRef.current; if (!el) return; + if (trackedElRef.current !== el) { + // A freshly mounted container starts at scrollTop 0 with no user scroll + // history — treat it as at-bottom so the initial jump happens. + trackedElRef.current = el; + isNearBottomRef.current = true; + setShowScrollFab(false); + setHasNewContent(false); + } if (isNearBottomRef.current) { const behavior = isInitialMountRef.current ? "auto" : "smooth"; if (typeof el.scrollTo === "function") { diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 790523b54..ee54a1233 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -726,6 +726,12 @@ "toolCall": "استدعاء أداة", "toolResult": "النتيجة", "error": "حدث خطأ", + "toolCallsCount_one": "استدعاء أداة واحد", + "toolCallsCount_zero": "لا استدعاءات أدوات", + "toolCallsCount_two": "استدعاءا أداة", + "toolCallsCount_few": "{{count}} استدعاءات أدوات", + "toolCallsCount_many": "{{count}} استدعاءً للأدوات", + "toolCallsCount_other": "عدد استدعاءات الأدوات: {{count}}", "toolCallsCount": "عدد استدعاءات الأدوات: {{count}}", "usingTool": "جارٍ استخدام {{tool}}…" }, diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 8d032a6fb..5f7004f88 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -694,6 +694,8 @@ "toolCall": "Werkzeugaufruf", "toolResult": "Ergebnis", "error": "Fehler aufgetreten", + "toolCallsCount_one": "{{count}} Tool-Aufruf", + "toolCallsCount_other": "{{count}} Tool-Aufrufe", "toolCallsCount": "{{count}} Tool-Aufrufe", "usingTool": "Verwendet {{tool}} …" }, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 46b72f771..24b3f91ec 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -723,6 +723,8 @@ "toolCall": "Tool call", "toolResult": "Result", "error": "Error occurred", + "toolCallsCount_one": "{{count}} tool call", + "toolCallsCount_other": "{{count}} tool calls", "toolCallsCount": "{{count}} tool calls", "usingTool": "Using {{tool}}…" }, diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index be8f05794..2f537f131 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -702,6 +702,9 @@ "toolCall": "Llamada a herramienta", "toolResult": "Resultado", "error": "Se produjo un error", + "toolCallsCount_one": "{{count}} llamada a herramienta", + "toolCallsCount_many": "{{count}} llamadas a herramientas", + "toolCallsCount_other": "{{count}} llamadas a herramientas", "toolCallsCount": "{{count}} llamadas a herramientas", "usingTool": "Usando {{tool}}…" }, diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 0d338c6c9..e7b3acb41 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -702,6 +702,9 @@ "toolCall": "Appel d’outil", "toolResult": "Résultat", "error": "Une erreur s'est produite", + "toolCallsCount_one": "{{count}} appel d'outil", + "toolCallsCount_many": "{{count}} appels d'outils", + "toolCallsCount_other": "{{count}} appels d'outils", "toolCallsCount": "{{count}} appels d'outils", "usingTool": "Utilisation de {{tool}}…" }, diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 4891b7da7..a619fc081 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -694,6 +694,8 @@ "toolCall": "उपकरण कॉल", "toolResult": "परिणाम", "error": "त्रुटि हुई", + "toolCallsCount_one": "{{count}} टूल कॉल", + "toolCallsCount_other": "{{count}} टूल कॉल", "toolCallsCount": "{{count}} टूल कॉल", "usingTool": "{{tool}} का उपयोग हो रहा है…" }, diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index aca58b68e..94ead18fe 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -694,6 +694,8 @@ "toolCall": "ツール呼び出し", "toolResult": "結果", "error": "エラーが発生しました", + "toolCallsCount_one": "{{count}} 件のツール呼び出し", + "toolCallsCount_other": "{{count}} 件のツール呼び出し", "toolCallsCount": "{{count}} 件のツール呼び出し", "usingTool": "{{tool}} を使用中…" }, diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 88baf87f9..a3f36f1da 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -694,6 +694,8 @@ "toolCall": "도구 호출", "toolResult": "결과", "error": "오류가 발생했습니다", + "toolCallsCount_one": "도구 호출 {{count}}건", + "toolCallsCount_other": "도구 호출 {{count}}건", "toolCallsCount": "도구 호출 {{count}}건", "usingTool": "{{tool}} 사용 중…" }, diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 97e53bd2c..09c3ef727 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -702,6 +702,9 @@ "toolCall": "Chamada de ferramenta", "toolResult": "Resultado", "error": "Ocorreu um erro", + "toolCallsCount_one": "{{count}} chamada de ferramenta", + "toolCallsCount_many": "{{count}} chamadas de ferramentas", + "toolCallsCount_other": "{{count}} chamadas de ferramentas", "toolCallsCount": "{{count}} chamadas de ferramentas", "usingTool": "Usando {{tool}}…" }, diff --git a/src/i18n/locales/th.json b/src/i18n/locales/th.json index 69f57ee1d..0bce45a86 100644 --- a/src/i18n/locales/th.json +++ b/src/i18n/locales/th.json @@ -694,6 +694,8 @@ "toolCall": "การเรียกใช้เครื่องมือ", "toolResult": "ผลลัพธ์", "error": "เกิดข้อผิดพลาด", + "toolCallsCount_one": "เรียกใช้เครื่องมือ {{count}} ครั้ง", + "toolCallsCount_other": "เรียกใช้เครื่องมือ {{count}} ครั้ง", "toolCallsCount": "เรียกใช้เครื่องมือ {{count}} ครั้ง", "usingTool": "กำลังใช้ {{tool}}…" }, diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index f26f84b22..e66a66838 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -694,6 +694,8 @@ "toolCall": "工具调用", "toolResult": "结果", "error": "发生错误", + "toolCallsCount_one": "{{count}} 次工具调用", + "toolCallsCount_other": "{{count}} 次工具调用", "toolCallsCount": "{{count}} 次工具调用", "usingTool": "正在使用 {{tool}}…" }, diff --git a/src/lib/operator/__tests__/system-prompt.test.ts b/src/lib/operator/__tests__/system-prompt.test.ts index 5165f1edf..cb12e9032 100644 --- a/src/lib/operator/__tests__/system-prompt.test.ts +++ b/src/lib/operator/__tests__/system-prompt.test.ts @@ -169,6 +169,9 @@ describe("buildOperatorPromptBody", () => { for (const body of [buildOperatorPromptBody(READ_ENDPOINTS), buildOperatorPromptBody(WITH_A_WRITE)]) { expect(body).toContain("help an administrator understand and operate this EDDI"); expect(body).toContain("How to work:"); + // The persona/formatting section — deleting it (or dropping its + // insertion from buildOperatorPromptBody) must fail here. + expect(body).toContain("Personality and formatting:"); } });