{isDragOver &&
}
+
+
+ {/* Centered scroll-to-bottom arrow — shown only while scrolled up */}
+ {showScrollFab && (
+
scrollToBottom("smooth")}
+ className="absolute bottom-3 inset-x-0 mx-auto z-10 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-card shadow-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all animate-in fade-in slide-in-from-bottom-2"
+ title={t("chat.scrollToBottom", "Scroll to bottom")}
+ aria-label={t("chat.scrollToBottom", "Scroll to bottom")}
+ data-testid="operator-scroll-to-bottom"
+ >
+
+ {hasNewContent && (
+
+
+
+
+ )}
+
+ )}
{/* Pending attachment chips (staged, uploading, errored) */}
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 bee5d46b3..ee54a1233 100644
--- a/src/i18n/locales/ar.json
+++ b/src/i18n/locales/ar.json
@@ -726,7 +726,14 @@
"toolCall": "استدعاء أداة",
"toolResult": "النتيجة",
"error": "حدث خطأ",
- "toolCallsCount": "{{count}} من استدعاءات الأدوات"
+ "toolCallsCount_one": "استدعاء أداة واحد",
+ "toolCallsCount_zero": "لا استدعاءات أدوات",
+ "toolCallsCount_two": "استدعاءا أداة",
+ "toolCallsCount_few": "{{count}} استدعاءات أدوات",
+ "toolCallsCount_many": "{{count}} استدعاءً للأدوات",
+ "toolCallsCount_other": "عدد استدعاءات الأدوات: {{count}}",
+ "toolCallsCount": "عدد استدعاءات الأدوات: {{count}}",
+ "usingTool": "جارٍ استخدام {{tool}}…"
},
"context": {
"conversationId": "معرف المحادثة",
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index 6584b0bf7..5f7004f88 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -694,7 +694,10 @@
"toolCall": "Werkzeugaufruf",
"toolResult": "Ergebnis",
"error": "Fehler aufgetreten",
- "toolCallsCount": "{{count}} Tool-Aufrufe"
+ "toolCallsCount_one": "{{count}} Tool-Aufruf",
+ "toolCallsCount_other": "{{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..24b3f91ec 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -723,7 +723,10 @@
"toolCall": "Tool call",
"toolResult": "Result",
"error": "Error occurred",
- "toolCallsCount": "{{count}} tool calls"
+ "toolCallsCount_one": "{{count}} tool call",
+ "toolCallsCount_other": "{{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..2f537f131 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -702,7 +702,11 @@
"toolCall": "Llamada a herramienta",
"toolResult": "Resultado",
"error": "Se produjo un error",
- "toolCallsCount": "{{count}} llamadas a herramientas"
+ "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}}…"
},
"context": {
"conversationId": "ID de conversación",
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 0d00857b6..e7b3acb41 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -702,7 +702,11 @@
"toolCall": "Appel d’outil",
"toolResult": "Résultat",
"error": "Une erreur s'est produite",
- "toolCallsCount": "{{count}} appels d'outils"
+ "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}}…"
},
"context": {
"conversationId": "ID conversation",
diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json
index 50258544a..a619fc081 100644
--- a/src/i18n/locales/hi.json
+++ b/src/i18n/locales/hi.json
@@ -694,7 +694,10 @@
"toolCall": "उपकरण कॉल",
"toolResult": "परिणाम",
"error": "त्रुटि हुई",
- "toolCallsCount": "{{count}} टूल कॉल"
+ "toolCallsCount_one": "{{count}} टूल कॉल",
+ "toolCallsCount_other": "{{count}} टूल कॉल",
+ "toolCallsCount": "{{count}} टूल कॉल",
+ "usingTool": "{{tool}} का उपयोग हो रहा है…"
},
"context": {
"conversationId": "बातचीत ID",
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 7ebf67834..94ead18fe 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -694,7 +694,10 @@
"toolCall": "ツール呼び出し",
"toolResult": "結果",
"error": "エラーが発生しました",
- "toolCallsCount": "{{count}} 件のツール呼び出し"
+ "toolCallsCount_one": "{{count}} 件のツール呼び出し",
+ "toolCallsCount_other": "{{count}} 件のツール呼び出し",
+ "toolCallsCount": "{{count}} 件のツール呼び出し",
+ "usingTool": "{{tool}} を使用中…"
},
"context": {
"conversationId": "会話ID",
diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json
index 1da780c33..a3f36f1da 100644
--- a/src/i18n/locales/ko.json
+++ b/src/i18n/locales/ko.json
@@ -694,7 +694,10 @@
"toolCall": "도구 호출",
"toolResult": "결과",
"error": "오류가 발생했습니다",
- "toolCallsCount": "도구 호출 {{count}}건"
+ "toolCallsCount_one": "도구 호출 {{count}}건",
+ "toolCallsCount_other": "도구 호출 {{count}}건",
+ "toolCallsCount": "도구 호출 {{count}}건",
+ "usingTool": "{{tool}} 사용 중…"
},
"context": {
"conversationId": "대화 ID",
diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json
index 6b6ca7460..09c3ef727 100644
--- a/src/i18n/locales/pt.json
+++ b/src/i18n/locales/pt.json
@@ -702,7 +702,11 @@
"toolCall": "Chamada de ferramenta",
"toolResult": "Resultado",
"error": "Ocorreu um erro",
- "toolCallsCount": "{{count}} chamadas de ferramentas"
+ "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}}…"
},
"context": {
"conversationId": "ID da conversa",
diff --git a/src/i18n/locales/th.json b/src/i18n/locales/th.json
index af5ebdb75..0bce45a86 100644
--- a/src/i18n/locales/th.json
+++ b/src/i18n/locales/th.json
@@ -694,7 +694,10 @@
"toolCall": "การเรียกใช้เครื่องมือ",
"toolResult": "ผลลัพธ์",
"error": "เกิดข้อผิดพลาด",
- "toolCallsCount": "เรียกใช้เครื่องมือ {{count}} ครั้ง"
+ "toolCallsCount_one": "เรียกใช้เครื่องมือ {{count}} ครั้ง",
+ "toolCallsCount_other": "เรียกใช้เครื่องมือ {{count}} ครั้ง",
+ "toolCallsCount": "เรียกใช้เครื่องมือ {{count}} ครั้ง",
+ "usingTool": "กำลังใช้ {{tool}}…"
},
"context": {
"conversationId": "รหัสบทสนทนา",
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index c9d5f6723..e66a66838 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -694,7 +694,10 @@
"toolCall": "工具调用",
"toolResult": "结果",
"error": "发生错误",
- "toolCallsCount": "{{count}} 次工具调用"
+ "toolCallsCount_one": "{{count}} 次工具调用",
+ "toolCallsCount_other": "{{count}} 次工具调用",
+ "toolCallsCount": "{{count}} 次工具调用",
+ "usingTool": "正在使用 {{tool}}…"
},
"context": {
"conversationId": "对话 ID",
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:");
}
});
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");
}