claudedesk 4.3.1 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/.github/workflows/ci.yml +44 -2
  2. package/CLAUDE.md +36 -3
  3. package/PHASE_1_IMPLEMENTATION.md +313 -0
  4. package/PHASE_2_PARTIAL_IMPLEMENTATION.md +286 -0
  5. package/dist/main/cli-manager.js +67 -2
  6. package/dist/main/command-registry.js +196 -0
  7. package/dist/main/git-manager.js +841 -0
  8. package/dist/main/index.js +25 -1
  9. package/dist/main/ipc-handlers.js +347 -3
  10. package/dist/main/layout-presets-manager.js +233 -0
  11. package/dist/main/model-history-manager.js +187 -0
  12. package/dist/main/session-manager.js +83 -26
  13. package/dist/main/session-persistence.js +1 -0
  14. package/dist/main/session-pool.js +40 -9
  15. package/dist/main/settings-persistence.js +67 -12
  16. package/dist/renderer/assets/index-BNeYLqV4.css +32 -0
  17. package/dist/renderer/assets/index-D5O5Ljoo.js +17189 -0
  18. package/dist/renderer/index.html +2 -2
  19. package/dist/shared/ipc-contract.js +79 -0
  20. package/dist/shared/model-detector.js +83 -0
  21. package/dist/shared/types/command-types.js +5 -0
  22. package/dist/shared/types/git-types.js +2 -0
  23. package/dist/types/layout-presets.js +11 -0
  24. package/docs/git-integration-implementation-tasks.md +974 -0
  25. package/docs/git-integration-product-spec.md +916 -0
  26. package/docs/git-integration-ui-spec.md +1464 -0
  27. package/docs/repo-index.md +83 -8
  28. package/e2e/app-launch.spec.ts +31 -0
  29. package/e2e/fixtures/electron.ts +34 -0
  30. package/e2e/keyboard-shortcuts.spec.ts +50 -0
  31. package/e2e/session.spec.ts +34 -0
  32. package/e2e/split-view.spec.ts +21 -0
  33. package/package.json +16 -3
  34. package/playwright.config.ts +15 -0
  35. package/src/main/cli-manager.ts +74 -3
  36. package/src/main/command-registry.ts +221 -0
  37. package/src/main/git-manager.test.ts +374 -0
  38. package/src/main/git-manager.ts +909 -0
  39. package/src/main/index.ts +31 -1
  40. package/src/main/ipc-emitter.test.ts +60 -0
  41. package/src/main/ipc-handlers.ts +295 -3
  42. package/src/main/ipc-registry.test.ts +75 -0
  43. package/src/main/layout-presets-manager.ts +268 -0
  44. package/src/main/model-history-manager.ts +196 -0
  45. package/src/main/session-manager.ts +102 -30
  46. package/src/main/session-persistence.test.ts +215 -0
  47. package/src/main/session-persistence.ts +1 -0
  48. package/src/main/session-pool.ts +31 -9
  49. package/src/main/settings-persistence.ts +74 -12
  50. package/src/renderer/App.tsx +215 -43
  51. package/src/renderer/components/CustomLayoutBuilder.tsx +143 -0
  52. package/src/renderer/components/GitPanel.test.tsx +181 -0
  53. package/src/renderer/components/GitPanel.tsx +1407 -0
  54. package/src/renderer/components/LayoutPicker.tsx +182 -0
  55. package/src/renderer/components/LayoutPreviewCard.tsx +175 -0
  56. package/src/renderer/components/ModelHistoryPanel.tsx +435 -0
  57. package/src/renderer/components/PaneHeader.test.tsx +96 -0
  58. package/src/renderer/components/PaneHeader.tsx +28 -0
  59. package/src/renderer/components/SplitLayout.test.tsx +153 -0
  60. package/src/renderer/components/SplitLayout.tsx +36 -1
  61. package/src/renderer/components/Terminal.tsx +10 -10
  62. package/src/renderer/components/WelcomeWizard.tsx +143 -0
  63. package/src/renderer/components/WizardStepper.tsx +135 -0
  64. package/src/renderer/components/ui/ClaudeReadinessProgress.tsx +168 -0
  65. package/src/renderer/components/ui/CommitDialog.test.tsx +134 -0
  66. package/src/renderer/components/ui/CommitDialog.tsx +464 -0
  67. package/src/renderer/components/ui/EmptyState.test.tsx +87 -0
  68. package/src/renderer/components/ui/EmptyState.tsx +115 -86
  69. package/src/renderer/components/ui/FeatureShowcase.tsx +187 -0
  70. package/src/renderer/components/ui/FuelGaugeBar.tsx +59 -0
  71. package/src/renderer/components/ui/FuelStatusIndicator.tsx +358 -0
  72. package/src/renderer/components/ui/FuelTooltip.tsx +267 -0
  73. package/src/renderer/components/ui/HelpButton.tsx +43 -0
  74. package/src/renderer/components/ui/ModelBadge.tsx +72 -0
  75. package/src/renderer/components/ui/ModelSwitcher.tsx +180 -0
  76. package/src/renderer/components/ui/PanelFooter.tsx +90 -0
  77. package/src/renderer/components/ui/PanelHeader.tsx +87 -0
  78. package/src/renderer/components/ui/PanelHelpOverlay.tsx +274 -0
  79. package/src/renderer/components/ui/QuickActionCard.tsx +103 -0
  80. package/src/renderer/components/ui/RecentSessionsList.tsx +154 -0
  81. package/src/renderer/components/ui/SessionStatusIndicator.tsx +104 -0
  82. package/src/renderer/components/ui/SettingsDialog.tsx +94 -0
  83. package/src/renderer/components/ui/ShortcutsPanel.tsx +433 -0
  84. package/src/renderer/components/ui/StatusPopover.tsx +344 -0
  85. package/src/renderer/components/ui/TabBar.test.tsx +124 -0
  86. package/src/renderer/components/ui/TabBar.tsx +152 -168
  87. package/src/renderer/components/ui/ToolbarDropdown.tsx +227 -0
  88. package/src/renderer/components/ui/ToolsDropdown.tsx +119 -0
  89. package/src/renderer/components/ui/TooltipCoach.tsx +217 -0
  90. package/src/renderer/components/ui/WelcomeHero.tsx +85 -0
  91. package/src/renderer/components/ui/index.ts +5 -0
  92. package/src/renderer/components/wizard/Step1_Welcome.tsx +166 -0
  93. package/src/renderer/components/wizard/Step2_LayoutPicker.tsx +246 -0
  94. package/src/renderer/components/wizard/Step3_Features.tsx +278 -0
  95. package/src/renderer/components/wizard/Step4_Ready.tsx +279 -0
  96. package/src/renderer/hooks/useGit.test.ts +140 -0
  97. package/src/renderer/hooks/useGit.ts +395 -0
  98. package/src/renderer/hooks/useLayoutPicker.ts +77 -0
  99. package/src/renderer/hooks/useModelHistory.ts +69 -0
  100. package/src/renderer/hooks/useSessionManager.test.ts +146 -0
  101. package/src/renderer/hooks/useSessionManager.ts +5 -0
  102. package/src/renderer/hooks/useSplitView.test.ts +168 -0
  103. package/src/renderer/hooks/useSplitView.ts +126 -128
  104. package/src/renderer/styles/globals.css +505 -0
  105. package/src/renderer/utils/fuzzy-search.test.ts +121 -0
  106. package/src/renderer/utils/layout-tree.test.ts +310 -0
  107. package/src/renderer/utils/layout-tree.ts +170 -0
  108. package/src/renderer/utils/variable-resolver.test.ts +102 -0
  109. package/src/shared/ipc-contract.ts +157 -0
  110. package/src/shared/ipc-types.ts +52 -1
  111. package/src/shared/message-parser.test.ts +79 -0
  112. package/src/shared/model-detector.test.ts +90 -0
  113. package/src/shared/model-detector.ts +97 -0
  114. package/src/shared/types/command-types.ts +26 -0
  115. package/src/shared/types/git-types.ts +126 -0
  116. package/src/types/layout-presets.ts +22 -0
  117. package/test/helpers/electron-api-mock.ts +52 -0
  118. package/test/setup-main.ts +61 -0
  119. package/test/setup-renderer.ts +8 -0
  120. package/tsconfig.json +1 -0
  121. package/tsconfig.main.json +2 -1
  122. package/vitest.workspace.ts +37 -0
  123. package/dist/renderer/assets/index-CR22a7j2.css +0 -32
  124. package/dist/renderer/assets/index-Dp-eceNq.js +0 -13915
