@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.
- package/.claude/hooks/gh-setup.sh +49 -0
- package/.claude/settings.json +15 -0
- package/.claude/skills/release-notes/SKILL.md +331 -0
- package/.editorconfig +10 -0
- package/.github/FUNDING.yml +2 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -0
- package/.github/ISSUE_TEMPLATE/config.yml +11 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +59 -0
- package/.github/copilot-instructions.md +45 -0
- package/.github/pull_request_template.md +32 -0
- package/.github/workflows/ci.yaml +25 -0
- package/.github/workflows/docs.yml +58 -0
- package/.github/workflows/relay_to_openclaw.yml +59 -0
- package/.github/workflows/release.yaml +45 -0
- package/.prettierignore +10 -0
- package/.prettierrc +13 -0
- package/.vscode/extensions.json +7 -0
- package/.vscode/settings.json +37 -0
- package/.zed/settings.json +42 -0
- package/AGENTS.md +330 -0
- package/ARCHITECTURE.md +390 -0
- package/CONTRIBUTING.md +216 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.ja.md +121 -0
- package/README.md +125 -0
- package/docs/.vitepress/config.mts +124 -0
- package/docs/.vitepress/theme/custom.css +111 -0
- package/docs/.vitepress/theme/index.ts +4 -0
- package/docs/agent-setup/claude-code.md +84 -0
- package/docs/agent-setup/codex.md +76 -0
- package/docs/agent-setup/custom-agents.md +67 -0
- package/docs/agent-setup/gemini-cli.md +99 -0
- package/docs/agent-setup/index.md +34 -0
- package/docs/announcements/gemini-cli-deprecation.md +73 -0
- package/docs/getting-started/index.md +78 -0
- package/docs/getting-started/quick-start.md +38 -0
- package/docs/help/faq.md +181 -0
- package/docs/help/troubleshooting.md +221 -0
- package/docs/index.md +63 -0
- package/docs/public/apple-touch-icon.png +0 -0
- package/docs/public/demo.mp4 +0 -0
- package/docs/public/favicon-16x16.png +0 -0
- package/docs/public/favicon-32x32.png +0 -0
- package/docs/public/favicon.ico +0 -0
- package/docs/public/images/editing.webp +0 -0
- package/docs/public/images/export.webp +0 -0
- package/docs/public/images/floating-chat-button.webp +0 -0
- package/docs/public/images/floating-chat-instance-menu.webp +0 -0
- package/docs/public/images/floating-chat-view.webp +0 -0
- package/docs/public/images/mode-selection.webp +0 -0
- package/docs/public/images/model-selection.webp +0 -0
- package/docs/public/images/multi-session.webp +0 -0
- package/docs/public/images/remove-image.webp +0 -0
- package/docs/public/images/ribbon-icon.webp +0 -0
- package/docs/public/images/selection-context.gif +0 -0
- package/docs/public/images/sending-images.webp +0 -0
- package/docs/public/images/sending-messages.webp +0 -0
- package/docs/public/images/session-history-button.webp +0 -0
- package/docs/public/images/slash-commands-1.webp +0 -0
- package/docs/public/images/slash-commands-2.webp +0 -0
- package/docs/public/images/switch-agent.webp +0 -0
- package/docs/public/images/switch-default-agent.webp +0 -0
- package/docs/public/images/temporary-disable.gif +0 -0
- package/docs/reference/acp-support.md +110 -0
- package/docs/usage/chat-export.md +80 -0
- package/docs/usage/commands.md +51 -0
- package/docs/usage/context-files.md +57 -0
- package/docs/usage/editing.md +69 -0
- package/docs/usage/floating-chat.md +84 -0
- package/docs/usage/index.md +97 -0
- package/docs/usage/mcp-tools.md +33 -0
- package/docs/usage/mentions.md +70 -0
- package/docs/usage/mode-selection.md +28 -0
- package/docs/usage/model-selection.md +32 -0
- package/docs/usage/multi-session.md +68 -0
- package/docs/usage/sending-images.md +64 -0
- package/docs/usage/session-history.md +91 -0
- package/docs/usage/slash-commands.md +44 -0
- package/esbuild.config.mjs +49 -0
- package/eslint.config.mjs +25 -0
- package/main.js +228 -0
- package/manifest.json +11 -0
- package/package.json +52 -0
- package/src/acp/acp-client.ts +921 -0
- package/src/acp/acp-handler.ts +252 -0
- package/src/acp/permission-handler.ts +282 -0
- package/src/acp/terminal-handler.ts +264 -0
- package/src/acp/type-converter.ts +272 -0
- package/src/hooks/useAgent.ts +250 -0
- package/src/hooks/useAgentMessages.ts +470 -0
- package/src/hooks/useAgentSession.ts +544 -0
- package/src/hooks/useChatActions.ts +400 -0
- package/src/hooks/useHistoryModal.ts +219 -0
- package/src/hooks/useSessionHistory.ts +863 -0
- package/src/hooks/useSettings.ts +19 -0
- package/src/hooks/useSuggestions.ts +342 -0
- package/src/main.ts +9 -0
- package/src/plugin.ts +1126 -0
- package/src/services/chat-exporter.ts +552 -0
- package/src/services/message-sender.ts +755 -0
- package/src/services/message-state.ts +375 -0
- package/src/services/session-helpers.ts +211 -0
- package/src/services/session-state.ts +130 -0
- package/src/services/session-storage.ts +267 -0
- package/src/services/settings-normalizer.ts +255 -0
- package/src/services/settings-service.ts +285 -0
- package/src/services/update-checker.ts +128 -0
- package/src/services/vault-service.ts +558 -0
- package/src/services/view-registry.ts +345 -0
- package/src/types/agent.ts +92 -0
- package/src/types/chat.ts +351 -0
- package/src/types/errors.ts +136 -0
- package/src/types/obsidian-internals.d.ts +14 -0
- package/src/types/session.ts +731 -0
- package/src/ui/ChangeDirectoryModal.ts +137 -0
- package/src/ui/ChatContext.ts +25 -0
- package/src/ui/ChatHeader.tsx +295 -0
- package/src/ui/ChatPanel.tsx +1162 -0
- package/src/ui/ChatView.tsx +348 -0
- package/src/ui/ErrorBanner.tsx +104 -0
- package/src/ui/FloatingButton.tsx +351 -0
- package/src/ui/FloatingChatView.tsx +531 -0
- package/src/ui/InputArea.tsx +1107 -0
- package/src/ui/InputToolbar.tsx +371 -0
- package/src/ui/MessageBubble.tsx +442 -0
- package/src/ui/MessageList.tsx +265 -0
- package/src/ui/PermissionBanner.tsx +61 -0
- package/src/ui/SessionHistoryModal.tsx +821 -0
- package/src/ui/SettingsTab.ts +1337 -0
- package/src/ui/SuggestionPopup.tsx +138 -0
- package/src/ui/TerminalBlock.tsx +107 -0
- package/src/ui/ToolCallBlock.tsx +456 -0
- package/src/ui/shared/AttachmentStrip.tsx +57 -0
- package/src/ui/shared/IconButton.tsx +55 -0
- package/src/ui/shared/MarkdownRenderer.tsx +103 -0
- package/src/ui/view-host.ts +56 -0
- package/src/utils/error-utils.ts +274 -0
- package/src/utils/logger.ts +44 -0
- package/src/utils/mention-parser.ts +129 -0
- package/src/utils/paths.ts +246 -0
- package/src/utils/platform.ts +425 -0
- package/styles.css +2322 -0
- package/tsconfig.json +18 -0
- package/version-bump.mjs +18 -0
- 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
|
+
}
|