@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,250 @@
1
+ /**
2
+ * Hook for managing the complete agent interaction lifecycle.
3
+ *
4
+ * This is a facade that composes useAgentSession and useAgentMessages,
5
+ * providing a unified API to ChatPanel.
6
+ */
7
+
8
+ import * as React from "react";
9
+ const { useState, useCallback, useEffect, useMemo } = React;
10
+
11
+ import type { SessionUpdate } from "../types/session";
12
+ import type { AcpClient } from "../acp/acp-client";
13
+ import type { IVaultAccess } from "../services/vault-service";
14
+ import type { ISettingsAccess } from "../services/settings-service";
15
+ import type { ErrorInfo } from "../types/errors";
16
+ import type { IMentionService } from "../utils/mention-parser";
17
+ import { useAgentSession } from "./useAgentSession";
18
+ import { useAgentMessages, type SendMessageOptions } from "./useAgentMessages";
19
+
20
+ // Re-export types that ChatPanel uses
21
+ export type { SendMessageOptions } from "./useAgentMessages";
22
+ export type { AgentDisplayInfo } from "../services/session-helpers";
23
+
24
+ // ============================================================================
25
+ // Types
26
+ // ============================================================================
27
+
28
+ import type { ChatMessage, ActivePermission } from "../types/chat";
29
+ import type {
30
+ ChatSession,
31
+ SessionModeState,
32
+ SessionModelState,
33
+ SessionConfigOption,
34
+ } from "../types/session";
35
+ import type { AgentDisplayInfo } from "../services/session-helpers";
36
+
37
+ /**
38
+ * Return type for useAgent hook.
39
+ */
40
+ export interface UseAgentReturn {
41
+ // Session state
42
+ session: ChatSession;
43
+ isReady: boolean;
44
+
45
+ // Message state
46
+ messages: ChatMessage[];
47
+ isSending: boolean;
48
+ lastUserMessage: string | null;
49
+
50
+ // Combined error
51
+ errorInfo: ErrorInfo | null;
52
+
53
+ // Session lifecycle
54
+ createSession: (
55
+ overrideAgentId?: string,
56
+ overrideCwd?: string,
57
+ ) => Promise<void>;
58
+ restartSession: (
59
+ newAgentId?: string,
60
+ overrideCwd?: string,
61
+ ) => Promise<void>;
62
+ closeSession: () => Promise<void>;
63
+ forceRestartAgent: () => Promise<void>;
64
+ cancelOperation: () => Promise<void>;
65
+ getAvailableAgents: () => AgentDisplayInfo[];
66
+ updateSessionFromLoad: (
67
+ sessionId: string,
68
+ modes?: SessionModeState,
69
+ models?: SessionModelState,
70
+ configOptions?: SessionConfigOption[],
71
+ ) => Promise<void>;
72
+
73
+ // Config
74
+ setMode: (modeId: string) => Promise<void>;
75
+ setModel: (modelId: string) => Promise<void>;
76
+ setConfigOption: (configId: string, value: string) => Promise<void>;
77
+
78
+ // Message operations
79
+ sendMessage: (
80
+ content: string,
81
+ options: SendMessageOptions,
82
+ ) => Promise<void>;
83
+ clearMessages: () => void;
84
+ setInitialMessages: (
85
+ history: Array<{
86
+ role: string;
87
+ content: Array<{ type: string; text: string }>;
88
+ timestamp?: string;
89
+ }>,
90
+ ) => void;
91
+ setMessagesFromLocal: (localMessages: ChatMessage[]) => void;
92
+ clearError: () => void;
93
+ setIgnoreUpdates: (ignore: boolean) => void;
94
+ // Permission
95
+ activePermission: ActivePermission | null;
96
+ hasActivePermission: boolean;
97
+ approvePermission: (requestId: string, optionId: string) => Promise<void>;
98
+ approveActivePermission: () => Promise<boolean>;
99
+ rejectActivePermission: () => Promise<boolean>;
100
+ }
101
+
102
+ // ============================================================================
103
+ // Hook Implementation
104
+ // ============================================================================
105
+
106
+ /**
107
+ * @param agentClient - Agent client for communication
108
+ * @param settingsAccess - Settings access for agent configuration
109
+ * @param vaultAccess - Vault access for reading notes (also serves as IMentionService)
110
+ * @param workingDirectory - Working directory for the session
111
+ * @param initialAgentId - Optional initial agent ID (from view persistence)
112
+ */
113
+ export function useAgent(
114
+ agentClient: AcpClient,
115
+ settingsAccess: ISettingsAccess,
116
+ vaultAccess: IVaultAccess & IMentionService,
117
+ workingDirectory: string,
118
+ initialAgentId?: string,
119
+ ): UseAgentReturn {
120
+ // ============================================================
121
+ // Shared Error State
122
+ // ============================================================
123
+
124
+ const [errorInfo, setErrorInfo] = useState<ErrorInfo | null>(null);
125
+
126
+ // ============================================================
127
+ // Sub-hooks
128
+ // ============================================================
129
+
130
+ const agentSession = useAgentSession(
131
+ agentClient,
132
+ settingsAccess,
133
+ workingDirectory,
134
+ setErrorInfo,
135
+ initialAgentId,
136
+ );
137
+
138
+ const agentMessages = useAgentMessages(
139
+ agentClient,
140
+ settingsAccess,
141
+ vaultAccess,
142
+ agentSession.session,
143
+ setErrorInfo,
144
+ );
145
+
146
+ // ============================================================
147
+ // Unified Session Update Handler
148
+ // ============================================================
149
+
150
+ const handleSessionUpdate = useCallback(
151
+ (update: SessionUpdate) => {
152
+ // Session-level updates (commands, mode, config, usage, error)
153
+ agentSession.handleSessionUpdate(update);
154
+
155
+ // Message-level updates (batched via RAF, ignoreUpdates checked internally)
156
+ agentMessages.enqueueUpdate(update);
157
+ },
158
+ [agentSession.handleSessionUpdate, agentMessages.enqueueUpdate],
159
+ );
160
+
161
+ // Composed cancel: session-level cancel + message-level RAF cleanup
162
+ const cancelOperation = useCallback(async () => {
163
+ await agentSession.cancelOperation();
164
+ agentMessages.clearPendingUpdates();
165
+ }, [agentSession.cancelOperation, agentMessages.clearPendingUpdates]);
166
+
167
+ // Subscribe to all updates from agent
168
+ useEffect(() => {
169
+ const unsubscribe = agentClient.onSessionUpdate(handleSessionUpdate);
170
+ return unsubscribe;
171
+ }, [agentClient, handleSessionUpdate]);
172
+
173
+ // ============================================================
174
+ // Return
175
+ // ============================================================
176
+
177
+ return useMemo(
178
+ () => ({
179
+ // Session state
180
+ session: agentSession.session,
181
+ isReady: agentSession.isReady,
182
+
183
+ // Message state
184
+ messages: agentMessages.messages,
185
+ isSending: agentMessages.isSending,
186
+ lastUserMessage: agentMessages.lastUserMessage,
187
+
188
+ // Combined error
189
+ errorInfo,
190
+
191
+ // Session lifecycle
192
+ createSession: agentSession.createSession,
193
+ restartSession: agentSession.restartSession,
194
+ closeSession: agentSession.closeSession,
195
+ forceRestartAgent: agentSession.forceRestartAgent,
196
+ cancelOperation,
197
+ getAvailableAgents: agentSession.getAvailableAgents,
198
+ updateSessionFromLoad: agentSession.updateSessionFromLoad,
199
+
200
+ // Config
201
+ setMode: agentSession.setMode,
202
+ setModel: agentSession.setModel,
203
+ setConfigOption: agentSession.setConfigOption,
204
+
205
+ // Message operations
206
+ sendMessage: agentMessages.sendMessage,
207
+ clearMessages: agentMessages.clearMessages,
208
+ setInitialMessages: agentMessages.setInitialMessages,
209
+ setMessagesFromLocal: agentMessages.setMessagesFromLocal,
210
+ clearError: agentMessages.clearError,
211
+ setIgnoreUpdates: agentMessages.setIgnoreUpdates,
212
+
213
+ // Permission
214
+ activePermission: agentMessages.activePermission,
215
+ hasActivePermission: agentMessages.hasActivePermission,
216
+ approvePermission: agentMessages.approvePermission,
217
+ approveActivePermission: agentMessages.approveActivePermission,
218
+ rejectActivePermission: agentMessages.rejectActivePermission,
219
+ }),
220
+ [
221
+ agentSession.session,
222
+ agentSession.isReady,
223
+ agentMessages.messages,
224
+ agentMessages.isSending,
225
+ agentMessages.lastUserMessage,
226
+ errorInfo,
227
+ agentSession.createSession,
228
+ agentSession.restartSession,
229
+ agentSession.closeSession,
230
+ agentSession.forceRestartAgent,
231
+ cancelOperation,
232
+ agentSession.getAvailableAgents,
233
+ agentSession.updateSessionFromLoad,
234
+ agentSession.setMode,
235
+ agentSession.setModel,
236
+ agentSession.setConfigOption,
237
+ agentMessages.sendMessage,
238
+ agentMessages.clearMessages,
239
+ agentMessages.setInitialMessages,
240
+ agentMessages.setMessagesFromLocal,
241
+ agentMessages.clearError,
242
+ agentMessages.setIgnoreUpdates,
243
+ agentMessages.activePermission,
244
+ agentMessages.hasActivePermission,
245
+ agentMessages.approvePermission,
246
+ agentMessages.approveActivePermission,
247
+ agentMessages.rejectActivePermission,
248
+ ],
249
+ );
250
+ }
@@ -0,0 +1,470 @@
1
+ /**
2
+ * Sub-hook for managing chat messages, streaming, and permissions.
3
+ *
4
+ * Handles message state, RAF batching for streaming updates,
5
+ * send/receive operations, and permission approve/reject.
6
+ */
7
+
8
+ import * as React from "react";
9
+ const { useState, useCallback, useMemo, useRef, useEffect } = React;
10
+
11
+ import type {
12
+ ChatMessage,
13
+ MessageContent,
14
+ ActivePermission,
15
+ ImagePromptContent,
16
+ ResourceLinkPromptContent,
17
+ } from "../types/chat";
18
+ import type { ChatSession, SessionUpdate } from "../types/session";
19
+ import type { AcpClient } from "../acp/acp-client";
20
+ import type { IVaultAccess, NoteMetadata } from "../services/vault-service";
21
+ import type { ISettingsAccess } from "../services/settings-service";
22
+ import type { ErrorInfo } from "../types/errors";
23
+ import type { IMentionService } from "../utils/mention-parser";
24
+ import { preparePrompt, sendPreparedPrompt } from "../services/message-sender";
25
+ import { extractErrorMessage } from "../utils/error-utils";
26
+ import { Platform } from "obsidian";
27
+ import {
28
+ rebuildToolCallIndex,
29
+ applySingleUpdate,
30
+ findActivePermission,
31
+ selectOption,
32
+ } from "../services/message-state";
33
+
34
+ // ============================================================================
35
+ // Types
36
+ // ============================================================================
37
+
38
+ /**
39
+ * Options for sending a message.
40
+ */
41
+ export interface SendMessageOptions {
42
+ /** Currently active note for auto-mention */
43
+ activeNote: NoteMetadata | null;
44
+ /** Vault base path for mention resolution */
45
+ vaultBasePath: string;
46
+ /** Whether auto-mention is temporarily disabled */
47
+ isAutoMentionDisabled?: boolean;
48
+ /** Attached images (Base64 embedded) */
49
+ images?: ImagePromptContent[];
50
+ /** Attached file references (resource links) */
51
+ resourceLinks?: ResourceLinkPromptContent[];
52
+ }
53
+
54
+ export interface UseAgentMessagesReturn {
55
+ // Message state
56
+ messages: ChatMessage[];
57
+ isSending: boolean;
58
+ lastUserMessage: string | null;
59
+
60
+ // Message operations
61
+ sendMessage: (
62
+ content: string,
63
+ options: SendMessageOptions,
64
+ ) => Promise<void>;
65
+ clearMessages: () => void;
66
+ setInitialMessages: (
67
+ history: Array<{
68
+ role: string;
69
+ content: Array<{ type: string; text: string }>;
70
+ timestamp?: string;
71
+ }>,
72
+ ) => void;
73
+ setMessagesFromLocal: (localMessages: ChatMessage[]) => void;
74
+ clearError: () => void;
75
+ setIgnoreUpdates: (ignore: boolean) => void;
76
+ /** Discard any pending RAF updates and reset streaming state (call after stop/cancel). */
77
+ clearPendingUpdates: () => void;
78
+
79
+ // Permission
80
+ activePermission: ActivePermission | null;
81
+ hasActivePermission: boolean;
82
+ approvePermission: (requestId: string, optionId: string) => Promise<void>;
83
+ approveActivePermission: () => Promise<boolean>;
84
+ rejectActivePermission: () => Promise<boolean>;
85
+
86
+ /** Enqueue a message-level update (used by useAgent for unified handler) */
87
+ enqueueUpdate: (update: SessionUpdate) => void;
88
+ }
89
+
90
+ // ============================================================================
91
+ // Hook Implementation
92
+ // ============================================================================
93
+
94
+ export function useAgentMessages(
95
+ agentClient: AcpClient,
96
+ settingsAccess: ISettingsAccess,
97
+ vaultAccess: IVaultAccess & IMentionService,
98
+ session: ChatSession,
99
+ setErrorInfo: (error: ErrorInfo | null) => void,
100
+ ): UseAgentMessagesReturn {
101
+ // ============================================================
102
+ // Message State
103
+ // ============================================================
104
+
105
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
106
+ const [isSending, setIsSending] = useState(false);
107
+ const [lastUserMessage, setLastUserMessage] = useState<string | null>(null);
108
+
109
+ // Tool call index: toolCallId → message index for O(1) lookup
110
+ const toolCallIndexRef = useRef<Map<string, number>>(new Map());
111
+
112
+ // Ignore updates flag (used during session/load to skip history replay)
113
+ const ignoreUpdatesRef = useRef(false);
114
+
115
+ // Generation counter to prevent stale async callbacks from overwriting
116
+ // state after cancel/stop followed by a new send. Each sendMessage()
117
+ // increments this; completion handlers only update state if the
118
+ // generation hasn't changed (fixes Issue #200).
119
+ const generationRef = useRef(0);
120
+
121
+ // Track the current send promise so a new sendMessage() can wait for
122
+ // the previous one to settle before starting (avoids interleaved sends).
123
+ const sendPromiseRef = useRef<Promise<void> | null>(null);
124
+
125
+ // ============================================================
126
+ // Streaming Update Batching
127
+ // ============================================================
128
+
129
+ const pendingUpdatesRef = useRef<SessionUpdate[]>([]);
130
+ const flushScheduledRef = useRef(false);
131
+
132
+ const flushPendingUpdates = useCallback(() => {
133
+ flushScheduledRef.current = false;
134
+ const updates = pendingUpdatesRef.current;
135
+ if (updates.length === 0) return;
136
+ pendingUpdatesRef.current = [];
137
+
138
+ setMessages((prev) => {
139
+ let result = prev;
140
+ for (const update of updates) {
141
+ result = applySingleUpdate(
142
+ result,
143
+ update,
144
+ toolCallIndexRef.current,
145
+ );
146
+ }
147
+ return result;
148
+ });
149
+ }, []);
150
+
151
+ const enqueueUpdate = useCallback(
152
+ (update: SessionUpdate) => {
153
+ if (ignoreUpdatesRef.current) return;
154
+ pendingUpdatesRef.current.push(update);
155
+ if (!flushScheduledRef.current) {
156
+ flushScheduledRef.current = true;
157
+ window.requestAnimationFrame(flushPendingUpdates);
158
+ }
159
+ },
160
+ [flushPendingUpdates],
161
+ );
162
+
163
+ // Clean up on unmount
164
+ useEffect(() => {
165
+ return () => {
166
+ pendingUpdatesRef.current = [];
167
+ flushScheduledRef.current = false;
168
+ toolCallIndexRef.current.clear();
169
+ };
170
+ }, []);
171
+
172
+ // ============================================================
173
+ // Message Operations
174
+ // ============================================================
175
+
176
+ const addMessage = useCallback((message: ChatMessage): void => {
177
+ setMessages((prev) => [...prev, message]);
178
+ }, []);
179
+
180
+ const setIgnoreUpdates = useCallback((ignore: boolean): void => {
181
+ ignoreUpdatesRef.current = ignore;
182
+ }, []);
183
+
184
+ /** Discard any pending RAF updates and reset the streaming flag. */
185
+ const clearPendingUpdates = useCallback((): void => {
186
+ pendingUpdatesRef.current = [];
187
+ flushScheduledRef.current = false;
188
+ setIsSending(false);
189
+ }, []);
190
+
191
+ const clearMessages = useCallback((): void => {
192
+ setMessages([]);
193
+ toolCallIndexRef.current.clear();
194
+ setLastUserMessage(null);
195
+ setIsSending(false);
196
+ setErrorInfo(null);
197
+ }, [setErrorInfo]);
198
+
199
+ const setInitialMessages = useCallback(
200
+ (
201
+ history: Array<{
202
+ role: string;
203
+ content: Array<{ type: string; text: string }>;
204
+ timestamp?: string;
205
+ }>,
206
+ ): void => {
207
+ const chatMessages: ChatMessage[] = history.map((msg) => ({
208
+ id: crypto.randomUUID(),
209
+ role: msg.role as "user" | "assistant",
210
+ content: msg.content.map((c) => ({
211
+ type: c.type as "text",
212
+ text: c.text,
213
+ })),
214
+ timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
215
+ }));
216
+
217
+ setMessages(chatMessages);
218
+ rebuildToolCallIndex(chatMessages, toolCallIndexRef.current);
219
+ setIsSending(false);
220
+ setErrorInfo(null);
221
+ },
222
+ [setErrorInfo],
223
+ );
224
+
225
+ const setMessagesFromLocal = useCallback(
226
+ (localMessages: ChatMessage[]): void => {
227
+ setMessages(localMessages);
228
+ rebuildToolCallIndex(localMessages, toolCallIndexRef.current);
229
+ setIsSending(false);
230
+ setErrorInfo(null);
231
+ },
232
+ [setErrorInfo],
233
+ );
234
+
235
+ const clearError = useCallback((): void => {
236
+ setErrorInfo(null);
237
+ }, [setErrorInfo]);
238
+
239
+ const shouldConvertToWsl = useMemo(() => {
240
+ const settings = settingsAccess.getSnapshot();
241
+ return Platform.isWin && settings.windowsWslMode;
242
+ }, [settingsAccess]);
243
+
244
+ const sendMessage = useCallback(
245
+ async (content: string, options: SendMessageOptions): Promise<void> => {
246
+ if (!session.sessionId) {
247
+ setErrorInfo({
248
+ title: "Cannot Send Message",
249
+ message: "No active session. Please wait for connection.",
250
+ });
251
+ return;
252
+ }
253
+
254
+ // Wait for any in-flight send to settle (e.g. after cancel/stop)
255
+ // before starting a new one to avoid interleaved state updates.
256
+ if (sendPromiseRef.current) {
257
+ try { await sendPromiseRef.current; } catch { /* ignore */ }
258
+ }
259
+
260
+ const currentSessionId = session.sessionId;
261
+ const generation = ++generationRef.current;
262
+ const settings = settingsAccess.getSnapshot();
263
+
264
+ const prepared = await preparePrompt(
265
+ {
266
+ message: content,
267
+ images: options.images,
268
+ resourceLinks: options.resourceLinks,
269
+ activeNote: options.activeNote,
270
+ vaultBasePath: options.vaultBasePath,
271
+ isAutoMentionDisabled: options.isAutoMentionDisabled,
272
+ convertToWsl: shouldConvertToWsl,
273
+ supportsEmbeddedContext:
274
+ session.promptCapabilities?.embeddedContext ?? false,
275
+ maxNoteLength: settings.displaySettings.maxNoteLength,
276
+ maxSelectionLength:
277
+ settings.displaySettings.maxSelectionLength,
278
+ },
279
+ vaultAccess,
280
+ vaultAccess, // IMentionService (same object)
281
+ );
282
+
283
+ const userMessageContent: MessageContent[] = [];
284
+
285
+ if (prepared.autoMentionContext) {
286
+ userMessageContent.push({
287
+ type: "text_with_context",
288
+ text: content,
289
+ autoMentionContext: prepared.autoMentionContext,
290
+ });
291
+ } else {
292
+ userMessageContent.push({
293
+ type: "text",
294
+ text: content,
295
+ });
296
+ }
297
+
298
+ if (options.images && options.images.length > 0) {
299
+ for (const img of options.images) {
300
+ userMessageContent.push({
301
+ type: "image",
302
+ data: img.data,
303
+ mimeType: img.mimeType,
304
+ });
305
+ }
306
+ }
307
+
308
+ if (options.resourceLinks && options.resourceLinks.length > 0) {
309
+ for (const link of options.resourceLinks) {
310
+ userMessageContent.push({
311
+ type: "resource_link",
312
+ uri: link.uri,
313
+ name: link.name,
314
+ mimeType: link.mimeType,
315
+ size: link.size,
316
+ });
317
+ }
318
+ }
319
+
320
+ const userMessage: ChatMessage = {
321
+ id: crypto.randomUUID(),
322
+ role: "user",
323
+ content: userMessageContent,
324
+ timestamp: new Date(),
325
+ };
326
+ addMessage(userMessage);
327
+
328
+ setIsSending(true);
329
+ setLastUserMessage(content);
330
+
331
+ const sendPromise = (async () => {
332
+ try {
333
+ const result = await sendPreparedPrompt(
334
+ {
335
+ sessionId: currentSessionId,
336
+ agentContent: prepared.agentContent,
337
+ displayContent: prepared.displayContent,
338
+ authMethods: session.authMethods,
339
+ },
340
+ agentClient,
341
+ );
342
+
343
+ // Discard results if a newer send has started
344
+ if (generationRef.current !== generation) return;
345
+
346
+ if (result.success) {
347
+ setIsSending(false);
348
+ setLastUserMessage(null);
349
+ } else {
350
+ setIsSending(false);
351
+ setErrorInfo(
352
+ result.error
353
+ ? {
354
+ title: result.error.title,
355
+ message: result.error.message,
356
+ suggestion: result.error.suggestion,
357
+ }
358
+ : {
359
+ title: "Send Message Failed",
360
+ message: "Failed to send message",
361
+ },
362
+ );
363
+ }
364
+ } catch (error) {
365
+ if (generationRef.current !== generation) return;
366
+ setIsSending(false);
367
+ setErrorInfo({
368
+ title: "Send Message Failed",
369
+ message: `Failed to send message: ${extractErrorMessage(error)}`,
370
+ });
371
+ }
372
+ })();
373
+
374
+ sendPromiseRef.current = sendPromise;
375
+ try {
376
+ await sendPromise;
377
+ } catch {
378
+ // Error already handled inside sendPromise
379
+ } finally {
380
+ sendPromiseRef.current = null;
381
+ }
382
+ },
383
+ [
384
+ agentClient,
385
+ vaultAccess,
386
+ settingsAccess,
387
+ session.sessionId,
388
+ session.authMethods,
389
+ session.promptCapabilities,
390
+ shouldConvertToWsl,
391
+ addMessage,
392
+ setErrorInfo,
393
+ ],
394
+ );
395
+
396
+ // ============================================================
397
+ // Permission State & Operations
398
+ // ============================================================
399
+
400
+ const activePermission = useMemo(
401
+ () => findActivePermission(messages),
402
+ [messages],
403
+ );
404
+
405
+ const hasActivePermission = activePermission !== null;
406
+
407
+ const approvePermission = useCallback(
408
+ async (requestId: string, optionId: string): Promise<void> => {
409
+ try {
410
+ await agentClient.respondToPermission(requestId, optionId);
411
+ } catch (error) {
412
+ setErrorInfo({
413
+ title: "Permission Error",
414
+ message: `Failed to respond to permission request: ${extractErrorMessage(error)}`,
415
+ });
416
+ }
417
+ },
418
+ [agentClient, setErrorInfo],
419
+ );
420
+
421
+ const approveActivePermission = useCallback(async (): Promise<boolean> => {
422
+ if (!activePermission || activePermission.options.length === 0)
423
+ return false;
424
+ const option = selectOption(activePermission.options, [
425
+ "allow_once",
426
+ "allow_always",
427
+ ]);
428
+ if (!option) return false;
429
+ await approvePermission(activePermission.requestId, option.optionId);
430
+ return true;
431
+ }, [activePermission, approvePermission]);
432
+
433
+ const rejectActivePermission = useCallback(async (): Promise<boolean> => {
434
+ if (!activePermission || activePermission.options.length === 0)
435
+ return false;
436
+ const option = selectOption(
437
+ activePermission.options,
438
+ ["reject_once", "reject_always"],
439
+ (opt) =>
440
+ opt.name.toLowerCase().includes("reject") ||
441
+ opt.name.toLowerCase().includes("deny"),
442
+ );
443
+ if (!option) return false;
444
+ await approvePermission(activePermission.requestId, option.optionId);
445
+ return true;
446
+ }, [activePermission, approvePermission]);
447
+
448
+ // ============================================================
449
+ // Return
450
+ // ============================================================
451
+
452
+ return {
453
+ messages,
454
+ isSending,
455
+ lastUserMessage,
456
+ sendMessage,
457
+ clearMessages,
458
+ setInitialMessages,
459
+ setMessagesFromLocal,
460
+ clearError,
461
+ setIgnoreUpdates,
462
+ clearPendingUpdates,
463
+ activePermission,
464
+ hasActivePermission,
465
+ approvePermission,
466
+ approveActivePermission,
467
+ rejectActivePermission,
468
+ enqueueUpdate,
469
+ };
470
+ }