@circuitwall/jarela 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +1 -1
  3. package/.next/standalone/.next/build-manifest.json +2 -2
  4. package/.next/standalone/.next/prerender-manifest.json +4 -4
  5. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.html +14 -2
  13. package/.next/standalone/.next/server/app/_not-found.rsc +16 -4
  14. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +16 -4
  15. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +16 -4
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +1 -1
  19. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +1 -1
  20. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +1 -1
  21. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +1 -1
  22. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +1 -1
  23. package/.next/standalone/.next/server/app/api/v1/dashboard/pricing-task/route.js +1 -1
  24. package/.next/standalone/.next/server/app/api/v1/documents/sources/[id]/route.js +1 -1
  25. package/.next/standalone/.next/server/app/api/v1/documents/sources/route.js +1 -1
  26. package/.next/standalone/.next/server/app/api/v1/events/route.js +1 -1
  27. package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js +1 -1
  28. package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js +1 -1
  29. package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js +1 -1
  30. package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js +1 -1
  31. package/.next/standalone/.next/server/app/api/v1/extensions/route.js +1 -1
  32. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/config/route.js +1 -1
  33. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/route.js +1 -1
  34. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +1 -1
  35. package/.next/standalone/.next/server/app/api/v1/packages/defaults/[id]/route.js +28 -4
  36. package/.next/standalone/.next/server/app/api/v1/packages/reload/route.js +28 -4
  37. package/.next/standalone/.next/server/app/api/v1/packages/route.js +28 -4
  38. package/.next/standalone/.next/server/app/api/v1/page-capture/route.js +1 -1
  39. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +578 -58
  40. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/run/route.js +1 -1
  41. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/route.js +118 -26
  42. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +1 -1
  43. package/.next/standalone/.next/server/app/api/v1/tools/route.js +1 -1
  44. package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/api/v1/watchers/[id]/run/route.js +1 -1
  46. package/.next/standalone/.next/server/app/api/v1/watchers/route.js +1 -1
  47. package/.next/standalone/.next/server/app/page.js +31202 -30960
  48. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app-paths-manifest.json +1 -1
  51. package/.next/standalone/.next/server/chunks/2471.js +17 -17
  52. package/.next/standalone/.next/server/chunks/2653.js +80 -1
  53. package/.next/standalone/.next/server/chunks/3399.js +10695 -10695
  54. package/.next/standalone/.next/server/chunks/3566.js +1 -1
  55. package/.next/standalone/.next/server/chunks/4189.js +1 -1
  56. package/.next/standalone/.next/server/chunks/4508.js +2 -2
  57. package/.next/standalone/.next/server/chunks/4596.js +28 -4
  58. package/.next/standalone/.next/server/chunks/46.js +18 -3
  59. package/.next/standalone/.next/server/chunks/466.js +53 -7
  60. package/.next/standalone/.next/server/chunks/4955.js +30 -3
  61. package/.next/standalone/.next/server/chunks/5585.js +28 -4
  62. package/.next/standalone/.next/server/chunks/6649.js +1 -1
  63. package/.next/standalone/.next/server/chunks/9164.js +65 -14
  64. package/.next/standalone/.next/server/chunks/9590.js +18 -3
  65. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  66. package/.next/standalone/.next/server/middleware.js +18 -3
  67. package/.next/standalone/.next/server/pages/404.html +14 -2
  68. package/.next/standalone/.next/server/pages/500.html +1 -1
  69. package/.next/standalone/.next/server/preview-props.json +3 -3
  70. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  71. package/.next/standalone/.next/static/chunks/{1395-e4a99912b8f83044.js → 1395-bfe1f3ef760c0f50.js} +54 -8
  72. package/.next/standalone/.next/static/chunks/app/{layout-9bc0b10127cb7d42.js → layout-7b24ad13faa3944b.js} +69 -2
  73. package/.next/standalone/.next/static/chunks/app/{page-04748847c6ff866f.js → page-2ef8d75d40eb4311.js} +10661 -10352
  74. package/.next/standalone/.next/static/css/c160ea862133acd2.css +5 -0
  75. package/.next/standalone/docs/adoption/changesets.json +2756 -0
  76. package/.next/standalone/package.json +1 -1
  77. package/CHANGELOG.md +33 -0
  78. package/api/client.models.test.ts +10 -0
  79. package/api/client.startup.test.ts +129 -0
  80. package/api/client.ts +51 -9
  81. package/api/thread-metadata.test.ts +72 -0
  82. package/api/types.ts +6 -3
  83. package/app/api/v1/providers/[provider]/models/route.test.ts +216 -6
  84. package/app/api/v1/providers/[provider]/models/route.ts +259 -40
  85. package/app/api/v1/threads/[thread_id]/route.ts +26 -23
  86. package/app/globals.css +119 -29
  87. package/app/layout.tsx +12 -0
  88. package/components/agents/AgentsPanel.tsx +5 -13
  89. package/components/chat/ChatView.tsx +9 -18
  90. package/components/chat/InputBar.tsx +1 -1
  91. package/components/chat/MessageBubble.tsx +1 -1
  92. package/components/chat/MessageList.test.tsx +45 -4
  93. package/components/chat/MessageList.tsx +123 -89
  94. package/components/chat/ToolList.tsx +88 -84
  95. package/components/chat/useThreadActivity.test.tsx +2 -2
  96. package/components/chat/useThreadActivity.ts +2 -2
  97. package/components/chat/useThreadData.ts +1 -30
  98. package/components/chat/useThreadMetadataPolling.test.tsx +128 -0
  99. package/components/chat/useThreadMetadataPolling.ts +50 -0
  100. package/components/chat/useUserProfileAndAgent.test.tsx +31 -0
  101. package/components/chat/useUserProfileAndAgent.ts +13 -2
  102. package/components/dashboard/DashboardPanel.tsx +1 -1
  103. package/components/dashboard/Series.tsx +34 -21
  104. package/components/dashboard/SummaryCards.tsx +1 -1
  105. package/components/layout/AppShell.tsx +22 -7
  106. package/components/layout/MenuPanel.tsx +14 -24
  107. package/components/profile/ProfilePanel.tsx +2 -2
  108. package/components/settings/AppearancePanel.tsx +70 -3
  109. package/components/settings/SettingsPanel.tsx +2 -2
  110. package/components/tools/ToolsPanel.tsx +2 -7
  111. package/components/ui/AppearancePreferences.test.tsx +68 -0
  112. package/components/ui/BootScreen.test.tsx +44 -0
  113. package/components/ui/BootScreen.tsx +24 -11
  114. package/components/ui/NotificationStatus.test.tsx +2 -2
  115. package/components/ui/NotificationStatus.tsx +5 -5
  116. package/components/ui/SubTabBar.tsx +2 -3
  117. package/contexts/ThemeContext.tsx +84 -1
  118. package/hooks/useAgentSession.test.ts +12 -1
  119. package/hooks/useAgentSession.ts +6 -4
  120. package/hooks/useUrlSync.test.ts +13 -0
  121. package/hooks/useUrlSync.ts +0 -4
  122. package/lib/db/migrations.test.ts +61 -0
  123. package/lib/db/migrations.ts +13 -0
  124. package/lib/embeddings/performance.test.ts +114 -0
  125. package/lib/stores/dashboard-metrics-cache.test.ts +99 -0
  126. package/lib/stores/dashboard-metrics.ts +30 -4
  127. package/lib/stores/message-embedding-jobs.test.ts +27 -0
  128. package/lib/stores/message-embedding-jobs.ts +5 -3
  129. package/lib/stores/message-usage.test.ts +21 -1
  130. package/lib/stores/pricing-cache.test.ts +31 -0
  131. package/lib/stores/pricing.ts +3 -0
  132. package/lib/tools/packages/default-packages.test.ts +21 -0
  133. package/lib/tools/packages/optional-package.test.ts +72 -0
  134. package/lib/tools/packages/optional-package.ts +33 -4
  135. package/lib/ui/navigate.test.ts +13 -0
  136. package/lib/ui/navigate.ts +10 -2
  137. package/package.json +1 -1
  138. package/.next/standalone/.next/static/css/2669e8744cc8c212.css +0 -5
  139. /package/.next/standalone/.next/static/{hwujW4hR1b-r3k5WdMxZO → 544OFJV-q0RC6Gjgje4LT}/_buildManifest.js +0 -0
  140. /package/.next/standalone/.next/static/{hwujW4hR1b-r3k5WdMxZO → 544OFJV-q0RC6Gjgje4LT}/_ssgManifest.js +0 -0
