@mseep/obsidian-agent-client 0.10.6

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 (146) hide show
  1. package/.claude/hooks/gh-setup.sh +49 -0
  2. package/.claude/settings.json +15 -0
  3. package/.claude/skills/release-notes/SKILL.md +331 -0
  4. package/.editorconfig +10 -0
  5. package/.github/FUNDING.yml +2 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -0
  7. package/.github/ISSUE_TEMPLATE/config.yml +11 -0
  8. package/.github/ISSUE_TEMPLATE/feature_request.yml +59 -0
  9. package/.github/copilot-instructions.md +45 -0
  10. package/.github/pull_request_template.md +32 -0
  11. package/.github/workflows/ci.yaml +25 -0
  12. package/.github/workflows/docs.yml +58 -0
  13. package/.github/workflows/relay_to_openclaw.yml +59 -0
  14. package/.github/workflows/release.yaml +45 -0
  15. package/.prettierignore +10 -0
  16. package/.prettierrc +13 -0
  17. package/.vscode/extensions.json +7 -0
  18. package/.vscode/settings.json +37 -0
  19. package/.zed/settings.json +42 -0
  20. package/AGENTS.md +330 -0
  21. package/ARCHITECTURE.md +390 -0
  22. package/CONTRIBUTING.md +216 -0
  23. package/LICENSE +202 -0
  24. package/NOTICE +2 -0
  25. package/README.ja.md +121 -0
  26. package/README.md +125 -0
  27. package/docs/.vitepress/config.mts +124 -0
  28. package/docs/.vitepress/theme/custom.css +111 -0
  29. package/docs/.vitepress/theme/index.ts +4 -0
  30. package/docs/agent-setup/claude-code.md +84 -0
  31. package/docs/agent-setup/codex.md +76 -0
  32. package/docs/agent-setup/custom-agents.md +67 -0
  33. package/docs/agent-setup/gemini-cli.md +99 -0
  34. package/docs/agent-setup/index.md +34 -0
  35. package/docs/announcements/gemini-cli-deprecation.md +73 -0
  36. package/docs/getting-started/index.md +78 -0
  37. package/docs/getting-started/quick-start.md +38 -0
  38. package/docs/help/faq.md +181 -0
  39. package/docs/help/troubleshooting.md +221 -0
  40. package/docs/index.md +63 -0
  41. package/docs/public/apple-touch-icon.png +0 -0
  42. package/docs/public/demo.mp4 +0 -0
  43. package/docs/public/favicon-16x16.png +0 -0
  44. package/docs/public/favicon-32x32.png +0 -0
  45. package/docs/public/favicon.ico +0 -0
  46. package/docs/public/images/editing.webp +0 -0
  47. package/docs/public/images/export.webp +0 -0
  48. package/docs/public/images/floating-chat-button.webp +0 -0
  49. package/docs/public/images/floating-chat-instance-menu.webp +0 -0
  50. package/docs/public/images/floating-chat-view.webp +0 -0
  51. package/docs/public/images/mode-selection.webp +0 -0
  52. package/docs/public/images/model-selection.webp +0 -0
  53. package/docs/public/images/multi-session.webp +0 -0
  54. package/docs/public/images/remove-image.webp +0 -0
  55. package/docs/public/images/ribbon-icon.webp +0 -0
  56. package/docs/public/images/selection-context.gif +0 -0
  57. package/docs/public/images/sending-images.webp +0 -0
  58. package/docs/public/images/sending-messages.webp +0 -0
  59. package/docs/public/images/session-history-button.webp +0 -0
  60. package/docs/public/images/slash-commands-1.webp +0 -0
  61. package/docs/public/images/slash-commands-2.webp +0 -0
  62. package/docs/public/images/switch-agent.webp +0 -0
  63. package/docs/public/images/switch-default-agent.webp +0 -0
  64. package/docs/public/images/temporary-disable.gif +0 -0
  65. package/docs/reference/acp-support.md +110 -0
  66. package/docs/usage/chat-export.md +80 -0
  67. package/docs/usage/commands.md +51 -0
  68. package/docs/usage/context-files.md +57 -0
  69. package/docs/usage/editing.md +69 -0
  70. package/docs/usage/floating-chat.md +84 -0
  71. package/docs/usage/index.md +97 -0
  72. package/docs/usage/mcp-tools.md +33 -0
  73. package/docs/usage/mentions.md +70 -0
  74. package/docs/usage/mode-selection.md +28 -0
  75. package/docs/usage/model-selection.md +32 -0
  76. package/docs/usage/multi-session.md +68 -0
  77. package/docs/usage/sending-images.md +64 -0
  78. package/docs/usage/session-history.md +91 -0
  79. package/docs/usage/slash-commands.md +44 -0
  80. package/esbuild.config.mjs +49 -0
  81. package/eslint.config.mjs +25 -0
  82. package/main.js +228 -0
  83. package/manifest.json +11 -0
  84. package/package.json +52 -0
  85. package/src/acp/acp-client.ts +921 -0
  86. package/src/acp/acp-handler.ts +252 -0
  87. package/src/acp/permission-handler.ts +282 -0
  88. package/src/acp/terminal-handler.ts +264 -0
  89. package/src/acp/type-converter.ts +272 -0
  90. package/src/hooks/useAgent.ts +250 -0
  91. package/src/hooks/useAgentMessages.ts +470 -0
  92. package/src/hooks/useAgentSession.ts +544 -0
  93. package/src/hooks/useChatActions.ts +400 -0
  94. package/src/hooks/useHistoryModal.ts +219 -0
  95. package/src/hooks/useSessionHistory.ts +863 -0
  96. package/src/hooks/useSettings.ts +19 -0
  97. package/src/hooks/useSuggestions.ts +342 -0
  98. package/src/main.ts +9 -0
  99. package/src/plugin.ts +1126 -0
  100. package/src/services/chat-exporter.ts +552 -0
  101. package/src/services/message-sender.ts +755 -0
  102. package/src/services/message-state.ts +375 -0
  103. package/src/services/session-helpers.ts +211 -0
  104. package/src/services/session-state.ts +130 -0
  105. package/src/services/session-storage.ts +267 -0
  106. package/src/services/settings-normalizer.ts +255 -0
  107. package/src/services/settings-service.ts +285 -0
  108. package/src/services/update-checker.ts +128 -0
  109. package/src/services/vault-service.ts +558 -0
  110. package/src/services/view-registry.ts +345 -0
  111. package/src/types/agent.ts +92 -0
  112. package/src/types/chat.ts +351 -0
  113. package/src/types/errors.ts +136 -0
  114. package/src/types/obsidian-internals.d.ts +14 -0
  115. package/src/types/session.ts +731 -0
  116. package/src/ui/ChangeDirectoryModal.ts +137 -0
  117. package/src/ui/ChatContext.ts +25 -0
  118. package/src/ui/ChatHeader.tsx +295 -0
  119. package/src/ui/ChatPanel.tsx +1162 -0
  120. package/src/ui/ChatView.tsx +348 -0
  121. package/src/ui/ErrorBanner.tsx +104 -0
  122. package/src/ui/FloatingButton.tsx +351 -0
  123. package/src/ui/FloatingChatView.tsx +531 -0
  124. package/src/ui/InputArea.tsx +1107 -0
  125. package/src/ui/InputToolbar.tsx +371 -0
  126. package/src/ui/MessageBubble.tsx +442 -0
  127. package/src/ui/MessageList.tsx +265 -0
  128. package/src/ui/PermissionBanner.tsx +61 -0
  129. package/src/ui/SessionHistoryModal.tsx +821 -0
  130. package/src/ui/SettingsTab.ts +1337 -0
  131. package/src/ui/SuggestionPopup.tsx +138 -0
  132. package/src/ui/TerminalBlock.tsx +107 -0
  133. package/src/ui/ToolCallBlock.tsx +456 -0
  134. package/src/ui/shared/AttachmentStrip.tsx +57 -0
  135. package/src/ui/shared/IconButton.tsx +55 -0
  136. package/src/ui/shared/MarkdownRenderer.tsx +103 -0
  137. package/src/ui/view-host.ts +56 -0
  138. package/src/utils/error-utils.ts +274 -0
  139. package/src/utils/logger.ts +44 -0
  140. package/src/utils/mention-parser.ts +129 -0
  141. package/src/utils/paths.ts +246 -0
  142. package/src/utils/platform.ts +425 -0
  143. package/styles.css +2322 -0
  144. package/tsconfig.json +18 -0
  145. package/version-bump.mjs +18 -0
  146. package/versions.json +3 -0
