@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,863 @@
1
+ import { useState, useCallback, useRef, useMemo } from "react";
2
+ import type { AcpClient } from "../acp/acp-client";
3
+ import type { ISettingsAccess } from "../services/settings-service";
4
+ import type {
5
+ SessionInfo,
6
+ ListSessionsResult,
7
+ SavedSessionInfo,
8
+ ChatSession,
9
+ SessionModeState,
10
+ SessionModelState,
11
+ SessionConfigOption,
12
+ AgentCapabilities,
13
+ } from "../types/session";
14
+ import type { ChatMessage } from "../types/chat";
15
+ import { extractErrorMessage } from "../utils/error-utils";
16
+
17
+ // ============================================================================
18
+ // Session Capability Helpers (from session-capability-utils.ts)
19
+ // ============================================================================
20
+
21
+ interface SessionCapabilityFlags {
22
+ /** Whether session/load is supported (stable) */
23
+ canLoad: boolean;
24
+ /** Whether session/resume is supported (unstable) */
25
+ canResume: boolean;
26
+ /** Whether session/fork is supported (unstable) */
27
+ canFork: boolean;
28
+ /** Whether session/list is supported (unstable) */
29
+ canList: boolean;
30
+ }
31
+
32
+ function getSessionCapabilityFlags(
33
+ agentCapabilities?: AgentCapabilities,
34
+ ): SessionCapabilityFlags {
35
+ const sessionCaps = agentCapabilities?.sessionCapabilities;
36
+ return {
37
+ canLoad: agentCapabilities?.loadSession === true,
38
+ canResume: sessionCaps?.resume !== undefined,
39
+ canFork: sessionCaps?.fork !== undefined,
40
+ canList: sessionCaps?.list !== undefined,
41
+ };
42
+ }
43
+
44
+ // ============================================================================
45
+ // Types
46
+ // ============================================================================
47
+
48
+ /**
49
+ * Callback invoked when a session is successfully loaded/resumed/forked.
50
+ * Provides the loaded session metadata to integrate with chat state.
51
+ *
52
+ * Note: Conversation history for load is received via session/update notifications,
53
+ * not via this callback.
54
+ */
55
+ export interface SessionLoadCallback {
56
+ /**
57
+ * @param sessionId - ID of the session (new session ID for fork)
58
+ * @param modes - Available modes from the session
59
+ * @param models - Available models from the session
60
+ * @param configOptions - Config options from the session
61
+ */
62
+ (
63
+ sessionId: string,
64
+ modes?: SessionModeState,
65
+ models?: SessionModelState,
66
+ configOptions?: SessionConfigOption[],
67
+ ): void;
68
+ }
69
+
70
+ /**
71
+ * Callback invoked when messages should be restored from local storage.
72
+ * Used for resume/fork operations where the agent doesn't return history.
73
+ */
74
+ export interface MessagesRestoreCallback {
75
+ /**
76
+ * @param messages - Messages to restore
77
+ */
78
+ (messages: ChatMessage[]): void;
79
+ }
80
+
81
+ /**
82
+ * Options for useSessionHistory hook.
83
+ */
84
+ export interface UseSessionHistoryOptions {
85
+ /** Agent client for session operations */
86
+ agentClient: AcpClient;
87
+ /** Current session (used to access agentCapabilities and agentId) */
88
+ session: ChatSession;
89
+ /** Settings access for local session storage */
90
+ settingsAccess: ISettingsAccess;
91
+ /** Vault root path — used for session list filtering */
92
+ cwd: string;
93
+ /** Agent working directory — used for saving new session metadata */
94
+ agentCwd: string;
95
+ /** Callback invoked when a session is loaded/resumed/forked */
96
+ onSessionLoad: SessionLoadCallback;
97
+ /** Callback invoked when messages should be restored from local storage */
98
+ onMessagesRestore?: MessagesRestoreCallback;
99
+ /** Control whether useMessages ignores incoming updates (for history replay suppression) */
100
+ onIgnoreUpdates?: (ignore: boolean) => void;
101
+ /** Clear messages before restoring from local storage */
102
+ onClearMessages?: () => void;
103
+ }
104
+
105
+ /**
106
+ * Return type for useSessionHistory hook.
107
+ */
108
+ export interface UseSessionHistoryReturn {
109
+ /** List of sessions */
110
+ sessions: SessionInfo[];
111
+ /** Whether sessions are being fetched */
112
+ loading: boolean;
113
+ /** Error message if fetch fails */
114
+ error: string | null;
115
+ /** Whether there are more sessions to load */
116
+ hasMore: boolean;
117
+
118
+ // Capability flags (from session.agentCapabilities)
119
+ /** Whether session history UI should be shown */
120
+ canShowSessionHistory: boolean;
121
+ /** Whether session can be restored (load or resume supported) */
122
+ canRestore: boolean;
123
+ /** Whether session/fork is supported (unstable) */
124
+ canFork: boolean;
125
+ /** Whether session/list is supported (unstable) */
126
+ canList: boolean;
127
+ /** Whether sessions are from local storage (agent doesn't support list) */
128
+ isUsingLocalSessions: boolean;
129
+
130
+ /** Set of session IDs that have local data (for UI filtering) */
131
+ localSessionIds: Set<string>;
132
+
133
+ /**
134
+ * Fetch sessions list from agent.
135
+ * Replaces existing sessions in state.
136
+ * @param cwd - Optional working directory filter
137
+ */
138
+ fetchSessions: (cwd?: string) => Promise<void>;
139
+
140
+ /**
141
+ * Load more sessions (pagination).
142
+ * Appends to existing sessions list.
143
+ */
144
+ loadMoreSessions: () => Promise<void>;
145
+
146
+ /**
147
+ * Restore a specific session by ID.
148
+ * Uses load if available (with history replay), otherwise resume (without history replay).
149
+ * Only available if canRestore is true.
150
+ * @param sessionId - Session to restore
151
+ * @param cwd - Working directory for the session
152
+ */
153
+ restoreSession: (sessionId: string, cwd: string) => Promise<void>;
154
+
155
+ /**
156
+ * Fork a specific session to create a new branch.
157
+ * Only available if canFork is true.
158
+ * @param sessionId - Session to fork
159
+ * @param cwd - Working directory for the session
160
+ */
161
+ forkSession: (sessionId: string, cwd: string) => Promise<void>;
162
+
163
+ /**
164
+ * Delete a session (local metadata + message file).
165
+ * @param sessionId - Session to delete
166
+ */
167
+ deleteSession: (sessionId: string) => Promise<void>;
168
+
169
+ /**
170
+ * Update the title of a saved session.
171
+ * @param sessionId - Session to update
172
+ * @param newTitle - New title string
173
+ * @param sessionCwd - Original cwd of the session (used when creating a new local entry)
174
+ */
175
+ updateSessionTitle: (
176
+ sessionId: string,
177
+ newTitle: string,
178
+ sessionCwd: string,
179
+ ) => Promise<void>;
180
+
181
+ /**
182
+ * Save session metadata locally.
183
+ * Called when the first message is sent in a new session.
184
+ * @param sessionId - Session ID to save
185
+ * @param messageContent - First message content (used to generate title)
186
+ */
187
+ saveSessionLocally: (
188
+ sessionId: string,
189
+ messageContent: string,
190
+ ) => Promise<void>;
191
+
192
+ /**
193
+ * Save session messages locally.
194
+ * Called when a turn ends (agent response complete).
195
+ * @param sessionId - Session ID
196
+ * @param messages - Messages to save
197
+ */
198
+ saveSessionMessages: (
199
+ sessionId: string,
200
+ messages: import("../types/chat").ChatMessage[],
201
+ ) => void;
202
+
203
+ /**
204
+ * Invalidate the session cache.
205
+ * Call this when creating a new session to refresh the list.
206
+ */
207
+ invalidateCache: () => void;
208
+ }
209
+
210
+ /**
211
+ * Cache entry for session list.
212
+ */
213
+ interface SessionCache {
214
+ sessions: SessionInfo[];
215
+ nextCursor?: string;
216
+ cwd?: string;
217
+ timestamp: number;
218
+ }
219
+
220
+ // ============================================================================
221
+ // Constants
222
+ // ============================================================================
223
+
224
+ /** Cache expiry time in milliseconds (5 minutes) */
225
+ const CACHE_EXPIRY_MS = 5 * 60 * 1000;
226
+
227
+ /**
228
+ * Merge agent sessions with locally saved titles.
229
+ * Prefers local titles over agent-provided titles for better UX.
230
+ *
231
+ * Some agents return poor quality titles (e.g., "ACP Session {id}" or
232
+ * system prompt text), so we prefer locally saved titles when available.
233
+ *
234
+ * @param agentSessions - Sessions from agent's session/list
235
+ * @param localSessions - Locally saved session metadata
236
+ * @returns Sessions with local titles merged in
237
+ */
238
+ function mergeWithLocalTitles(
239
+ agentSessions: SessionInfo[],
240
+ localSessions: SavedSessionInfo[],
241
+ ): SessionInfo[] {
242
+ // Create a map for O(1) lookup
243
+ const localMap = new Map(localSessions.map((s) => [s.sessionId, s]));
244
+
245
+ return agentSessions.map((s) => {
246
+ const local = localMap.get(s.sessionId);
247
+ return {
248
+ ...s,
249
+ title: local?.title ?? s.title,
250
+ };
251
+ });
252
+ }
253
+
254
+ // ============================================================================
255
+ // Hook Implementation
256
+ // ============================================================================
257
+
258
+ /**
259
+ * Hook for managing session history.
260
+ *
261
+ * Handles listing, loading, resuming, forking, and caching of previous chat sessions.
262
+ * Integrates with the agent client to fetch session metadata and
263
+ * load previous conversations.
264
+ *
265
+ * Capability detection is based on session.agentCapabilities, which is set
266
+ * during initialization and persists for the session lifetime.
267
+ *
268
+ * @param options - Hook options including agentClient, session, and onSessionLoad
269
+ */
270
+ export function useSessionHistory(
271
+ options: UseSessionHistoryOptions,
272
+ ): UseSessionHistoryReturn {
273
+ const {
274
+ agentClient,
275
+ session,
276
+ settingsAccess,
277
+ agentCwd,
278
+ onSessionLoad,
279
+ onMessagesRestore,
280
+ onIgnoreUpdates,
281
+ onClearMessages,
282
+ } = options;
283
+
284
+ // Derive capability flags from session.agentCapabilities
285
+ const capabilities: SessionCapabilityFlags = useMemo(
286
+ () => getSessionCapabilityFlags(session.agentCapabilities),
287
+ [session.agentCapabilities],
288
+ );
289
+
290
+ // State
291
+ const [sessions, setSessions] = useState<SessionInfo[]>([]);
292
+ const [loading, setLoading] = useState(false);
293
+ const [error, setError] = useState<string | null>(null);
294
+ const [nextCursor, setNextCursor] = useState<string | undefined>(undefined);
295
+ const [localSessionIds, setLocalSessionIds] = useState<Set<string>>(
296
+ new Set(),
297
+ );
298
+
299
+ // Cache reference (not state to avoid re-renders)
300
+ const cacheRef = useRef<SessionCache | null>(null);
301
+ const currentCwdRef = useRef<string | undefined>(undefined);
302
+
303
+ /**
304
+ * Check if cache is valid.
305
+ */
306
+ const isCacheValid = useCallback((cwd?: string): boolean => {
307
+ if (!cacheRef.current) return false;
308
+
309
+ // Check if cwd matches
310
+ if (cacheRef.current.cwd !== cwd) return false;
311
+
312
+ // Check if cache has expired
313
+ const age = Date.now() - cacheRef.current.timestamp;
314
+ return age < CACHE_EXPIRY_MS;
315
+ }, []);
316
+
317
+ /**
318
+ * Invalidate the cache.
319
+ */
320
+ const invalidateCache = useCallback(() => {
321
+ cacheRef.current = null;
322
+ }, []);
323
+
324
+ // Check if any restoration operation is available
325
+ const canPerformAnyOperation =
326
+ capabilities.canLoad || capabilities.canResume || capabilities.canFork;
327
+
328
+ /**
329
+ * Fetch sessions list from agent or local storage.
330
+ * Uses agent's session/list if supported, otherwise falls back to local storage.
331
+ * For agents that don't support restoration, local sessions are used for deletion.
332
+ * Replaces existing sessions in state.
333
+ */
334
+ const fetchSessions = useCallback(
335
+ async (cwd?: string) => {
336
+ // Use local sessions if:
337
+ // - Agent doesn't support session/list, OR
338
+ // - Agent doesn't support any restoration operation (for delete only)
339
+ const shouldUseLocalSessions =
340
+ !capabilities.canList || !canPerformAnyOperation;
341
+
342
+ if (shouldUseLocalSessions) {
343
+ // Get locally saved sessions for this agent
344
+ const localSessions = settingsAccess.getSavedSessions(
345
+ session.agentId,
346
+ cwd,
347
+ );
348
+
349
+ // Convert SavedSessionInfo to SessionInfo format
350
+ const sessionInfos: SessionInfo[] = localSessions.map((s) => ({
351
+ sessionId: s.sessionId,
352
+ cwd: s.cwd,
353
+ title: s.title,
354
+ updatedAt: s.updatedAt,
355
+ }));
356
+
357
+ setSessions(sessionInfos);
358
+ setLocalSessionIds(
359
+ new Set(localSessions.map((s) => s.sessionId)),
360
+ );
361
+ setNextCursor(undefined); // No pagination for local sessions
362
+ setError(null);
363
+ return;
364
+ }
365
+
366
+ // Check cache first
367
+ if (isCacheValid(cwd)) {
368
+ // Update localSessionIds even on cache hit
369
+ const localSessions = settingsAccess.getSavedSessions(
370
+ session.agentId,
371
+ cwd,
372
+ );
373
+ setLocalSessionIds(
374
+ new Set(localSessions.map((s) => s.sessionId)),
375
+ );
376
+ // Re-merge with local titles to pick up newly saved session titles
377
+ const sessionsWithLocalTitles = mergeWithLocalTitles(
378
+ cacheRef.current!.sessions,
379
+ localSessions,
380
+ );
381
+ setSessions(sessionsWithLocalTitles);
382
+ setNextCursor(cacheRef.current!.nextCursor);
383
+ setError(null);
384
+ return;
385
+ }
386
+
387
+ setLoading(true);
388
+ setError(null);
389
+ currentCwdRef.current = cwd;
390
+
391
+ try {
392
+ const result: ListSessionsResult =
393
+ await agentClient.listSessions(cwd);
394
+
395
+ // Merge with local titles for better UX
396
+ // (some agents return poor quality titles)
397
+ const localSessions = settingsAccess.getSavedSessions(
398
+ session.agentId,
399
+ cwd,
400
+ );
401
+ const sessionsWithLocalTitles = mergeWithLocalTitles(
402
+ result.sessions,
403
+ localSessions,
404
+ );
405
+
406
+ // Update state
407
+ setSessions(sessionsWithLocalTitles);
408
+ setLocalSessionIds(
409
+ new Set(localSessions.map((s) => s.sessionId)),
410
+ );
411
+ setNextCursor(result.nextCursor);
412
+
413
+ // Update cache (with merged titles)
414
+ cacheRef.current = {
415
+ sessions: sessionsWithLocalTitles,
416
+ nextCursor: result.nextCursor,
417
+ cwd,
418
+ timestamp: Date.now(),
419
+ };
420
+ } catch (err) {
421
+ const errorMessage = extractErrorMessage(err);
422
+ setError(`Failed to fetch sessions: ${errorMessage}`);
423
+ setSessions([]);
424
+ setNextCursor(undefined);
425
+ } finally {
426
+ setLoading(false);
427
+ }
428
+ },
429
+ [
430
+ agentClient,
431
+ capabilities.canList,
432
+ canPerformAnyOperation,
433
+ isCacheValid,
434
+ settingsAccess,
435
+ session.agentId,
436
+ ],
437
+ );
438
+
439
+ /**
440
+ * Load more sessions (pagination).
441
+ * Appends to existing sessions list.
442
+ */
443
+ const loadMoreSessions = useCallback(async () => {
444
+ // Guard: Check if there's more to load
445
+ if (!nextCursor || !capabilities.canList) {
446
+ return;
447
+ }
448
+
449
+ setLoading(true);
450
+ setError(null);
451
+
452
+ try {
453
+ const result: ListSessionsResult = await agentClient.listSessions(
454
+ currentCwdRef.current,
455
+ nextCursor,
456
+ );
457
+
458
+ // Merge with local titles for better UX
459
+ // (some agents return poor quality titles)
460
+ const localSessions = settingsAccess.getSavedSessions(
461
+ session.agentId,
462
+ currentCwdRef.current,
463
+ );
464
+ const sessionsWithLocalTitles = mergeWithLocalTitles(
465
+ result.sessions,
466
+ localSessions,
467
+ );
468
+
469
+ // Append new sessions to existing list (use functional setState)
470
+ setSessions((prev) => [...prev, ...sessionsWithLocalTitles]);
471
+ setLocalSessionIds(new Set(localSessions.map((s) => s.sessionId)));
472
+ setNextCursor(result.nextCursor);
473
+
474
+ // Update cache with appended sessions (with merged titles)
475
+ if (cacheRef.current) {
476
+ cacheRef.current = {
477
+ ...cacheRef.current,
478
+ sessions: [
479
+ ...cacheRef.current.sessions,
480
+ ...sessionsWithLocalTitles,
481
+ ],
482
+ nextCursor: result.nextCursor,
483
+ timestamp: Date.now(),
484
+ };
485
+ }
486
+ } catch (err) {
487
+ const errorMessage = extractErrorMessage(err);
488
+ setError(`Failed to load more sessions: ${errorMessage}`);
489
+ } finally {
490
+ setLoading(false);
491
+ }
492
+ }, [
493
+ agentClient,
494
+ capabilities.canList,
495
+ nextCursor,
496
+ settingsAccess,
497
+ session.agentId,
498
+ ]);
499
+
500
+ /**
501
+ * Restore a specific session by ID.
502
+ * Uses load if available (with history replay), otherwise resume (without history replay).
503
+ */
504
+ const restoreSession = useCallback(
505
+ async (sessionId: string, cwd: string) => {
506
+ setLoading(true);
507
+ setError(null);
508
+
509
+ try {
510
+ // IMPORTANT: Update session.sessionId BEFORE calling restore
511
+ // so that session/update notifications are not ignored
512
+ onSessionLoad(sessionId, undefined, undefined, undefined);
513
+
514
+ if (capabilities.canLoad) {
515
+ // Check local messages first to decide whether to use them or agent replay
516
+ const localMessages =
517
+ await settingsAccess.loadSessionMessages(sessionId);
518
+
519
+ if (localMessages && onMessagesRestore) {
520
+ // Local messages available: ignore agent replay, restore from local
521
+ onIgnoreUpdates?.(true);
522
+ onClearMessages?.();
523
+ try {
524
+ const result = await agentClient.loadSession(
525
+ sessionId,
526
+ cwd,
527
+ );
528
+ onSessionLoad(
529
+ result.sessionId,
530
+ result.modes,
531
+ result.models,
532
+ result.configOptions,
533
+ );
534
+ onMessagesRestore(localMessages);
535
+ } finally {
536
+ onIgnoreUpdates?.(false);
537
+ }
538
+ } else {
539
+ // No local messages: let agent replay flow through to UI
540
+ const result = await agentClient.loadSession(
541
+ sessionId,
542
+ cwd,
543
+ );
544
+ onSessionLoad(
545
+ result.sessionId,
546
+ result.modes,
547
+ result.models,
548
+ result.configOptions,
549
+ );
550
+ }
551
+ } else if (capabilities.canResume) {
552
+ // Use resume (without history replay, restore from local storage)
553
+ const result = await agentClient.resumeSession(
554
+ sessionId,
555
+ cwd,
556
+ );
557
+ onSessionLoad(
558
+ result.sessionId,
559
+ result.modes,
560
+ result.models,
561
+ result.configOptions,
562
+ );
563
+
564
+ // Resume doesn't return history, so restore from local storage
565
+ const localMessages =
566
+ await settingsAccess.loadSessionMessages(sessionId);
567
+ if (localMessages && onMessagesRestore) {
568
+ onMessagesRestore(localMessages);
569
+ }
570
+ } else {
571
+ throw new Error("Session restoration is not supported");
572
+ }
573
+ } catch (err) {
574
+ const errorMessage = extractErrorMessage(err);
575
+ setError(`Failed to restore session: ${errorMessage}`);
576
+ throw err; // Re-throw to allow caller to handle
577
+ } finally {
578
+ setLoading(false);
579
+ }
580
+ },
581
+ [
582
+ agentClient,
583
+ capabilities.canLoad,
584
+ capabilities.canResume,
585
+ onSessionLoad,
586
+ settingsAccess,
587
+ onMessagesRestore,
588
+ onIgnoreUpdates,
589
+ onClearMessages,
590
+ ],
591
+ );
592
+
593
+ /**
594
+ * Fork a specific session to create a new branch.
595
+ * Note: For fork, we update sessionId AFTER the call since a new session ID is created.
596
+ * Restores messages from the original session's local storage since agent doesn't return history.
597
+ */
598
+ const forkSession = useCallback(
599
+ async (sessionId: string, cwd: string) => {
600
+ setLoading(true);
601
+ setError(null);
602
+
603
+ try {
604
+ const result = await agentClient.forkSession(sessionId, cwd);
605
+
606
+ // Update with new session ID and modes/models from result
607
+ // For fork, the new session ID is returned in result
608
+ onSessionLoad(
609
+ result.sessionId,
610
+ result.modes,
611
+ result.models,
612
+ result.configOptions,
613
+ );
614
+
615
+ // Fork doesn't return history, so restore from original session's local storage
616
+ const localMessages =
617
+ await settingsAccess.loadSessionMessages(sessionId);
618
+ if (localMessages && onMessagesRestore) {
619
+ onMessagesRestore(localMessages);
620
+ }
621
+
622
+ // Save forked session to history
623
+ if (session.agentId) {
624
+ const originalSession = sessions.find(
625
+ (s) => s.sessionId === sessionId,
626
+ );
627
+ const originalTitle = originalSession?.title ?? "Session";
628
+
629
+ // Truncate title to 50 characters
630
+ const maxTitleLength = 50;
631
+ const prefix = "Fork: ";
632
+ const maxBaseLength = maxTitleLength - prefix.length;
633
+ const truncatedTitle =
634
+ originalTitle.length > maxBaseLength
635
+ ? originalTitle.substring(0, maxBaseLength) + "..."
636
+ : originalTitle;
637
+ const newTitle = `${prefix}${truncatedTitle}`;
638
+
639
+ const now = new Date().toISOString();
640
+
641
+ await settingsAccess.saveSession({
642
+ sessionId: result.sessionId,
643
+ agentId: session.agentId,
644
+ cwd,
645
+ title: newTitle,
646
+ createdAt: now,
647
+ updatedAt: now,
648
+ });
649
+
650
+ // Save messages under new session ID for restore after restart
651
+ if (localMessages) {
652
+ void settingsAccess.saveSessionMessages(
653
+ result.sessionId,
654
+ session.agentId,
655
+ localMessages,
656
+ );
657
+ }
658
+ }
659
+
660
+ // Invalidate cache since a new session was created
661
+ invalidateCache();
662
+ } catch (err) {
663
+ const errorMessage = extractErrorMessage(err);
664
+ setError(`Failed to fork session: ${errorMessage}`);
665
+ throw err; // Re-throw to allow caller to handle
666
+ } finally {
667
+ setLoading(false);
668
+ }
669
+ },
670
+ [
671
+ agentClient,
672
+ onSessionLoad,
673
+ settingsAccess,
674
+ onMessagesRestore,
675
+ invalidateCache,
676
+ session.agentId,
677
+ sessions,
678
+ ],
679
+ );
680
+
681
+ /**
682
+ * Delete a session (local metadata + message file).
683
+ * Removes from both local state and persistent storage.
684
+ */
685
+ const deleteSession = useCallback(
686
+ async (sessionId: string) => {
687
+ try {
688
+ // Delete from persistent storage (metadata + message file)
689
+ await settingsAccess.deleteSession(sessionId);
690
+
691
+ // Remove from local state
692
+ setSessions((prev) =>
693
+ prev.filter((s) => s.sessionId !== sessionId),
694
+ );
695
+
696
+ // Invalidate cache to ensure consistency
697
+ invalidateCache();
698
+ } catch (err) {
699
+ const errorMessage = extractErrorMessage(err);
700
+ setError(`Failed to delete session: ${errorMessage}`);
701
+ throw err; // Re-throw to allow caller to handle
702
+ }
703
+ },
704
+ [settingsAccess, invalidateCache],
705
+ );
706
+
707
+ /**
708
+ * Update the title of a saved session.
709
+ * Updates both local state and persistent storage.
710
+ */
711
+ const updateSessionTitle = useCallback(
712
+ async (sessionId: string, newTitle: string, sessionCwd: string) => {
713
+ // Read current title for potential rollback
714
+ const savedSessions = settingsAccess.getSavedSessions();
715
+ const existing = savedSessions.find(
716
+ (s) => s.sessionId === sessionId,
717
+ );
718
+ const previousTitle = existing?.title;
719
+
720
+ // Optimistic update
721
+ setSessions((prev) =>
722
+ prev.map((s) =>
723
+ s.sessionId === sessionId ? { ...s, title: newTitle } : s,
724
+ ),
725
+ );
726
+
727
+ try {
728
+ if (existing) {
729
+ await settingsAccess.saveSession({
730
+ ...existing,
731
+ title: newTitle,
732
+ updatedAt: new Date().toISOString(),
733
+ });
734
+ } else {
735
+ // Session exists only on agent side — create local entry
736
+ // Use sessionCwd (from SessionInfo) instead of hook's cwd
737
+ await settingsAccess.saveSession({
738
+ sessionId,
739
+ agentId: session.agentId,
740
+ cwd: sessionCwd,
741
+ title: newTitle,
742
+ createdAt: new Date().toISOString(),
743
+ updatedAt: new Date().toISOString(),
744
+ });
745
+ }
746
+
747
+ invalidateCache();
748
+ } catch (err) {
749
+ // Rollback optimistic update
750
+ setSessions((prev) =>
751
+ prev.map((s) =>
752
+ s.sessionId === sessionId
753
+ ? { ...s, title: previousTitle }
754
+ : s,
755
+ ),
756
+ );
757
+ const errorMessage = extractErrorMessage(err);
758
+ setError(`Failed to update title: ${errorMessage}`);
759
+ throw err;
760
+ }
761
+ },
762
+ [settingsAccess, session.agentId, invalidateCache],
763
+ );
764
+
765
+ /**
766
+ * Save session metadata locally.
767
+ * Called when the first message is sent in a new session.
768
+ */
769
+ const saveSessionLocally = useCallback(
770
+ async (sessionId: string, messageContent: string) => {
771
+ if (!session.agentId) return;
772
+
773
+ const title =
774
+ messageContent.length > 50
775
+ ? messageContent.substring(0, 50) + "..."
776
+ : messageContent;
777
+
778
+ await settingsAccess.saveSession({
779
+ sessionId,
780
+ agentId: session.agentId,
781
+ cwd: agentCwd,
782
+ title,
783
+ createdAt: new Date().toISOString(),
784
+ updatedAt: new Date().toISOString(),
785
+ });
786
+ },
787
+ [session.agentId, agentCwd, settingsAccess],
788
+ );
789
+
790
+ /**
791
+ * Save session messages locally.
792
+ * Called when a turn ends (agent response complete).
793
+ * Fire-and-forget (does not block UI).
794
+ */
795
+ const saveSessionMessages = useCallback(
796
+ (
797
+ sessionId: string,
798
+ messages: import("../types/chat").ChatMessage[],
799
+ ) => {
800
+ if (!session.agentId || messages.length === 0) return;
801
+
802
+ // Fire-and-forget
803
+ void settingsAccess.saveSessionMessages(
804
+ sessionId,
805
+ session.agentId,
806
+ messages,
807
+ );
808
+ },
809
+ [session.agentId, settingsAccess],
810
+ );
811
+
812
+ return useMemo(
813
+ () => ({
814
+ sessions,
815
+ loading,
816
+ error,
817
+ hasMore: nextCursor !== undefined,
818
+
819
+ // Capability flags
820
+ canShowSessionHistory:
821
+ capabilities.canList ||
822
+ capabilities.canLoad ||
823
+ capabilities.canResume ||
824
+ capabilities.canFork,
825
+ canRestore: capabilities.canLoad || capabilities.canResume,
826
+ canFork: capabilities.canFork,
827
+ canList: capabilities.canList,
828
+ isUsingLocalSessions: !capabilities.canList,
829
+ localSessionIds,
830
+
831
+ // Methods
832
+ fetchSessions,
833
+ loadMoreSessions,
834
+ restoreSession,
835
+ forkSession,
836
+ deleteSession,
837
+ updateSessionTitle,
838
+ saveSessionLocally,
839
+ saveSessionMessages,
840
+ invalidateCache,
841
+ }),
842
+ [
843
+ sessions,
844
+ loading,
845
+ error,
846
+ nextCursor,
847
+ capabilities.canList,
848
+ capabilities.canLoad,
849
+ capabilities.canResume,
850
+ capabilities.canFork,
851
+ localSessionIds,
852
+ fetchSessions,
853
+ loadMoreSessions,
854
+ restoreSession,
855
+ forkSession,
856
+ deleteSession,
857
+ updateSessionTitle,
858
+ saveSessionLocally,
859
+ saveSessionMessages,
860
+ invalidateCache,
861
+ ],
862
+ );
863
+ }