@@ -0,0 +1,395 @@
1
+ import { useState, useCallback, useEffect, useRef } from 'react';
2
+ import type {
3
+ GitStatus,
4
+ GitBranchInfo,
5
+ GitCommitInfo,
6
+ GitDiffResult,
7
+ GeneratedCommitMessage,
8
+ GitCommitRequest,
9
+ } from '../../shared/types/git-types';
10
+ import { showToast } from '../utils/toast';
11
+
12
+ export function useGit(projectPath: string | null) {
13
+ const [status, setStatus] = useState<GitStatus | null>(null);
14
+ const [branches, setBranches] = useState<GitBranchInfo[]>([]);
15
+ const [log, setLog] = useState<GitCommitInfo[]>([]);
16
+ const [selectedDiff, setSelectedDiff] = useState<GitDiffResult | null>(null);
17
+ const [generatedMessage, setGeneratedMessage] = useState<GeneratedCommitMessage | null>(null);
18
+ const [isLoading, setIsLoading] = useState(false);
19
+ const [operationInProgress, setOperationInProgress] = useState<string | null>(null);
20
+ const [error, setError] = useState<string | null>(null);
21
+ const lastPath = useRef<string | null>(null);
22
+
23
+ // Subscribe to status change events
24
+ useEffect(() => {
25
+ const unsub = window.electronAPI.onGitStatusChanged((newStatus) => {
26
+ setStatus(newStatus);
27
+ });
28
+ return unsub;
29
+ }, []);
30
+
31
+ // Load status when project path changes
32
+ useEffect(() => {
33
+ if (projectPath && projectPath !== lastPath.current) {
34
+ lastPath.current = projectPath;
35
+ refreshStatus();
36
+ }
37
+ if (!projectPath) {
38
+ setStatus(null);
39
+ setBranches([]);
40
+ setLog([]);
41
+ setSelectedDiff(null);
42
+ setGeneratedMessage(null);
43
+ lastPath.current = null;
44
+ }
45
+ // eslint-disable-next-line react-hooks/exhaustive-deps
46
+ }, [projectPath]);
47
+
48
+ const refreshStatus = useCallback(async () => {
49
+ if (!projectPath) return;
50
+ setIsLoading(true);
51
+ setError(null);
52
+ try {
53
+ const s = await window.electronAPI.getGitStatus(projectPath);
54
+ setStatus(s);
55
+ } catch (err) {
56
+ console.error('Failed to get git status:', err);
57
+ setError('Failed to get git status');
58
+ } finally {
59
+ setIsLoading(false);
60
+ }
61
+ }, [projectPath]);
62
+
63
+ const loadBranches = useCallback(async () => {
64
+ if (!projectPath) return;
65
+ try {
66
+ const b = await window.electronAPI.getGitBranches(projectPath);
67
+ setBranches(b);
68
+ } catch (err) {
69
+ console.error('Failed to load branches:', err);
70
+ }
71
+ }, [projectPath]);
72
+
73
+ const loadHistory = useCallback(async (count?: number) => {
74
+ if (!projectPath) return;
75
+ try {
76
+ const h = await window.electronAPI.gitLog(projectPath, count);
77
+ setLog(h);
78
+ } catch (err) {
79
+ console.error('Failed to load history:', err);
80
+ }
81
+ }, [projectPath]);
82
+
83
+ const stageFiles = useCallback(async (files: string[]) => {
84
+ if (!projectPath) return;
85
+ setOperationInProgress('staging');
86
+ try {
87
+ const result = await window.electronAPI.gitStageFiles(projectPath, files);
88
+ if (!result.success) {
89
+ showToast(result.message, 'error');
90
+ }
91
+ await refreshStatus();
92
+ } catch (err) {
93
+ showToast('Failed to stage files', 'error');
94
+ } finally {
95
+ setOperationInProgress(null);
96
+ }
97
+ }, [projectPath, refreshStatus]);
98
+
99
+ const unstageFiles = useCallback(async (files: string[]) => {
100
+ if (!projectPath) return;
101
+ setOperationInProgress('unstaging');
102
+ try {
103
+ const result = await window.electronAPI.gitUnstageFiles(projectPath, files);
104
+ if (!result.success) {
105
+ showToast(result.message, 'error');
106
+ }
107
+ await refreshStatus();
108
+ } catch (err) {
109
+ showToast('Failed to unstage files', 'error');
110
+ } finally {
111
+ setOperationInProgress(null);
112
+ }
113
+ }, [projectPath, refreshStatus]);
114
+
115
+ const stageAll = useCallback(async () => {
116
+ if (!projectPath) return;
117
+ setOperationInProgress('staging');
118
+ try {
119
+ const result = await window.electronAPI.gitStageAll(projectPath);
120
+ if (!result.success) showToast(result.message, 'error');
121
+ await refreshStatus();
122
+ } catch (err) {
123
+ showToast('Failed to stage all', 'error');
124
+ } finally {
125
+ setOperationInProgress(null);
126
+ }
127
+ }, [projectPath, refreshStatus]);
128
+
129
+ const unstageAll = useCallback(async () => {
130
+ if (!projectPath) return;
131
+ setOperationInProgress('unstaging');
132
+ try {
133
+ const result = await window.electronAPI.gitUnstageAll(projectPath);
134
+ if (!result.success) showToast(result.message, 'error');
135
+ await refreshStatus();
136
+ } catch (err) {
137
+ showToast('Failed to unstage all', 'error');
138
+ } finally {
139
+ setOperationInProgress(null);
140
+ }
141
+ }, [projectPath, refreshStatus]);
142
+
143
+ const commit = useCallback(async (request: GitCommitRequest) => {
144
+ setOperationInProgress('committing');
145
+ try {
146
+ const result = await window.electronAPI.gitCommit(request);
147
+ if (result.success) {
148
+ showToast(result.message, 'success');
149
+ await refreshStatus();
150
+ await loadHistory(10);
151
+ } else {
152
+ showToast(result.message, 'error');
153
+ }
154
+ return result;
155
+ } catch (err) {
156
+ showToast('Failed to commit', 'error');
157
+ return { success: false, message: 'Failed to commit', errorCode: 'UNKNOWN' as const };
158
+ } finally {
159
+ setOperationInProgress(null);
160
+ }
161
+ }, [refreshStatus, loadHistory]);
162
+
163
+ const generateMessage = useCallback(async () => {
164
+ if (!projectPath) return;
165
+ setOperationInProgress('generating');
166
+ try {
167
+ const msg = await window.electronAPI.gitGenerateMessage(projectPath);
168
+ setGeneratedMessage(msg);
169
+ return msg;
170
+ } catch (err) {
171
+ showToast('Failed to generate commit message', 'error');
172
+ return null;
173
+ } finally {
174
+ setOperationInProgress(null);
175
+ }
176
+ }, [projectPath]);
177
+
178
+ const push = useCallback(async (setUpstream?: boolean) => {
179
+ if (!projectPath) return;
180
+ setOperationInProgress('pushing');
181
+ try {
182
+ const result = await window.electronAPI.gitPush(projectPath, setUpstream);
183
+ if (result.success) {
184
+ showToast(result.message, 'success');
185
+ await refreshStatus();
186
+ } else {
187
+ showToast(result.message, 'error');
188
+ }
189
+ return result;
190
+ } catch (err) {
191
+ showToast('Failed to push', 'error');
192
+ return null;
193
+ } finally {
194
+ setOperationInProgress(null);
195
+ }
196
+ }, [projectPath, refreshStatus]);
197
+
198
+ const pull = useCallback(async () => {
199
+ if (!projectPath) return;
200
+ setOperationInProgress('pulling');
201
+ try {
202
+ const result = await window.electronAPI.gitPull(projectPath);
203
+ if (result.success) {
204
+ showToast(result.message, 'success');
205
+ await refreshStatus();
206
+ await loadHistory(10);
207
+ } else {
208
+ showToast(result.message, 'error');
209
+ }
210
+ return result;
211
+ } catch (err) {
212
+ showToast('Failed to pull', 'error');
213
+ return null;
214
+ } finally {
215
+ setOperationInProgress(null);
216
+ }
217
+ }, [projectPath, refreshStatus, loadHistory]);
218
+
219
+ const fetch_ = useCallback(async () => {
220
+ if (!projectPath) return;
221
+ setOperationInProgress('fetching');
222
+ try {
223
+ const result = await window.electronAPI.gitFetch(projectPath);
224
+ if (result.success) {
225
+ showToast(result.message, 'success');
226
+ await refreshStatus();
227
+ } else {
228
+ showToast(result.message, 'error');
229
+ }
230
+ return result;
231
+ } catch (err) {
232
+ showToast('Failed to fetch', 'error');
233
+ return null;
234
+ } finally {
235
+ setOperationInProgress(null);
236
+ }
237
+ }, [projectPath, refreshStatus]);
238
+
239
+ const switchBranch = useCallback(async (branch: string) => {
240
+ if (!projectPath) return;
241
+ setOperationInProgress('switching');
242
+ try {
243
+ const result = await window.electronAPI.gitSwitchBranch(projectPath, branch);
244
+ if (result.success) {
245
+ showToast(`Switched to branch '${branch}'`, 'success');
246
+ await refreshStatus();
247
+ await loadBranches();
248
+ await loadHistory(10);
249
+ } else {
250
+ showToast(result.message, 'error');
251
+ }
252
+ return result;
253
+ } catch (err) {
254
+ showToast('Failed to switch branch', 'error');
255
+ return null;
256
+ } finally {
257
+ setOperationInProgress(null);
258
+ }
259
+ }, [projectPath, refreshStatus, loadBranches, loadHistory]);
260
+
261
+ const createBranch = useCallback(async (branch: string) => {
262
+ if (!projectPath) return;
263
+ setOperationInProgress('creating branch');
264
+ try {
265
+ const result = await window.electronAPI.gitCreateBranch(projectPath, branch);
266
+ if (result.success) {
267
+ showToast(`Created branch '${branch}'`, 'success');
268
+ await refreshStatus();
269
+ await loadBranches();
270
+ } else {
271
+ showToast(result.message, 'error');
272
+ }
273
+ return result;
274
+ } catch (err) {
275
+ showToast('Failed to create branch', 'error');
276
+ return null;
277
+ } finally {
278
+ setOperationInProgress(null);
279
+ }
280
+ }, [projectPath, refreshStatus, loadBranches]);
281
+
282
+ const viewDiff = useCallback(async (filePath: string, staged: boolean) => {
283
+ if (!projectPath) return;
284
+ try {
285
+ const diff = await window.electronAPI.gitDiff(projectPath, filePath, staged);
286
+ setSelectedDiff(diff);
287
+ } catch (err) {
288
+ showToast('Failed to load diff', 'error');
289
+ }
290
+ }, [projectPath]);
291
+
292
+ const discardFile = useCallback(async (filePath: string) => {
293
+ if (!projectPath) return;
294
+ setOperationInProgress('discarding');
295
+ try {
296
+ const result = await window.electronAPI.gitDiscardFile(projectPath, filePath);
297
+ if (result.success) {
298
+ showToast(`Discarded changes to ${filePath}`, 'success');
299
+ await refreshStatus();
300
+ } else {
301
+ showToast(result.message, 'error');
302
+ }
303
+ } catch (err) {
304
+ showToast('Failed to discard changes', 'error');
305
+ } finally {
306
+ setOperationInProgress(null);
307
+ }
308
+ }, [projectPath, refreshStatus]);
309
+
310
+ const discardAll = useCallback(async () => {
311
+ if (!projectPath) return;
312
+ setOperationInProgress('discarding');
313
+ try {
314
+ const result = await window.electronAPI.gitDiscardAll(projectPath);
315
+ if (result.success) {
316
+ showToast('Discarded all unstaged changes', 'success');
317
+ await refreshStatus();
318
+ } else {
319
+ showToast(result.message, 'error');
320
+ }
321
+ } catch (err) {
322
+ showToast('Failed to discard all changes', 'error');
323
+ } finally {
324
+ setOperationInProgress(null);
325
+ }
326
+ }, [projectPath, refreshStatus]);
327
+
328
+ const initRepo = useCallback(async () => {
329
+ if (!projectPath) return;
330
+ setOperationInProgress('initializing');
331
+ try {
332
+ const result = await window.electronAPI.gitInit(projectPath);
333
+ if (result.success) {
334
+ showToast('Initialized git repository', 'success');
335
+ await refreshStatus();
336
+ } else {
337
+ showToast(result.message, 'error');
338
+ }
339
+ } catch (err) {
340
+ showToast('Failed to initialize git repository', 'error');
341
+ } finally {
342
+ setOperationInProgress(null);
343
+ }
344
+ }, [projectPath, refreshStatus]);
345
+
346
+ const startWatching = useCallback(async () => {
347
+ if (!projectPath) return;
348
+ try {
349
+ await window.electronAPI.gitStartWatching(projectPath);
350
+ } catch (err) {
351
+ console.warn('Failed to start git watching:', err);
352
+ }
353
+ }, [projectPath]);
354
+
355
+ const stopWatching = useCallback(async () => {
356
+ if (!projectPath) return;
357
+ try {
358
+ await window.electronAPI.gitStopWatching(projectPath);
359
+ } catch (err) {
360
+ console.warn('Failed to stop git watching:', err);
361
+ }
362
+ }, [projectPath]);
363
+
364
+ return {
365
+ status,
366
+ branches,
367
+ log,
368
+ selectedDiff,
369
+ generatedMessage,
370
+ isLoading,
371
+ operationInProgress,
372
+ error,
373
+ refreshStatus,
374
+ loadBranches,
375
+ loadHistory,
376
+ stageFiles,
377
+ unstageFiles,
378
+ stageAll,
379
+ unstageAll,
380
+ commit,
381
+ generateMessage,
382
+ push,
383
+ pull,
384
+ fetch: fetch_,
385
+ switchBranch,
386
+ createBranch,
387
+ viewDiff,
388
+ setSelectedDiff,
389
+ discardFile,
390
+ discardAll,
391
+ initRepo,
392
+ startWatching,
393
+ stopWatching,
394
+ };
395
+ }
@@ -0,0 +1,77 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { LayoutPreset } from '../../types/layout-presets';
3
+
4
+ export interface UseLayoutPickerReturn {
5
+ isPickerOpen: boolean;
6
+ presets: LayoutPreset[];
7
+ currentPresetId: string | null;
8
+ setCurrentPresetId: (id: string | null) => void;
9
+ openPicker: () => void;
10
+ closePicker: () => void;
11
+ selectPreset: (preset: LayoutPreset) => Promise<void>;
12
+ }
13
+
14
+ export function useLayoutPicker(
15
+ onPresetSelected?: (preset: LayoutPreset) => void
16
+ ): UseLayoutPickerReturn {
17
+ const [isPickerOpen, setIsPickerOpen] = useState(false);
18
+ const [presets, setPresets] = useState<LayoutPreset[]>([]);
19
+ const [currentPresetId, setCurrentPresetId] = useState<string | null>(null);
20
+
21
+ // Load presets on mount
22
+ useEffect(() => {
23
+ const loadPresets = async () => {
24
+ try {
25
+ const fetchedPresets = await window.electronAPI.getLayoutPresets();
26
+ setPresets(fetchedPresets);
27
+
28
+ // Load last used preset ID from settings
29
+ const settings = await window.electronAPI.getSettings();
30
+ setCurrentPresetId(settings.lastUsedLayoutPresetId || null);
31
+ } catch (err) {
32
+ console.error('Failed to load layout presets:', err);
33
+ }
34
+ };
35
+
36
+ loadPresets();
37
+ }, []);
38
+
39
+ const openPicker = useCallback(() => {
40
+ setIsPickerOpen(true);
41
+ }, []);
42
+
43
+ const closePicker = useCallback(() => {
44
+ setIsPickerOpen(false);
45
+ }, []);
46
+
47
+ const selectPreset = useCallback(async (preset: LayoutPreset) => {
48
+ try {
49
+ // Apply the preset
50
+ const success = await window.electronAPI.applyLayoutPreset(preset.id);
51
+
52
+ if (success) {
53
+ setCurrentPresetId(preset.id);
54
+ closePicker();
55
+
56
+ // Notify callback
57
+ if (onPresetSelected) {
58
+ onPresetSelected(preset);
59
+ }
60
+ } else {
61
+ console.error('Failed to apply preset:', preset.id);
62
+ }
63
+ } catch (err) {
64
+ console.error('Error applying preset:', err);
65
+ }
66
+ }, [closePicker, onPresetSelected]);
67
+
68
+ return {
69
+ isPickerOpen,
70
+ presets,
71
+ currentPresetId,
72
+ setCurrentPresetId,
73
+ openPicker,
74
+ closePicker,
75
+ selectPreset,
76
+ };
77
+ }
@@ -0,0 +1,69 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import type { ModelSwitchHistoryEntry } from '../../main/model-history-manager';
3
+
4
+ export interface UseModelHistoryReturn {
5
+ history: ModelSwitchHistoryEntry[];
6
+ isLoading: boolean;
7
+ refresh: () => Promise<void>;
8
+ clearHistory: () => Promise<void>;
9
+ }
10
+
11
+ export function useModelHistory(sessionId: string | null): UseModelHistoryReturn {
12
+ const [history, setHistory] = useState<ModelSwitchHistoryEntry[]>([]);
13
+ const [isLoading, setIsLoading] = useState(false);
14
+
15
+ const loadHistory = useCallback(async () => {
16
+ if (!sessionId) {
17
+ setHistory([]);
18
+ return;
19
+ }
20
+
21
+ setIsLoading(true);
22
+ try {
23
+ const result = await window.electronAPI.getModelHistory(sessionId);
24
+ setHistory(result);
25
+ } catch (err) {
26
+ console.error('[useModelHistory] Failed to load history:', err);
27
+ setHistory([]);
28
+ } finally {
29
+ setIsLoading(false);
30
+ }
31
+ }, [sessionId]);
32
+
33
+ const clearHistory = useCallback(async () => {
34
+ if (!sessionId) return;
35
+
36
+ try {
37
+ await window.electronAPI.clearModelHistory(sessionId);
38
+ setHistory([]);
39
+ } catch (err) {
40
+ console.error('[useModelHistory] Failed to clear history:', err);
41
+ }
42
+ }, [sessionId]);
43
+
44
+ // Load on mount and when sessionId changes
45
+ useEffect(() => {
46
+ loadHistory();
47
+ }, [loadHistory]);
48
+
49
+ // Listen for model change events to refresh
50
+ useEffect(() => {
51
+ if (!sessionId) return;
52
+
53
+ const unsubscribe = window.electronAPI.onModelChanged((event) => {
54
+ // Refresh if the event is for our session
55
+ if (event.sessionId === sessionId) {
56
+ loadHistory();
57
+ }
58
+ });
59
+
60
+ return unsubscribe;
61
+ }, [sessionId, loadHistory]);
62
+
63
+ return {
64
+ history,
65
+ isLoading,
66
+ refresh: loadHistory,
67
+ clearHistory,
68
+ };
69
+ }
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { renderHook, act, waitFor } from '@testing-library/react';
3
+ import { getElectronAPI } from '../../../test/helpers/electron-api-mock';
4
+ import { useSessionManager } from './useSessionManager';
5
+
6
+ describe('useSessionManager', () => {
7
+ let api: ReturnType<typeof getElectronAPI>;
8
+
9
+ beforeEach(() => {
10
+ api = getElectronAPI();
11
+ });
12
+
13
+ it('loads initial sessions on mount', async () => {
14
+ const mockSessions = [
15
+ { id: 's1', name: 'Session 1', workingDirectory: '/test', permissionMode: 'standard' as const, status: 'running' as const, createdAt: Date.now() },
16
+ ];
17
+ api.listSessions.mockResolvedValue({
18
+ sessions: mockSessions,
19
+ activeSessionId: 's1',
20
+ });
21
+
22
+ const { result } = renderHook(() => useSessionManager());
23
+
24
+ await waitFor(() => {
25
+ expect(result.current.isLoading).toBe(false);
26
+ });
27
+
28
+ expect(result.current.sessions).toHaveLength(1);
29
+ expect(result.current.sessions[0].id).toBe('s1');
30
+ expect(result.current.activeSessionId).toBe('s1');
31
+ });
32
+
33
+ it('createSession calls electronAPI.createSession', async () => {
34
+ api.listSessions.mockResolvedValue({ sessions: [], activeSessionId: null });
35
+ api.createSession.mockResolvedValue({ id: 's-new', name: 'New', workingDirectory: '/test', permissionMode: 'standard', status: 'running', createdAt: Date.now() });
36
+ api.getSettings.mockResolvedValue({ version: 1, workspaces: [], defaultModel: 'sonnet' });
37
+
38
+ const { result } = renderHook(() => useSessionManager());
39
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
40
+
41
+ await act(async () => {
42
+ await result.current.createSession('New', '/test', 'standard');
43
+ });
44
+
45
+ expect(api.createSession).toHaveBeenCalledWith({
46
+ name: 'New',
47
+ workingDirectory: '/test',
48
+ permissionMode: 'standard',
49
+ model: 'sonnet',
50
+ });
51
+ });
52
+
53
+ it('closeSession calls electronAPI.closeSession', async () => {
54
+ api.listSessions.mockResolvedValue({
55
+ sessions: [{ id: 's1', name: 'S1', workingDirectory: '/', permissionMode: 'standard', status: 'running', createdAt: Date.now() }],
56
+ activeSessionId: 's1',
57
+ });
58
+ api.closeSession.mockResolvedValue(true);
59
+
60
+ const { result } = renderHook(() => useSessionManager());
61
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
62
+
63
+ await act(async () => {
64
+ await result.current.closeSession('s1');
65
+ });
66
+
67
+ expect(api.closeSession).toHaveBeenCalledWith('s1');
68
+ });
69
+
70
+ it('switchSession calls electronAPI.switchSession', async () => {
71
+ api.listSessions.mockResolvedValue({
72
+ sessions: [
73
+ { id: 's1', name: 'S1', workingDirectory: '/', permissionMode: 'standard', status: 'running', createdAt: Date.now() },
74
+ { id: 's2', name: 'S2', workingDirectory: '/', permissionMode: 'standard', status: 'running', createdAt: Date.now() },
75
+ ],
76
+ activeSessionId: 's1',
77
+ });
78
+ api.switchSession.mockResolvedValue(true);
79
+
80
+ const { result } = renderHook(() => useSessionManager());
81
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
82
+
83
+ await act(async () => {
84
+ await result.current.switchSession('s2');
85
+ });
86
+
87
+ expect(api.switchSession).toHaveBeenCalledWith('s2');
88
+ });
89
+
90
+ it('subscribes to session events and cleans up on unmount', () => {
91
+ api.listSessions.mockResolvedValue({ sessions: [], activeSessionId: null });
92
+
93
+ const unsubCreated = vi.fn();
94
+ const unsubClosed = vi.fn();
95
+ const unsubSwitched = vi.fn();
96
+ const unsubUpdated = vi.fn();
97
+ const unsubOutput = vi.fn();
98
+ const unsubExited = vi.fn();
99
+
100
+ api.onSessionCreated.mockReturnValue(unsubCreated);
101
+ api.onSessionClosed.mockReturnValue(unsubClosed);
102
+ api.onSessionSwitched.mockReturnValue(unsubSwitched);
103
+ api.onSessionUpdated.mockReturnValue(unsubUpdated);
104
+ api.onSessionOutput.mockReturnValue(unsubOutput);
105
+ api.onSessionExited.mockReturnValue(unsubExited);
106
+
107
+ const { unmount } = renderHook(() => useSessionManager());
108
+
109
+ expect(api.onSessionCreated).toHaveBeenCalled();
110
+ expect(api.onSessionClosed).toHaveBeenCalled();
111
+ expect(api.onSessionOutput).toHaveBeenCalled();
112
+
113
+ unmount();
114
+ expect(unsubCreated).toHaveBeenCalled();
115
+ expect(unsubClosed).toHaveBeenCalled();
116
+ expect(unsubOutput).toHaveBeenCalled();
117
+ });
118
+
119
+ it('output callback supports multiple subscribers', async () => {
120
+ api.listSessions.mockResolvedValue({ sessions: [], activeSessionId: null });
121
+
122
+ let outputListener: Function;
123
+ api.onSessionOutput.mockImplementation((cb: Function) => {
124
+ outputListener = cb;
125
+ return vi.fn();
126
+ });
127
+
128
+ const { result } = renderHook(() => useSessionManager());
129
+
130
+ const cb1 = vi.fn();
131
+ const cb2 = vi.fn();
132
+
133
+ act(() => {
134
+ result.current.onOutput(cb1);
135
+ result.current.onOutput(cb2);
136
+ });
137
+
138
+ // Simulate output event
139
+ act(() => {
140
+ outputListener({ sessionId: 's1', data: 'hello' });
141
+ });
142
+
143
+ expect(cb1).toHaveBeenCalledWith('s1', 'hello');
144
+ expect(cb2).toHaveBeenCalledWith('s1', 'hello');
145
+ });
146
+ });
@@ -102,10 +102,15 @@ export function useSessionManager(): UseSessionManagerReturn {
102
102
  permissionMode: 'standard' | 'skip-permissions'
103
103
  ) => {
104
104
  try {
105
+ // Read default model from settings
106
+ const settings = await window.electronAPI.getSettings();
107
+ const defaultModel = settings.defaultModel || 'sonnet';
108
+
105
109
  await window.electronAPI.createSession({
106
110
  name: name || undefined,
107
111
  workingDirectory,
108
112
  permissionMode,
113
+ model: defaultModel,
109
114
  });
110
115
  } catch (err) {
111
116
  console.error('Failed to create session:', err);