@@ -0,0 +1,442 @@
1
+ import * as React from "react";
2
+ const { useState, useCallback } = React;
3
+ import { setIcon } from "obsidian";
4
+ import type { ChatMessage, MessageContent } from "../types/chat";
5
+ import type { AcpClient } from "../acp/acp-client";
6
+ import type AgentClientPlugin from "../plugin";
7
+ import { MarkdownRenderer } from "./shared/MarkdownRenderer";
8
+ import { TerminalBlock } from "./TerminalBlock";
9
+ import { ToolCallBlock } from "./ToolCallBlock";
10
+ import { LucideIcon } from "./shared/IconButton";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // TextWithMentions (internal helper)
14
+ // ---------------------------------------------------------------------------
15
+
16
+ interface TextWithMentionsProps {
17
+ text: string;
18
+ plugin: AgentClientPlugin;
19
+ autoMentionContext?: {
20
+ noteName: string;
21
+ notePath: string;
22
+ selection?: {
23
+ fromLine: number;
24
+ toLine: number;
25
+ };
26
+ };
27
+ }
28
+
29
+ // Function to render text with @mentions and optional auto-mention
30
+ function TextWithMentions({
31
+ text,
32
+ plugin,
33
+ autoMentionContext,
34
+ }: TextWithMentionsProps): React.ReactElement {
35
+ // Match @[[filename]] format only
36
+ const mentionRegex = /@\[\[([^\]]+)\]\]/g;
37
+ const parts: React.ReactNode[] = [];
38
+
39
+ // Add auto-mention badge first if provided
40
+ if (autoMentionContext) {
41
+ const displayText = autoMentionContext.selection
42
+ ? `@${autoMentionContext.noteName}:${autoMentionContext.selection.fromLine}-${autoMentionContext.selection.toLine}`
43
+ : `@${autoMentionContext.noteName}`;
44
+
45
+ parts.push(
46
+ <span
47
+ key="auto-mention"
48
+ className="agent-client-text-mention"
49
+ onClick={() => {
50
+ void plugin.app.workspace.openLinkText(
51
+ autoMentionContext.notePath,
52
+ "",
53
+ );
54
+ }}
55
+ >
56
+ {displayText}
57
+ </span>,
58
+ );
59
+ parts.push("\n");
60
+ }
61
+
62
+ let lastIndex = 0;
63
+ let match;
64
+
65
+ while ((match = mentionRegex.exec(text)) !== null) {
66
+ // Add text before the mention
67
+ if (match.index > lastIndex) {
68
+ parts.push(text.slice(lastIndex, match.index));
69
+ }
70
+
71
+ // Extract filename from [[brackets]]
72
+ const noteName = match[1];
73
+
74
+ // Check if file actually exists
75
+ const file = plugin.app.vault
76
+ .getMarkdownFiles()
77
+ .find((f) => f.basename === noteName);
78
+
79
+ if (file) {
80
+ // File exists - render as clickable mention
81
+ parts.push(
82
+ <span
83
+ key={match.index}
84
+ className="agent-client-text-mention"
85
+ onClick={() => {
86
+ void plugin.app.workspace.openLinkText(file.path, "");
87
+ }}
88
+ >
89
+ @{noteName}
90
+ </span>,
91
+ );
92
+ } else {
93
+ // File doesn't exist - render as plain text
94
+ parts.push(`@${noteName}`);
95
+ }
96
+
97
+ lastIndex = match.index + match[0].length;
98
+ }
99
+
100
+ // Add any remaining text
101
+ if (lastIndex < text.length) {
102
+ parts.push(text.slice(lastIndex));
103
+ }
104
+
105
+ return <div className="agent-client-text-with-mentions">{parts}</div>;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // CollapsibleThought (internal helper)
110
+ // ---------------------------------------------------------------------------
111
+
112
+ interface CollapsibleThoughtProps {
113
+ text: string;
114
+ plugin: AgentClientPlugin;
115
+ }
116
+
117
+ function CollapsibleThought({ text, plugin }: CollapsibleThoughtProps) {
118
+ const [isExpanded, setIsExpanded] = useState(false);
119
+ const showEmojis = plugin.settings.displaySettings.showEmojis;
120
+
121
+ return (
122
+ <div
123
+ className="agent-client-collapsible-thought"
124
+ onClick={() => setIsExpanded(!isExpanded)}
125
+ >
126
+ <div className="agent-client-collapsible-thought-header">
127
+ {showEmojis && (
128
+ <LucideIcon
129
+ name="lightbulb"
130
+ className="agent-client-collapsible-thought-label-icon"
131
+ />
132
+ )}
133
+ Thinking
134
+ <LucideIcon
135
+ name={isExpanded ? "chevron-down" : "chevron-right"}
136
+ className="agent-client-collapsible-thought-icon"
137
+ />
138
+ </div>
139
+ {isExpanded && (
140
+ <div className="agent-client-collapsible-thought-content">
141
+ <MarkdownRenderer text={text} plugin={plugin} />
142
+ </div>
143
+ )}
144
+ </div>
145
+ );
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // ContentBlock (internal helper, formerly MessageContentRenderer)
150
+ // ---------------------------------------------------------------------------
151
+
152
+ interface ContentBlockProps {
153
+ content: MessageContent;
154
+ plugin: AgentClientPlugin;
155
+ messageRole?: "user" | "assistant";
156
+ terminalClient?: AcpClient;
157
+ /** Callback to approve a permission request */
158
+ onApprovePermission?: (
159
+ requestId: string,
160
+ optionId: string,
161
+ ) => Promise<void>;
162
+ }
163
+
164
+ function ContentBlock({
165
+ content,
166
+ plugin,
167
+ messageRole,
168
+ terminalClient,
169
+ onApprovePermission,
170
+ }: ContentBlockProps) {
171
+ switch (content.type) {
172
+ case "text":
173
+ // User messages: render with mention support
174
+ // Assistant messages: render as markdown
175
+ if (messageRole === "user") {
176
+ return <TextWithMentions text={content.text} plugin={plugin} />;
177
+ }
178
+ return <MarkdownRenderer text={content.text} plugin={plugin} />;
179
+
180
+ case "text_with_context":
181
+ // User messages with auto-mention context
182
+ return (
183
+ <TextWithMentions
184
+ text={content.text}
185
+ autoMentionContext={content.autoMentionContext}
186
+ plugin={plugin}
187
+ />
188
+ );
189
+
190
+ case "agent_thought":
191
+ return <CollapsibleThought text={content.text} plugin={plugin} />;
192
+
193
+ case "tool_call":
194
+ return (
195
+ <ToolCallBlock
196
+ content={content}
197
+ plugin={plugin}
198
+ terminalClient={terminalClient}
199
+ onApprovePermission={onApprovePermission}
200
+ />
201
+ );
202
+
203
+ case "plan": {
204
+ const showEmojis = plugin.settings.displaySettings.showEmojis;
205
+ return (
206
+ <div className="agent-client-message-plan">
207
+ <div className="agent-client-message-plan-title">
208
+ {showEmojis && (
209
+ <LucideIcon
210
+ name="list-checks"
211
+ className="agent-client-message-plan-label-icon"
212
+ />
213
+ )}
214
+ Plan
215
+ </div>
216
+ {content.entries.map((entry, idx) => (
217
+ <div
218
+ key={idx}
219
+ className={`agent-client-message-plan-entry agent-client-plan-status-${entry.status}`}
220
+ >
221
+ {showEmojis && (
222
+ <span
223
+ className={`agent-client-message-plan-entry-icon agent-client-status-${entry.status}`}
224
+ >
225
+ <LucideIcon
226
+ name={
227
+ entry.status === "completed"
228
+ ? "check"
229
+ : entry.status === "in_progress"
230
+ ? "loader"
231
+ : "circle"
232
+ }
233
+ />
234
+ </span>
235
+ )}{" "}
236
+ {entry.content}
237
+ </div>
238
+ ))}
239
+ </div>
240
+ );
241
+ }
242
+
243
+ case "terminal":
244
+ return (
245
+ <TerminalBlock
246
+ terminalId={content.terminalId}
247
+ terminalClient={terminalClient || null}
248
+ />
249
+ );
250
+
251
+ case "image":
252
+ return (
253
+ <div className="agent-client-message-image">
254
+ <img
255
+ src={`data:${content.mimeType};base64,${content.data}`}
256
+ alt="Attached image"
257
+ className="agent-client-message-image-thumbnail"
258
+ />
259
+ </div>
260
+ );
261
+
262
+ case "resource_link":
263
+ return (
264
+ <div className="agent-client-message-resource-link">
265
+ <span
266
+ className="agent-client-message-resource-link-icon"
267
+ ref={(el) => {
268
+ if (el) setIcon(el, "file");
269
+ }}
270
+ />
271
+ <span className="agent-client-message-resource-link-name">
272
+ {content.name}
273
+ </span>
274
+ </div>
275
+ );
276
+
277
+ default:
278
+ return <span>Unsupported content type</span>;
279
+ }
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // MessageBubble (exported, formerly MessageRenderer)
284
+ // ---------------------------------------------------------------------------
285
+
286
+ export interface MessageBubbleProps {
287
+ message: ChatMessage;
288
+ plugin: AgentClientPlugin;
289
+ terminalClient?: AcpClient;
290
+ /** Callback to approve a permission request */
291
+ onApprovePermission?: (
292
+ requestId: string,
293
+ optionId: string,
294
+ ) => Promise<void>;
295
+ }
296
+
297
+ /**
298
+ * Extract plain text from message contents for clipboard copy.
299
+ */
300
+ function extractTextContent(contents: MessageContent[]): string {
301
+ return contents
302
+ .filter((c) => c.type === "text" || c.type === "text_with_context")
303
+ .map((c) => ("text" in c ? c.text : ""))
304
+ .join("\n");
305
+ }
306
+
307
+ /**
308
+ * Copy button that shows a check icon briefly after copying.
309
+ * Uses callback ref for Obsidian's setIcon DOM manipulation.
310
+ */
311
+ function CopyButton({ contents }: { contents: MessageContent[] }) {
312
+ const [copied, setCopied] = useState(false);
313
+
314
+ const handleCopy = useCallback(() => {
315
+ const text = extractTextContent(contents);
316
+ if (!text) return;
317
+ void navigator.clipboard
318
+ .writeText(text)
319
+ .then(() => {
320
+ setCopied(true);
321
+ window.setTimeout(() => setCopied(false), 2000);
322
+ })
323
+ .catch(() => {});
324
+ }, [contents]);
325
+
326
+ const iconRef = useCallback(
327
+ (el: HTMLButtonElement | null) => {
328
+ if (el) setIcon(el, copied ? "check" : "copy");
329
+ },
330
+ [copied],
331
+ );
332
+
333
+ return (
334
+ <button
335
+ className="clickable-icon agent-client-message-action-button"
336
+ onClick={handleCopy}
337
+ aria-label="Copy message"
338
+ ref={iconRef}
339
+ />
340
+ );
341
+ }
342
+
343
+ /**
344
+ * Group consecutive image/resource_link contents together for horizontal display.
345
+ * Non-attachment contents are wrapped individually.
346
+ */
347
+ function groupContent(
348
+ contents: MessageContent[],
349
+ ): Array<
350
+ | { type: "attachments"; items: MessageContent[] }
351
+ | { type: "single"; item: MessageContent }
352
+ > {
353
+ const groups: Array<
354
+ | { type: "attachments"; items: MessageContent[] }
355
+ | { type: "single"; item: MessageContent }
356
+ > = [];
357
+
358
+ let currentAttachmentGroup: MessageContent[] = [];
359
+
360
+ for (const content of contents) {
361
+ if (content.type === "image" || content.type === "resource_link") {
362
+ currentAttachmentGroup.push(content);
363
+ } else {
364
+ // Flush any pending attachment group
365
+ if (currentAttachmentGroup.length > 0) {
366
+ groups.push({
367
+ type: "attachments",
368
+ items: currentAttachmentGroup,
369
+ });
370
+ currentAttachmentGroup = [];
371
+ }
372
+ groups.push({ type: "single", item: content });
373
+ }
374
+ }
375
+
376
+ // Flush remaining attachments
377
+ if (currentAttachmentGroup.length > 0) {
378
+ groups.push({ type: "attachments", items: currentAttachmentGroup });
379
+ }
380
+
381
+ return groups;
382
+ }
383
+
384
+ export const MessageBubble = React.memo(function MessageBubble({
385
+ message,
386
+ plugin,
387
+ terminalClient,
388
+ onApprovePermission,
389
+ }: MessageBubbleProps) {
390
+ const groups = groupContent(message.content);
391
+
392
+ return (
393
+ <div
394
+ className={`agent-client-message-renderer ${message.role === "user" ? "agent-client-message-user" : "agent-client-message-assistant"}`}
395
+ >
396
+ {groups.map((group, idx) => {
397
+ if (group.type === "attachments") {
398
+ // Render attachments (images + resource_links) in horizontal strip
399
+ return (
400
+ <div
401
+ key={idx}
402
+ className="agent-client-message-images-strip"
403
+ >
404
+ {group.items.map((content, imgIdx) => (
405
+ <ContentBlock
406
+ key={imgIdx}
407
+ content={content}
408
+ plugin={plugin}
409
+ messageRole={message.role}
410
+ terminalClient={terminalClient}
411
+ onApprovePermission={onApprovePermission}
412
+ />
413
+ ))}
414
+ </div>
415
+ );
416
+ } else {
417
+ // Render single non-image content
418
+ return (
419
+ <div key={idx}>
420
+ <ContentBlock
421
+ content={group.item}
422
+ plugin={plugin}
423
+ messageRole={message.role}
424
+ terminalClient={terminalClient}
425
+ onApprovePermission={onApprovePermission}
426
+ />
427
+ </div>
428
+ );
429
+ }
430
+ })}
431
+ {message.content.some(
432
+ (c) =>
433
+ (c.type === "text" || c.type === "text_with_context") &&
434
+ c.text,
435
+ ) && (
436
+ <div className="agent-client-message-actions">
437
+ <CopyButton contents={message.content} />
438
+ </div>
439
+ )}
440
+ </div>
441
+ );
442
+ });
@@ -0,0 +1,265 @@
1
+ import * as React from "react";
2
+ const { useRef, useState, useEffect, useCallback } = React;
3
+
4
+ import type { ChatMessage } from "../types/chat";
5
+ import type { AcpClient } from "../acp/acp-client";
6
+ import type AgentClientPlugin from "../plugin";
7
+ import type { IChatViewHost } from "./view-host";
8
+ import { setIcon } from "obsidian";
9
+ import { MessageBubble } from "./MessageBubble";
10
+ import { useVirtualizer } from "@tanstack/react-virtual";
11
+
12
+ /**
13
+ * Props for MessageList component
14
+ */
15
+ export interface MessageListProps {
16
+ /** All messages in the current chat session */
17
+ messages: ChatMessage[];
18
+ /** Whether a message is currently being sent */
19
+ isSending: boolean;
20
+ /** Whether the session is ready for user input */
21
+ isSessionReady: boolean;
22
+ /** Whether a session is being restored (load/resume/fork) */
23
+ isRestoringSession: boolean;
24
+ /** Display name of the active agent */
25
+ agentLabel: string;
26
+ /** Plugin instance */
27
+ plugin: AgentClientPlugin;
28
+ /** View instance for event registration */
29
+ view: IChatViewHost;
30
+ /** Terminal client for output polling */
31
+ terminalClient?: AcpClient;
32
+ /** Callback to approve a permission request */
33
+ onApprovePermission?: (
34
+ requestId: string,
35
+ optionId: string,
36
+ ) => Promise<void>;
37
+ /** Whether a permission request is currently pending */
38
+ hasActivePermission: boolean;
39
+ }
40
+
41
+ /**
42
+ * Messages container component with virtualized rendering.
43
+ *
44
+ * Uses @tanstack/react-virtual to only render messages visible in the viewport,
45
+ * dramatically improving performance for long conversations.
46
+ *
47
+ * Handles:
48
+ * - Virtualized message list rendering
49
+ * - Auto-scroll behavior (follows new content when at bottom)
50
+ * - Empty state display
51
+ * - Loading indicator
52
+ */
53
+ export function MessageList({
54
+ messages,
55
+ isSending,
56
+ isSessionReady,
57
+ isRestoringSession,
58
+ agentLabel,
59
+ plugin,
60
+ view,
61
+ terminalClient,
62
+ onApprovePermission,
63
+ hasActivePermission,
64
+ }: MessageListProps) {
65
+ const containerRef = useRef<HTMLDivElement>(null);
66
+ const [isAtBottom, setIsAtBottom] = useState(true);
67
+ const isAtBottomRef = useRef(true);
68
+ const prevIsSendingRef = useRef(false);
69
+
70
+ // ============================================================
71
+ // Virtualizer
72
+ // ============================================================
73
+ const virtualizer = useVirtualizer({
74
+ count: messages.length,
75
+ getScrollElement: () => containerRef.current,
76
+ estimateSize: () => 80,
77
+ overscan: 5,
78
+ });
79
+
80
+ // Suppress scroll position correction when user has scrolled up.
81
+ // By default, the virtualizer adjusts scrollTop when an item before
82
+ // the scroll offset changes size (to keep visible content stable).
83
+ // During streaming, this causes the viewport to creep down as the
84
+ // last message grows. Our auto-scroll effect handles following new
85
+ // content when isAtBottom, so corrections are only needed there.
86
+ virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () =>
87
+ isAtBottomRef.current;
88
+
89
+ // ============================================================
90
+ // Scroll management
91
+ // ============================================================
92
+
93
+ /**
94
+ * Check if the scroll position is near the bottom.
95
+ */
96
+ const checkIfAtBottom = useCallback(() => {
97
+ const container = containerRef.current;
98
+ if (!container) return true;
99
+
100
+ const threshold = 35;
101
+ const isNearBottom =
102
+ container.scrollTop + container.clientHeight >=
103
+ container.scrollHeight - threshold;
104
+ isAtBottomRef.current = isNearBottom;
105
+ setIsAtBottom(isNearBottom);
106
+ return isNearBottom;
107
+ }, []);
108
+
109
+ // Reset scroll state when messages are cleared (new chat)
110
+ useEffect(() => {
111
+ if (messages.length === 0) {
112
+ setIsAtBottom(true);
113
+ isAtBottomRef.current = true;
114
+ }
115
+ }, [messages.length]);
116
+
117
+ // Track when user just sent a message (for smooth scroll)
118
+ const scrollSmoothRef = useRef(false);
119
+ useEffect(() => {
120
+ if (isSending && !prevIsSendingRef.current) {
121
+ // User just sent a message — next scroll should be smooth
122
+ scrollSmoothRef.current = true;
123
+ }
124
+ prevIsSendingRef.current = isSending;
125
+ }, [isSending]);
126
+
127
+ // Auto-scroll to bottom when new messages arrive or content changes
128
+ useEffect(() => {
129
+ if (messages.length === 0) return;
130
+
131
+ if (scrollSmoothRef.current) {
132
+ // User sent a message — smooth scroll regardless of isAtBottom
133
+ scrollSmoothRef.current = false;
134
+ window.requestAnimationFrame(() => {
135
+ virtualizer.scrollToIndex(messages.length - 1, {
136
+ align: "end",
137
+ behavior: "smooth",
138
+ });
139
+ });
140
+ return;
141
+ }
142
+
143
+ if (isAtBottomRef.current) {
144
+ // Use requestAnimationFrame to ensure virtualizer has measured
145
+ window.requestAnimationFrame(() => {
146
+ virtualizer.scrollToIndex(messages.length - 1, {
147
+ align: "end",
148
+ });
149
+ });
150
+ }
151
+ }, [messages, virtualizer]);
152
+
153
+ // Set up scroll event listener for isAtBottom detection
154
+ useEffect(() => {
155
+ const container = containerRef.current;
156
+ if (!container) return;
157
+
158
+ const handleScroll = () => {
159
+ checkIfAtBottom();
160
+ };
161
+
162
+ view.registerDomEvent(container, "scroll", handleScroll);
163
+
164
+ // Initial check
165
+ checkIfAtBottom();
166
+ }, [view, checkIfAtBottom]);
167
+
168
+ // ============================================================
169
+ // Render
170
+ // ============================================================
171
+
172
+ // Empty state
173
+ if (messages.length === 0) {
174
+ return (
175
+ <div ref={containerRef} className="agent-client-chat-view-messages">
176
+ <div className="agent-client-chat-empty-state">
177
+ {isRestoringSession
178
+ ? "Restoring session..."
179
+ : !isSessionReady
180
+ ? `Connecting to ${agentLabel}...`
181
+ : `Start a conversation with ${agentLabel}...`}
182
+ </div>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ const virtualItems = virtualizer.getVirtualItems();
188
+
189
+ return (
190
+ <div ref={containerRef} className="agent-client-chat-view-messages">
191
+ {/* Virtualized message list */}
192
+ <div
193
+ className="agent-client-virtual-list-inner"
194
+ style={{
195
+ height: virtualizer.getTotalSize(),
196
+ position: "relative",
197
+ }}
198
+ >
199
+ {virtualItems.map((virtualItem) => {
200
+ const message = messages[virtualItem.index];
201
+ return (
202
+ <div
203
+ key={message.id}
204
+ ref={virtualizer.measureElement}
205
+ data-index={virtualItem.index}
206
+ className="agent-client-virtual-item"
207
+ style={{
208
+ position: "absolute",
209
+ top: 0,
210
+ left: 0,
211
+ width: "100%",
212
+ transform: `translateY(${virtualItem.start}px)`,
213
+ }}
214
+ >
215
+ <MessageBubble
216
+ message={message}
217
+ plugin={plugin}
218
+ terminalClient={terminalClient}
219
+ onApprovePermission={onApprovePermission}
220
+ />
221
+ </div>
222
+ );
223
+ })}
224
+ </div>
225
+
226
+ {/* Loading indicator — outside virtualizer */}
227
+ <div
228
+ className={`agent-client-loading-indicator ${!isSending ? "agent-client-hidden" : ""}`}
229
+ >
230
+ <div className="agent-client-loading-dots">
231
+ <div className="agent-client-loading-dot"></div>
232
+ <div className="agent-client-loading-dot"></div>
233
+ <div className="agent-client-loading-dot"></div>
234
+ <div className="agent-client-loading-dot"></div>
235
+ <div className="agent-client-loading-dot"></div>
236
+ <div className="agent-client-loading-dot"></div>
237
+ <div className="agent-client-loading-dot"></div>
238
+ <div className="agent-client-loading-dot"></div>
239
+ <div className="agent-client-loading-dot"></div>
240
+ </div>
241
+ {hasActivePermission && (
242
+ <span className="agent-client-loading-status">
243
+ Waiting for permission...
244
+ </span>
245
+ )}
246
+ </div>
247
+
248
+ {/* Scroll to bottom button */}
249
+ {!isAtBottom && (
250
+ <button
251
+ className="agent-client-scroll-to-bottom"
252
+ onClick={() => {
253
+ virtualizer.scrollToIndex(messages.length - 1, {
254
+ align: "end",
255
+ behavior: "smooth",
256
+ });
257
+ }}
258
+ ref={(el) => {
259
+ if (el) setIcon(el, "chevron-down");
260
+ }}
261
+ />
262
+ )}
263
+ </div>
264
+ );
265
+ }