@@ -1,12 +1,12 @@
1
1
  "use client";
2
2
  import { useEffect, useRef, useState } from "react";
3
- import { Plus, Trash2 } from "lucide-react";
3
+ import { Bot, Plus, Trash2 } from "lucide-react";
4
4
  import type { AgentConfig, AgentConfigIn } from "@/api/types";
5
5
  import { api } from "@/api/client";
6
6
  import { useAgents } from "@/hooks/useAgents";
7
7
  import { useModels } from "@/hooks/useModels";
8
8
  import { useDeepLinkScroll } from "@/hooks/useDeepLinkScroll";
9
- import { Button } from "@/components/ui/Button";
9
+ import { HeaderAction, PanelHeader } from "@/components/ui/PanelHeader";
10
10
  import { AgentEditor } from "./AgentEditor";
11
11
  import { useAppContext } from "@/contexts/AppContext";
12
12
 
@@ -100,17 +100,9 @@ export function AgentsPanel() {
100
100
  return (
101
101
  <div className="flex flex-col h-full min-h-0">
102
102
  <>
103
- {/* Header */}
104
- <div className="flex items-center justify-between px-3 pt-3 pb-2 shrink-0">
105
- <span className="text-[11px] text-fg-faint font-medium uppercase tracking-wide">Agents</span>
106
- <Button
107
- onClick={() => setEditing("new")}
108
- size="sm"
109
- icon={<Plus size={11} />}
110
- >
111
- New
112
- </Button>
113
- </div>
103
+ <PanelHeader icon={<Bot size={14} />} title="Agents">
104
+ <HeaderAction onClick={() => setEditing("new")} icon={<Plus size={14} />} label="New" />
105
+ </PanelHeader>
114
106
 
115
107
  {/* List */}
116
108
  <div ref={containerRef} className="flex-1 overflow-y-auto px-2 pb-2">
@@ -17,6 +17,7 @@ import { useChatErrorReporting } from "./useChatErrorReporting";
17
17
  import { useChatQueue, type ChatQueueApi } from "./useChatQueue";
18
18
  import { useThreadCrossDeviceSync } from "./useThreadCrossDeviceSync";
19
19
  import { useThreadData } from "./useThreadData";
20
+ import { useThreadMetadataPolling } from "./useThreadMetadataPolling";
20
21
  import { useThreadActivity } from "./useThreadActivity";
21
22
  import { useUserProfileAndAgent } from "./useUserProfileAndAgent";
22
23
  import { composerPlaceholder, finalizeRunFromServer } from "./chat-run-utils";
@@ -109,21 +110,11 @@ export function ChatView({ threadId, agentId, sessionLoading, sessionError, onMe
109
110
  const currentThreadIdRef = useRef(threadId);
110
111
  currentThreadIdRef.current = threadId;
111
112
 
112
- // Auto compaction starts server-side during a run; poll so the banner shows live.
113
- useEffect(() => {
114
- if (!threadId || !sse.streaming) return;
115
- let cancelled = false;
116
- const timer = window.setInterval(() => {
117
- void api.threads.get(threadId, { limit: 1 }).then((d) => {
118
- if (!cancelled) applyThreadMeta(thread.metaApplier, d);
119
- }).catch(() => { /* best-effort */ });
120
- }, 2000);
121
- return () => {
122
- cancelled = true;
123
- window.clearInterval(timer);
124
- };
125
- // eslint-disable-next-line react-hooks/exhaustive-deps
126
- }, [threadId, sse.streaming]);
113
+ useThreadMetadataPolling(
114
+ threadId,
115
+ sse.streaming || thread.warmSummaryPending || thread.compactionPending || thread.manualCompactionPending,
116
+ thread.metaApplier,
117
+ );
127
118
 
128
119
  useThreadCrossDeviceSync({
129
120
  threadId,
@@ -262,9 +253,9 @@ export function ChatView({ threadId, agentId, sessionLoading, sessionError, onMe
262
253
  thread.metaApplier.setWarmSummaryTopics?.(parseWarmSummaryTopics(result.warm_summary_topics));
263
254
  // The compact response carries only the chat recap; channel recaps
264
255
  // (bridge, watcher, scheduled task) come with the thread metadata.
265
- void api.threads.get(compactedThreadId, { limit: 1 })
256
+ void api.threads.metadata(compactedThreadId)
266
257
  .then((d) => { if (stillCurrent()) applyThreadMeta(thread.metaApplier, d); })
267
- .catch(() => { /* best-effort */ });
258
+ .catch((err) => console.error("compaction metadata refresh failed", err));
268
259
  }
269
260
  notifyCompaction(result);
270
261
  } catch (err) {
@@ -296,7 +287,7 @@ export function ChatView({ threadId, agentId, sessionLoading, sessionError, onMe
296
287
  });
297
288
 
298
289
  return (
299
- <div className="flex flex-col h-full">
290
+ <div className="flex h-full min-h-0 w-full min-w-0 flex-col">
300
291
  <MessageList
301
292
  threadId={threadId}
302
293
  messages={thread.messages}
@@ -366,7 +366,7 @@ export function InputBar({ attachments, onAttachmentsChange, onSubmit, onQueue,
366
366
  )}
367
367
 
368
368
  <textarea
369
- className="flex-1 resize-none bg-surface-3/60 text-fg text-sm rounded-xl px-3 py-2 border border-border/60 focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent/40 placeholder:text-fg-faint max-h-[84px] min-h-[44px] transition-colors"
369
+ className="flex-1 min-w-0 resize-none bg-surface-3/60 text-fg text-sm rounded-xl px-3 py-2 border border-border/60 focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent/40 placeholder:text-fg-faint max-h-[84px] min-h-[44px] transition-colors"
370
370
  ref={textareaRef}
371
371
  placeholder={placeholder ?? "Message…"}
372
372
  rows={1}
@@ -2040,7 +2040,7 @@ export const MessageBubble = memo(function MessageBubble({ message, attachedActi
2040
2040
  )}
2041
2041
  </div>
2042
2042
 
2043
- <div className={`flex flex-col min-w-0 ${containsImageAttachment ? "max-w-[calc(100%-2.25rem)] sm:max-w-[92%] md:max-w-[86%]" : "max-w-[88%] sm:max-w-[75%]"} ${isUser ? "items-end" : "items-start"}`}>
2043
+ <div className={`flex flex-col min-w-0 ${!isUser ? "flex-1 items-start" : containsImageAttachment ? "max-w-[calc(100%-2.25rem)] sm:max-w-[92%] md:max-w-[86%] items-end" : "max-w-[calc(100%-2.25rem)] sm:max-w-[75%] items-end"}`}>
2044
2044
  {(timeLabel || messageId) && (
2045
2045
  <div className={`flex items-center gap-1 mb-0.5 px-1 opacity-0 group-hover:opacity-100 pointer-coarse:opacity-100 transition-opacity ${isUser ? "flex-row-reverse" : ""}`}>
2046
2046
  {timeLabel && (
@@ -4,14 +4,16 @@ import { render, screen, fireEvent, within, waitFor } from "@testing-library/rea
4
4
  import { MessageList } from "./MessageList";
5
5
  import type { Message } from "@/api/types";
6
6
 
7
+ const renderCounts = vi.hoisted(() => new Map<string, number>());
7
8
  vi.mock("./MessageBubble", () => ({
8
9
  MessageBubble: ({ message, attachedActivity, showAvatar }: {
9
- message: Pick<Message, "content">;
10
+ message: Pick<Message, "id" | "content">;
10
11
  attachedActivity?: { source_kind: string };
11
12
  showAvatar?: boolean;
12
- }) => (
13
- <div data-activity-kind={attachedActivity?.source_kind} data-show-avatar={String(showAvatar)}>{message.content}</div>
14
- ),
13
+ }) => {
14
+ renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1);
15
+ return <div data-activity-kind={attachedActivity?.source_kind} data-show-avatar={String(showAvatar)}>{message.content}</div>;
16
+ },
15
17
  }));
16
18
 
17
19
  vi.mock("./ToolList", () => ({
@@ -65,6 +67,45 @@ function installPointerCapture(button: HTMLButtonElement) {
65
67
  }
66
68
 
67
69
  describe("MessageList conversation focus", () => {
70
+ it("does not re-render historical rows as streaming tokens or thinking arrive", () => {
71
+ renderCounts.clear();
72
+ const messages = [
73
+ mkMessage("history1", "user", "earlier prompt", "2026-10-18T08:00:00.000Z"),
74
+ mkMessage("history2", "assistant", "earlier answer", "2026-10-18T08:00:01.000Z"),
75
+ ];
76
+ const { rerender } = render(
77
+ <MessageList messages={messages} streaming streamingContent="A" thinkingContent="thinking" />,
78
+ );
79
+ const firstCounts = messages.map((m) => renderCounts.get(m.id));
80
+ rerender(<MessageList messages={messages} streaming streamingContent="A longer reply" thinkingContent="more thinking" />);
81
+ expect(messages.map((m) => renderCounts.get(m.id))).toEqual(firstCounts);
82
+ expect(screen.getByText("A longer reply")).toBeTruthy();
83
+ });
84
+
85
+ it("updates history when a persisted message changes", () => {
86
+ const messages = [mkMessage("history3", "assistant", "before edit", "2026-10-18T08:00:00.000Z")];
87
+ const { rerender } = render(<MessageList messages={messages} />);
88
+ rerender(<MessageList messages={[{ ...messages[0], content: "after edit" }]} />);
89
+ expect(screen.getByText("after edit")).toBeTruthy();
90
+ expect(screen.queryByText("before edit")).toBeNull();
91
+ });
92
+
93
+ it("preserves historical message nodes when the context boundary moves", () => {
94
+ const messages = [
95
+ mkMessage("history4", "user", "first message", "2026-10-18T08:00:00.000Z"),
96
+ mkMessage("history5", "assistant", "second message", "2026-10-18T08:00:01.000Z"),
97
+ ];
98
+ const { container, rerender } = render(<MessageList messages={messages} hotSinceSeq={4} />);
99
+ const first = container.querySelector('[data-message-id="history4"]');
100
+ const second = container.querySelector('[data-message-id="history5"]');
101
+ rerender(<MessageList messages={messages} hotSinceSeq={5} />);
102
+ expect(container.querySelector('[data-message-id="history4"]')).toBe(first);
103
+ expect(container.querySelector('[data-message-id="history5"]')).toBe(second);
104
+ const boundary = container.querySelector("[data-focus-boundary='1']");
105
+ expect(first!.compareDocumentPosition(boundary!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
106
+ expect(boundary!.compareDocumentPosition(second!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
107
+ });
108
+
68
109
  it.each([
69
110
  ["bridge", "bridge-1:chat-1", "user", "bridge", { bridge_conversation: { key: "bridge-1:chat-1" } }],
70
111
  ["scheduled_task", "task-1", "assistant", "scheduled_task", undefined],
@@ -76,8 +76,9 @@ const ALL_FILTERS_SHOWN: Record<MessageFilterKey, boolean> = Object.fromEntries(
76
76
  MESSAGE_FILTER_KEYS.map((k) => [k, true]),
77
77
  ) as Record<MessageFilterKey, boolean>;
78
78
  const NO_CHANNEL_SUMMARIES: WarmChannelSummary[] = [];
79
+ const NOOP = () => {};
79
80
 
80
- export function MessageList({ threadId, messages, steeredSegments, agentConfig, userProfile, streamingContent, thinkingContent, toolEvents, hasMore, loadingMore, onLoadMore, queuedMessages, onRemoveQueued, hotSinceSeq, warmSummary, warmSummaryBeforeSeq, warmSummaryComputedAt, warmSummarySourceMessages, warmSummarySourceChars, warmSummaryTopics, warmChannelSummaries = NO_CHANNEL_SUMMARIES, warmSummaryPending = false, compactionPending = false, onSetContextPin, streaming, contextWindowTokens, onRetryMessage, filters = ALL_FILTERS_SHOWN, onToggleFilter: toggle = () => {}, onResetFilters: reset = () => {} }: Props) {
81
+ export function MessageList({ threadId, messages, steeredSegments, agentConfig, userProfile, streamingContent, thinkingContent, toolEvents, hasMore, loadingMore, onLoadMore, queuedMessages, onRemoveQueued, hotSinceSeq, warmSummary, warmSummaryBeforeSeq, warmSummaryComputedAt, warmSummarySourceMessages, warmSummarySourceChars, warmSummaryTopics, warmChannelSummaries = NO_CHANNEL_SUMMARIES, warmSummaryPending = false, compactionPending = false, onSetContextPin, streaming, contextWindowTokens, onRetryMessage, filters = ALL_FILTERS_SHOWN, onToggleFilter: toggle = NOOP, onResetFilters: reset = NOOP }: Props) {
81
82
  const hostRef = useRef<HTMLDivElement>(null);
82
83
  const maskRegionRef = useRef<HTMLDivElement>(null);
83
84
  const scrollRef = useRef<HTMLDivElement>(null);
@@ -102,7 +103,11 @@ export function MessageList({ threadId, messages, steeredSegments, agentConfig,
102
103
  const [pendingHotSinceSeq, setPendingHotSinceSeq] = useState<number | null>(null);
103
104
  const [topControlsOpen, setTopControlsOpen] = useState(false);
104
105
  const [summaryPopoverOpen, setSummaryPopoverOpen] = useState(false);
105
-
106
+ const hasStreamingContent = !!streamingContent;
107
+ const hasThinkingContent = !!thinkingContent;
108
+ const hasLiveToolEvents = !!toolEvents?.length;
109
+ const hidePersistedDraft = !!streaming || hasStreamingContent;
110
+
106
111
  const retryWorkflowStep = useMemo(
107
112
  () => onRetryMessage
108
113
  ? (request: WorkflowStepRetryRequest) => onRetryMessage(workflowStepRetryMessage(request), [])
@@ -116,7 +121,7 @@ export function MessageList({ threadId, messages, steeredSegments, agentConfig,
116
121
  // they're handled below by gating the inline ToolList / ThinkingLine.
117
122
  const visibleMessages = useMemo(() => {
118
123
  return messages.filter((m) => {
119
- if (m.role === "assistant" && m.transcript_status === "in_progress" && (streaming || !!streamingContent)) return false;
124
+ if (m.role === "assistant" && m.transcript_status === "in_progress" && hidePersistedDraft) return false;
120
125
  const cat = m.category;
121
126
  if (!cat) return true;
122
127
  if (cat === "scheduled_task") return filters.scheduled_task;
@@ -129,7 +134,53 @@ export function MessageList({ threadId, messages, steeredSegments, agentConfig,
129
134
  // never silently drop content the server thinks should be visible.
130
135
  return true;
131
136
  });
132
- }, [messages, streaming, streamingContent, filters.scheduled_task, filters.watcher, filters.bridge, filters.extension, filters.page_capture, filters.synthetic]);
137
+ }, [messages, hidePersistedDraft, filters.scheduled_task, filters.watcher, filters.bridge, filters.extension, filters.page_capture, filters.synthetic]);
138
+
139
+ const historyRows = useMemo(() => {
140
+ const attachedActivities = new Map<string, AutomationActivityMetadata>();
141
+ const hiddenActivityIds = new Set<string>();
142
+ for (let i = 0; i < visibleMessages.length - 1; i++) {
143
+ const activityMessage = visibleMessages[i];
144
+ const activity = activityMessage.metadata?.automation_activity;
145
+ if (!activity) continue;
146
+ const related = visibleMessages[i + 1];
147
+ const matches = !related.metadata?.automation_activity && (
148
+ activity.source_kind === "bridge"
149
+ ? related.role === "user"
150
+ && related.metadata?.bridge_conversation?.key === activity.source_id
151
+ : related.role === "assistant" && related.category === activity.source_kind
152
+ );
153
+ if (!matches) continue;
154
+ attachedActivities.set(related.id, activity);
155
+ hiddenActivityIds.add(activityMessage.id);
156
+ }
157
+ const segmentsById = new Map(steeredSegments?.map((segment) => [segment.id, segment]));
158
+ let previousRole: Message["role"] | null = null;
159
+ return visibleMessages.map((msg, i) => {
160
+ if (hiddenActivityIds.has(msg.id)) return null;
161
+ const startsTurn = previousRole == null || previousRole !== msg.role;
162
+ previousRole = msg.role;
163
+ const steeredSegment = segmentsById.get(msg.id);
164
+ return (
165
+ <HistoryRow
166
+ key={msg.id}
167
+ message={msg}
168
+ steeredSegment={steeredSegment}
169
+ segmentShowAvatar={i === 0 || visibleMessages[i - 1].role !== "assistant"}
170
+ attachedActivity={attachedActivities.get(msg.id)}
171
+ startsTurn={startsTurn}
172
+ separated={startsTurn && i > 0}
173
+ threadId={threadId ?? null}
174
+ agentConfig={agentConfig}
175
+ userProfile={userProfile}
176
+ showToolEvents={filters.tool_use}
177
+ contextWindowTokens={contextWindowTokens ?? null}
178
+ isLatest={i === visibleMessages.length - 1 && !hasStreamingContent}
179
+ onRetry={onRetryMessage}
180
+ />
181
+ );
182
+ });
183
+ }, [visibleMessages, steeredSegments, threadId, agentConfig, userProfile, filters.tool_use, contextWindowTokens, hasStreamingContent, onRetryMessage]);
133
184
 
134
185
  const hiddenCount = messages.length - visibleMessages.length;
135
186
  const effectiveHotSinceSeq = hotSinceSeq ?? null;
@@ -536,7 +587,7 @@ export function MessageList({ threadId, messages, steeredSegments, agentConfig,
536
587
  // perceive this as "chat is empty". Auto-reset once per thread snapshot so
537
588
  // content remains visible without requiring manual recovery.
538
589
  useEffect(() => {
539
- if (messages.length === 0 || streamingContent) return;
590
+ if (messages.length === 0 || hasStreamingContent) return;
540
591
  if (visibleMessages.length > 0) return;
541
592
  if (hiddenCount !== messages.length) return;
542
593
  const lastId = messages[messages.length - 1]?.id ?? "none";
@@ -544,7 +595,7 @@ export function MessageList({ threadId, messages, steeredSegments, agentConfig,
544
595
  if (autoRecoveredRef.current === key) return;
545
596
  autoRecoveredRef.current = key;
546
597
  reset();
547
- }, [threadId, messages, visibleMessages.length, hiddenCount, streamingContent, reset]);
598
+ }, [threadId, messages, visibleMessages.length, hiddenCount, hasStreamingContent, reset]);
548
599
 
549
600
  // Surface a category chip in the toolbar only if it can actually do
550
601
  // something useful right now: scheduled_task/bridge/synthetic chips
@@ -563,10 +614,10 @@ export function MessageList({ threadId, messages, steeredSegments, agentConfig,
563
614
  else if (m.category === "synthetic") set.add("synthetic");
564
615
  if (m.tool_events && m.tool_events.length > 0) set.add("tool_use");
565
616
  }
566
- if (thinkingContent) set.add("thinking");
567
- if (toolEvents && toolEvents.length > 0) set.add("tool_use");
617
+ if (hasThinkingContent) set.add("thinking");
618
+ if (hasLiveToolEvents) set.add("tool_use");
568
619
  return MESSAGE_FILTER_KEYS.filter((k) => set.has(k));
569
- }, [messages, thinkingContent, toolEvents]);
620
+ }, [messages, hasThinkingContent, hasLiveToolEvents]);
570
621
 
571
622
  // Tracks whether the user was at the bottom on the most recent scroll event.
572
623
  // After every render, if true, we snap to bottom — which means: while the
@@ -920,30 +971,6 @@ export function MessageList({ threadId, messages, steeredSegments, agentConfig,
920
971
  // "+ N more we don't know about yet" — older pages are likely unloaded.
921
972
  const olderCountLabel = olderInVisible + (hasMore ? 1 : 0);
922
973
 
923
- const attachedActivities = new Map<string, AutomationActivityMetadata>();
924
- const hiddenActivityIds = new Set<string>();
925
- for (let i = 0; i < visibleMessages.length - 1; i++) {
926
- const activityMessage = visibleMessages[i];
927
- const activity = activityMessage.metadata?.automation_activity;
928
- if (!activity) continue;
929
- const related = visibleMessages[i + 1];
930
- const matches = !related.metadata?.automation_activity && (
931
- activity.source_kind === "bridge"
932
- ? related.role === "user"
933
- && related.metadata?.bridge_conversation?.key === activity.source_id
934
- : related.role === "assistant" && related.category === activity.source_kind
935
- );
936
- if (!matches) continue;
937
- attachedActivities.set(related.id, activity);
938
- hiddenActivityIds.add(activityMessage.id);
939
- }
940
- const previousVisibleRoles = new Map<string, Message["role"] | null>();
941
- let previousVisibleRole: Message["role"] | null = null;
942
- for (const message of visibleMessages) {
943
- previousVisibleRoles.set(message.id, previousVisibleRole);
944
- if (!hiddenActivityIds.has(message.id)) previousVisibleRole = message.role;
945
- }
946
-
947
974
  const renderBoundary = (key: string) => (
948
975
  <div
949
976
  key={key}
@@ -969,54 +996,12 @@ export function MessageList({ threadId, messages, steeredSegments, agentConfig,
969
996
  </div>
970
997
  );
971
998
 
972
- const nodes = visibleMessages.flatMap((msg, i) => {
973
- const out = [] as React.ReactNode[];
974
- if (hasBoundary && !pinAfterAll && i === boundaryIndex) {
975
- out.push(renderBoundary(`boundary-${msg.id}`));
976
- }
977
- if (hiddenActivityIds.has(msg.id)) return out;
978
- const previousRole = previousVisibleRoles.get(msg.id);
979
- const startsTurn = previousRole == null || previousRole !== msg.role;
980
- const steeredSegment = steeredSegments?.find((segment) => segment.id === msg.id);
981
- if (steeredSegment) {
982
- out.push(
983
- <StreamingBubble
984
- key={`steered-${steeredSegment.id}`}
985
- content={steeredSegment.content}
986
- threadId={threadId ?? null}
987
- agentConfig={agentConfig ?? null}
988
- showAvatar={i === 0 || visibleMessages[i - 1].role !== "assistant"}
989
- />,
990
- );
991
- }
992
- out.push(
993
- <div
994
- key={msg.id}
995
- id={typeof msg.seq === "number" ? String(msg.seq) : `msg-${msg.id}`}
996
- data-message-id={msg.id}
997
- data-hot-candidate="1"
998
- data-message-seq={msg.seq}
999
- data-created-at={msg.created_at}
1000
- className={startsTurn && i > 0 ? "mt-3" : undefined}
1001
- >
1002
- <MessageBubble
1003
- message={msg}
1004
- attachedActivity={attachedActivities.get(msg.id)}
1005
- threadId={threadId ?? null}
1006
- agentConfig={agentConfig}
1007
- userProfile={userProfile}
1008
- showAvatar={startsTurn}
1009
- showToolEvents={filters.tool_use}
1010
- contextWindowTokens={contextWindowTokens ?? null}
1011
- isLatest={i === visibleMessages.length - 1 && !streamingContent}
1012
- onRetry={onRetryMessage}
1013
- />
1014
- </div>,
1015
- );
1016
- return out;
1017
- });
1018
- if (hasBoundary && pinAfterAll) nodes.push(renderBoundary("boundary-tail"));
1019
- return nodes;
999
+ if (!hasBoundary) return historyRows;
1000
+ return [
1001
+ ...historyRows.slice(0, boundaryIndex),
1002
+ renderBoundary(pinAfterAll ? "boundary-tail" : `boundary-${visibleMessages[boundaryIndex].id}`),
1003
+ ...historyRows.slice(boundaryIndex),
1004
+ ];
1020
1005
  })()}
1021
1006
  <LiveTurnActivity
1022
1007
  thinkingContent={thinkingContent}
@@ -1225,8 +1210,8 @@ function LiveTurnActivity({
1225
1210
  if (!showThinking && !streamingContent && !showTools && !showDots) return null;
1226
1211
  return (
1227
1212
  <div data-testid="live-turn-activity" className="mt-1 flex w-full min-w-0 flex-col gap-1">
1228
- {showDots && <ThinkingDots />}
1229
- {showThinking && <ThinkingLine text={thinkingContent} />}
1213
+ {showDots && <div className="min-w-0 pl-9"><ThinkingDots /></div>}
1214
+ {showThinking && <div className="min-w-0 pl-9"><ThinkingLine text={thinkingContent} /></div>}
1230
1215
  {streamingContent && (
1231
1216
  <StreamingBubble
1232
1217
  content={streamingContent}
@@ -1235,16 +1220,65 @@ function LiveTurnActivity({
1235
1220
  showAvatar={showStreamingAvatar}
1236
1221
  />
1237
1222
  )}
1238
- {showTools && <ToolList events={toolEvents} />}
1223
+ {showTools && <div className="min-w-0 pl-9"><ToolList events={toolEvents} /></div>}
1239
1224
  </div>
1240
1225
  );
1241
1226
  }
1242
1227
 
1243
- // Streaming-bubble wrapper. Wraps the in-flight assistant bubble so the
1244
- // `message` object passed to MessageBubble keeps a stable identity across
1245
- // renders that don't actually change the streaming text — without this
1246
- // the inline `{ role, content, streaming: true }` literal in the parent
1247
- // JSX would defeat MessageBubble's React.memo on every parent re-render.
1228
+ const HistoryRow = memo(function HistoryRow({
1229
+ message, steeredSegment, segmentShowAvatar, attachedActivity, startsTurn, separated,
1230
+ threadId, agentConfig, userProfile, showToolEvents, contextWindowTokens, isLatest, onRetry,
1231
+ }: {
1232
+ message: Message;
1233
+ steeredSegment?: SteeredSegment;
1234
+ segmentShowAvatar: boolean;
1235
+ attachedActivity?: AutomationActivityMetadata;
1236
+ startsTurn: boolean;
1237
+ separated: boolean;
1238
+ threadId: string | null;
1239
+ agentConfig?: AgentConfig | null;
1240
+ userProfile?: UserProfile | null;
1241
+ showToolEvents: boolean;
1242
+ contextWindowTokens: number | null;
1243
+ isLatest: boolean;
1244
+ onRetry?: Props["onRetryMessage"];
1245
+ }) {
1246
+ return (
1247
+ <>
1248
+ {steeredSegment && (
1249
+ <StreamingBubble
1250
+ content={steeredSegment.content}
1251
+ threadId={threadId}
1252
+ agentConfig={agentConfig ?? null}
1253
+ showAvatar={segmentShowAvatar}
1254
+ />
1255
+ )}
1256
+ <div
1257
+ id={typeof message.seq === "number" ? String(message.seq) : `msg-${message.id}`}
1258
+ data-message-id={message.id}
1259
+ data-hot-candidate="1"
1260
+ data-message-seq={message.seq}
1261
+ data-created-at={message.created_at}
1262
+ className={separated ? "mt-3" : undefined}
1263
+ >
1264
+ <MessageBubble
1265
+ message={message}
1266
+ attachedActivity={attachedActivity}
1267
+ threadId={threadId}
1268
+ agentConfig={agentConfig}
1269
+ userProfile={userProfile}
1270
+ showAvatar={startsTurn}
1271
+ showToolEvents={showToolEvents}
1272
+ contextWindowTokens={contextWindowTokens}
1273
+ isLatest={isLatest}
1274
+ onRetry={onRetry}
1275
+ />
1276
+ </div>
1277
+ </>
1278
+ );
1279
+ });
1280
+
1281
+ // Keep the synthetic message identity stable when only the parent updates.
1248
1282
  const StreamingBubble = memo(function StreamingBubble({
1249
1283
  content,
1250
1284
  threadId,