@atercates/claude-deck 0.2.1

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 (293) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/app/api/claude/hidden/route.ts +66 -0
  4. package/app/api/claude/projects/[name]/sessions/route.ts +71 -0
  5. package/app/api/claude/projects/route.ts +44 -0
  6. package/app/api/code-search/available/route.ts +12 -0
  7. package/app/api/code-search/route.ts +47 -0
  8. package/app/api/dev-servers/[id]/logs/route.ts +23 -0
  9. package/app/api/dev-servers/[id]/restart/route.ts +20 -0
  10. package/app/api/dev-servers/[id]/route.ts +51 -0
  11. package/app/api/dev-servers/[id]/stop/route.ts +20 -0
  12. package/app/api/dev-servers/detect/route.ts +39 -0
  13. package/app/api/dev-servers/route.ts +48 -0
  14. package/app/api/exec/route.ts +60 -0
  15. package/app/api/files/content/route.ts +76 -0
  16. package/app/api/files/route.ts +37 -0
  17. package/app/api/files/upload-temp/route.ts +41 -0
  18. package/app/api/git/check/route.ts +54 -0
  19. package/app/api/git/clone/route.ts +99 -0
  20. package/app/api/git/commit/route.ts +75 -0
  21. package/app/api/git/discard/route.ts +38 -0
  22. package/app/api/git/file-content/route.ts +64 -0
  23. package/app/api/git/history/[hash]/diff/route.ts +38 -0
  24. package/app/api/git/history/[hash]/route.ts +34 -0
  25. package/app/api/git/history/route.ts +27 -0
  26. package/app/api/git/multi-status/route.ts +46 -0
  27. package/app/api/git/pr/route.ts +164 -0
  28. package/app/api/git/push/route.ts +64 -0
  29. package/app/api/git/stage/route.ts +40 -0
  30. package/app/api/git/status/route.ts +51 -0
  31. package/app/api/git/unstage/route.ts +46 -0
  32. package/app/api/groups/[...path]/route.ts +136 -0
  33. package/app/api/groups/route.ts +93 -0
  34. package/app/api/orchestrate/spawn/route.ts +45 -0
  35. package/app/api/orchestrate/workers/[id]/route.ts +89 -0
  36. package/app/api/orchestrate/workers/route.ts +31 -0
  37. package/app/api/projects/[id]/detect/route.ts +27 -0
  38. package/app/api/projects/[id]/dev-servers/[dsId]/route.ts +66 -0
  39. package/app/api/projects/[id]/dev-servers/route.ts +51 -0
  40. package/app/api/projects/[id]/repositories/[repoId]/route.ts +67 -0
  41. package/app/api/projects/[id]/repositories/route.ts +74 -0
  42. package/app/api/projects/[id]/route.ts +108 -0
  43. package/app/api/projects/detect/route.ts +33 -0
  44. package/app/api/projects/route.ts +59 -0
  45. package/app/api/sessions/[id]/claude-session/route.ts +42 -0
  46. package/app/api/sessions/[id]/fork/route.ts +74 -0
  47. package/app/api/sessions/[id]/mcp-config/route.ts +34 -0
  48. package/app/api/sessions/[id]/messages/route.ts +60 -0
  49. package/app/api/sessions/[id]/pr/route.ts +188 -0
  50. package/app/api/sessions/[id]/preview/route.ts +42 -0
  51. package/app/api/sessions/[id]/route.ts +229 -0
  52. package/app/api/sessions/[id]/send-keys/route.ts +119 -0
  53. package/app/api/sessions/[id]/summarize/route.ts +331 -0
  54. package/app/api/sessions/init-script/route.ts +84 -0
  55. package/app/api/sessions/route.ts +209 -0
  56. package/app/api/sessions/status/route.ts +237 -0
  57. package/app/api/system/route.ts +9 -0
  58. package/app/api/tmux/kill-all/route.ts +57 -0
  59. package/app/api/tmux/rename/route.ts +30 -0
  60. package/app/globals.css +174 -0
  61. package/app/icon.svg +11 -0
  62. package/app/layout.tsx +122 -0
  63. package/app/page.tsx +629 -0
  64. package/components/ChatMessage.tsx +65 -0
  65. package/components/ChatView.tsx +276 -0
  66. package/components/ClaudeProjects/ClaudeProjectCard.tsx +195 -0
  67. package/components/ClaudeProjects/ClaudeProjectsSection.tsx +89 -0
  68. package/components/ClaudeProjects/ClaudeSessionCard.tsx +100 -0
  69. package/components/ClaudeProjects/index.ts +1 -0
  70. package/components/CodeSearch/CodeSearchResults.tsx +177 -0
  71. package/components/ConductorPanel.tsx +256 -0
  72. package/components/DevServers/DevServerCard.tsx +311 -0
  73. package/components/DevServers/DevServersSection.tsx +91 -0
  74. package/components/DevServers/ServerLogsModal.tsx +151 -0
  75. package/components/DevServers/StartServerDialog.tsx +359 -0
  76. package/components/DevServers/index.ts +4 -0
  77. package/components/DiffViewer/DiffModal.tsx +151 -0
  78. package/components/DiffViewer/UnifiedDiff.tsx +185 -0
  79. package/components/DiffViewer/index.tsx +2 -0
  80. package/components/DirectoryPicker.tsx +355 -0
  81. package/components/FileExplorer/FileEditor.tsx +276 -0
  82. package/components/FileExplorer/FileTabs.tsx +118 -0
  83. package/components/FileExplorer/FileTree.tsx +214 -0
  84. package/components/FileExplorer/HtmlRenderer.tsx +16 -0
  85. package/components/FileExplorer/MarkdownRenderer.tsx +18 -0
  86. package/components/FileExplorer/index.tsx +520 -0
  87. package/components/FilePicker.tsx +339 -0
  88. package/components/FolderPicker.tsx +201 -0
  89. package/components/GitDrawer/FileEditDialog.tsx +400 -0
  90. package/components/GitDrawer/index.tsx +464 -0
  91. package/components/GitPanel/CommitForm.tsx +205 -0
  92. package/components/GitPanel/CommitHistory.tsx +174 -0
  93. package/components/GitPanel/CommitItem.tsx +196 -0
  94. package/components/GitPanel/FileChanges.tsx +414 -0
  95. package/components/GitPanel/GitPanelTabs.tsx +39 -0
  96. package/components/GitPanel/index.tsx +817 -0
  97. package/components/MessageInput.tsx +82 -0
  98. package/components/NewClaudeSessionDialog.tsx +166 -0
  99. package/components/NewSessionDialog/AdvancedSettings.tsx +78 -0
  100. package/components/NewSessionDialog/AgentSelector.tsx +37 -0
  101. package/components/NewSessionDialog/CreatingOverlay.tsx +94 -0
  102. package/components/NewSessionDialog/NewSessionDialog.types.ts +136 -0
  103. package/components/NewSessionDialog/ProjectSelector.tsx +146 -0
  104. package/components/NewSessionDialog/WorkingDirectoryInput.tsx +55 -0
  105. package/components/NewSessionDialog/WorktreeSection.tsx +92 -0
  106. package/components/NewSessionDialog/hooks/useNewSessionForm.ts +370 -0
  107. package/components/NewSessionDialog/index.tsx +106 -0
  108. package/components/NotificationSettings.tsx +127 -0
  109. package/components/PRCreationModal.tsx +272 -0
  110. package/components/Pane/DesktopTabBar.tsx +353 -0
  111. package/components/Pane/MobileTabBar.tsx +210 -0
  112. package/components/Pane/OpenInVSCode.tsx +69 -0
  113. package/components/Pane/PaneSkeletons.tsx +57 -0
  114. package/components/Pane/index.tsx +558 -0
  115. package/components/PaneLayout.tsx +60 -0
  116. package/components/Projects/DevServersSection.tsx +140 -0
  117. package/components/Projects/DirectoryField.tsx +92 -0
  118. package/components/Projects/NewProjectDialog.tsx +188 -0
  119. package/components/Projects/NewProjectDialog.types.ts +46 -0
  120. package/components/Projects/ProjectCard.tsx +276 -0
  121. package/components/Projects/ProjectSettingsDialog.tsx +811 -0
  122. package/components/Projects/hooks/useNewProjectForm.ts +249 -0
  123. package/components/Projects/index.ts +3 -0
  124. package/components/Providers.tsx +49 -0
  125. package/components/QuickSwitcher.tsx +306 -0
  126. package/components/SessionList/KillAllConfirm.tsx +46 -0
  127. package/components/SessionList/SelectionToolbar.tsx +164 -0
  128. package/components/SessionList/SessionList.types.ts +37 -0
  129. package/components/SessionList/SessionListHeader.tsx +71 -0
  130. package/components/SessionList/hooks/useSessionListMutations.ts +269 -0
  131. package/components/SessionList/index.tsx +189 -0
  132. package/components/ShellDrawer/index.tsx +106 -0
  133. package/components/SidebarFooter.tsx +55 -0
  134. package/components/Terminal/KeybarToggleButton.tsx +45 -0
  135. package/components/Terminal/ScrollToBottomButton.tsx +32 -0
  136. package/components/Terminal/SearchBar.tsx +71 -0
  137. package/components/Terminal/TerminalToolbar.tsx +551 -0
  138. package/components/Terminal/VirtualKeyboard.tsx +711 -0
  139. package/components/Terminal/constants.ts +20 -0
  140. package/components/Terminal/hooks/index.ts +5 -0
  141. package/components/Terminal/hooks/resize-handlers.ts +140 -0
  142. package/components/Terminal/hooks/terminal-init.ts +151 -0
  143. package/components/Terminal/hooks/touch-scroll.ts +155 -0
  144. package/components/Terminal/hooks/useTerminalConnection.ts +282 -0
  145. package/components/Terminal/hooks/useTerminalConnection.types.ts +39 -0
  146. package/components/Terminal/hooks/useTerminalSearch.ts +103 -0
  147. package/components/Terminal/hooks/websocket-connection.ts +274 -0
  148. package/components/Terminal/index.tsx +320 -0
  149. package/components/ThemeToggle.tsx +168 -0
  150. package/components/TmuxSessions.tsx +132 -0
  151. package/components/ToolCallDisplay.tsx +71 -0
  152. package/components/WorkerCard.tsx +245 -0
  153. package/components/a/ABadge.tsx +115 -0
  154. package/components/a/AButton.tsx +163 -0
  155. package/components/a/ADialog.tsx +93 -0
  156. package/components/a/ADropdownMenu.tsx +279 -0
  157. package/components/a/AIconButton.tsx +190 -0
  158. package/components/a/ASheet.tsx +150 -0
  159. package/components/a/ATooltip.tsx +77 -0
  160. package/components/a/index.ts +64 -0
  161. package/components/mobile/SwipeSidebar.tsx +122 -0
  162. package/components/ui/badge.tsx +41 -0
  163. package/components/ui/button.tsx +60 -0
  164. package/components/ui/context-menu.tsx +197 -0
  165. package/components/ui/dialog.tsx +143 -0
  166. package/components/ui/dropdown-menu.tsx +257 -0
  167. package/components/ui/input.tsx +21 -0
  168. package/components/ui/scroll-area.tsx +52 -0
  169. package/components/ui/select.tsx +159 -0
  170. package/components/ui/skeleton.tsx +111 -0
  171. package/components/ui/switch.tsx +31 -0
  172. package/components/ui/textarea.tsx +21 -0
  173. package/components/ui/tooltip.tsx +32 -0
  174. package/components/views/DesktopView.tsx +244 -0
  175. package/components/views/MobileView.tsx +110 -0
  176. package/components/views/types.ts +75 -0
  177. package/contexts/PaneContext.tsx +336 -0
  178. package/data/claude/index.ts +9 -0
  179. package/data/claude/keys.ts +6 -0
  180. package/data/claude/queries.ts +120 -0
  181. package/data/claude/useClaudeUpdates.ts +37 -0
  182. package/data/code-search/index.ts +2 -0
  183. package/data/code-search/keys.ts +7 -0
  184. package/data/code-search/queries.ts +61 -0
  185. package/data/dev-servers/index.ts +8 -0
  186. package/data/dev-servers/keys.ts +4 -0
  187. package/data/dev-servers/queries.ts +104 -0
  188. package/data/files/index.ts +3 -0
  189. package/data/files/keys.ts +4 -0
  190. package/data/files/queries.ts +25 -0
  191. package/data/git/keys.ts +15 -0
  192. package/data/git/queries.ts +395 -0
  193. package/data/groups/index.ts +1 -0
  194. package/data/groups/mutations.ts +95 -0
  195. package/data/projects/index.ts +10 -0
  196. package/data/projects/keys.ts +4 -0
  197. package/data/projects/queries.ts +193 -0
  198. package/data/repositories/index.ts +7 -0
  199. package/data/repositories/keys.ts +5 -0
  200. package/data/repositories/queries.ts +122 -0
  201. package/data/sessions/index.ts +12 -0
  202. package/data/sessions/keys.ts +8 -0
  203. package/data/sessions/queries.ts +218 -0
  204. package/data/statuses/index.ts +1 -0
  205. package/data/statuses/queries.ts +69 -0
  206. package/hooks/useCopyToClipboard.ts +48 -0
  207. package/hooks/useDevServersManager.ts +73 -0
  208. package/hooks/useDirectoryBrowser.ts +90 -0
  209. package/hooks/useDrawerAnimation.ts +27 -0
  210. package/hooks/useFileDrop.ts +87 -0
  211. package/hooks/useFileEditor.ts +184 -0
  212. package/hooks/useGroups.ts +37 -0
  213. package/hooks/useHomePath.ts +34 -0
  214. package/hooks/useKeyRepeat.ts +55 -0
  215. package/hooks/useKeybarVisibility.ts +42 -0
  216. package/hooks/useNotifications.ts +257 -0
  217. package/hooks/useProjects.ts +53 -0
  218. package/hooks/useSessionStatuses.ts +30 -0
  219. package/hooks/useSessions.ts +86 -0
  220. package/hooks/useSpeechRecognition.ts +124 -0
  221. package/hooks/useViewport.ts +32 -0
  222. package/hooks/useViewportHeight.ts +50 -0
  223. package/lib/async-operations.ts +35 -0
  224. package/lib/banner.ts +81 -0
  225. package/lib/claude/jsonl-cache.ts +86 -0
  226. package/lib/claude/jsonl-reader.ts +271 -0
  227. package/lib/claude/process-manager.ts +278 -0
  228. package/lib/claude/stream-parser.ts +173 -0
  229. package/lib/claude/types.ts +154 -0
  230. package/lib/claude/watcher.ts +71 -0
  231. package/lib/client/session-registry.ts +111 -0
  232. package/lib/code-search.ts +121 -0
  233. package/lib/db/index.ts +48 -0
  234. package/lib/db/migrations.ts +45 -0
  235. package/lib/db/queries.ts +460 -0
  236. package/lib/db/schema.ts +114 -0
  237. package/lib/db/types.ts +92 -0
  238. package/lib/db.ts +2 -0
  239. package/lib/dev-servers.ts +509 -0
  240. package/lib/diff-parser.ts +221 -0
  241. package/lib/env-setup.ts +285 -0
  242. package/lib/file-upload.ts +34 -0
  243. package/lib/file-utils.ts +50 -0
  244. package/lib/files.ts +207 -0
  245. package/lib/git-history.ts +294 -0
  246. package/lib/git-status.ts +391 -0
  247. package/lib/git.ts +257 -0
  248. package/lib/mcp-config.ts +81 -0
  249. package/lib/multi-repo-git.ts +179 -0
  250. package/lib/notifications.ts +219 -0
  251. package/lib/orchestration.ts +448 -0
  252. package/lib/panes.ts +232 -0
  253. package/lib/ports.ts +97 -0
  254. package/lib/pr-generation.ts +307 -0
  255. package/lib/pr.ts +234 -0
  256. package/lib/projects.ts +578 -0
  257. package/lib/providers/registry.ts +70 -0
  258. package/lib/providers.ts +121 -0
  259. package/lib/query-client.ts +14 -0
  260. package/lib/rangeSelectionUtils.ts +65 -0
  261. package/lib/status-detector.ts +375 -0
  262. package/lib/terminal-themes.ts +265 -0
  263. package/lib/theme-config.ts +327 -0
  264. package/lib/utils.ts +6 -0
  265. package/lib/worktrees.ts +262 -0
  266. package/mcp/orchestration-server.ts +438 -0
  267. package/package.json +139 -0
  268. package/postcss.config.mjs +7 -0
  269. package/public/icon.svg +10 -0
  270. package/public/icons/icon-128x128.png +0 -0
  271. package/public/icons/icon-144x144.png +0 -0
  272. package/public/icons/icon-152x152.png +0 -0
  273. package/public/icons/icon-192x192.png +0 -0
  274. package/public/icons/icon-384x384.png +0 -0
  275. package/public/icons/icon-512x512.png +0 -0
  276. package/public/icons/icon-72x72.png +0 -0
  277. package/public/icons/icon-96x96.png +0 -0
  278. package/public/manifest.json +61 -0
  279. package/public/sw.js +64 -0
  280. package/scripts/agent-os +91 -0
  281. package/scripts/install.sh +48 -0
  282. package/scripts/lib/ai-clis.sh +132 -0
  283. package/scripts/lib/commands.sh +487 -0
  284. package/scripts/lib/common.sh +89 -0
  285. package/scripts/lib/prerequisites.sh +462 -0
  286. package/scripts/setup.sh +134 -0
  287. package/server.ts +155 -0
  288. package/stores/fileOpen.ts +26 -0
  289. package/stores/index.ts +1 -0
  290. package/stores/initialPrompt.ts +24 -0
  291. package/stores/sessionSelection.ts +48 -0
  292. package/styles/themes.css +603 -0
  293. package/tsconfig.json +33 -0
@@ -0,0 +1,811 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ DialogFooter,
10
+ } from "@/components/ui/dialog";
11
+ import { Button } from "@/components/ui/button";
12
+ import { Input } from "@/components/ui/input";
13
+ import { Textarea } from "@/components/ui/textarea";
14
+ import {
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ } from "@/components/ui/select";
21
+ import {
22
+ Plus,
23
+ Trash2,
24
+ Loader2,
25
+ RefreshCw,
26
+ Server,
27
+ GitBranch,
28
+ Star,
29
+ FolderOpen,
30
+ } from "lucide-react";
31
+ import { FolderPicker } from "@/components/FolderPicker";
32
+ import { useUpdateProject } from "@/data/projects";
33
+ import { useQueryClient } from "@tanstack/react-query";
34
+ import { devServerKeys } from "@/data/dev-servers";
35
+ import { repositoryKeys } from "@/data/repositories";
36
+ import type { AgentType } from "@/lib/providers";
37
+ import type {
38
+ ProjectWithRepositories,
39
+ DetectedDevServer,
40
+ } from "@/lib/projects";
41
+
42
+ const AGENT_OPTIONS: { value: AgentType; label: string }[] = [
43
+ { value: "claude", label: "Claude Code" },
44
+ ];
45
+
46
+ const MODEL_OPTIONS = [
47
+ { value: "sonnet", label: "Sonnet" },
48
+ { value: "opus", label: "Opus" },
49
+ { value: "haiku", label: "Haiku" },
50
+ ];
51
+
52
+ interface DevServerConfig {
53
+ id: string;
54
+ name: string;
55
+ type: "node" | "docker";
56
+ command: string;
57
+ port?: number;
58
+ portEnvVar?: string;
59
+ isNew?: boolean;
60
+ isDeleted?: boolean;
61
+ }
62
+
63
+ interface RepositoryConfig {
64
+ id: string;
65
+ name: string;
66
+ path: string;
67
+ isPrimary: boolean;
68
+ isNew?: boolean;
69
+ isDeleted?: boolean;
70
+ }
71
+
72
+ interface ProjectSettingsDialogProps {
73
+ project: ProjectWithRepositories | null;
74
+ open: boolean;
75
+ onClose: () => void;
76
+ onSave: () => void;
77
+ }
78
+
79
+ export function ProjectSettingsDialog({
80
+ project,
81
+ open,
82
+ onClose,
83
+ onSave,
84
+ }: ProjectSettingsDialogProps) {
85
+ const [name, setName] = useState("");
86
+ const [workingDirectory, setWorkingDirectory] = useState("");
87
+ const [agentType, setAgentType] = useState<AgentType>("claude");
88
+ const [defaultModel, setDefaultModel] = useState("sonnet");
89
+ const [initialPrompt, setInitialPrompt] = useState("");
90
+ const [devServers, setDevServers] = useState<DevServerConfig[]>([]);
91
+ const [repositories, setRepositories] = useState<RepositoryConfig[]>([]);
92
+ const [isLoading, setIsLoading] = useState(false);
93
+ const [isDetecting, setIsDetecting] = useState(false);
94
+ const [isDetectingRepos, setIsDetectingRepos] = useState(false);
95
+ const [error, setError] = useState<string | null>(null);
96
+ const [folderPickerRepoId, setFolderPickerRepoId] = useState<string | null>(
97
+ null
98
+ );
99
+
100
+ const updateProject = useUpdateProject();
101
+ const queryClient = useQueryClient();
102
+
103
+ // Initialize form when project changes
104
+ useEffect(() => {
105
+ if (project) {
106
+ setName(project.name);
107
+ setWorkingDirectory(project.working_directory);
108
+ setAgentType(project.agent_type);
109
+ setDefaultModel(project.default_model);
110
+ setInitialPrompt(project.initial_prompt || "");
111
+ setDevServers(
112
+ project.devServers.map((ds) => ({
113
+ id: ds.id,
114
+ name: ds.name,
115
+ type: ds.type,
116
+ command: ds.command,
117
+ port: ds.port || undefined,
118
+ portEnvVar: ds.port_env_var || undefined,
119
+ }))
120
+ );
121
+ setRepositories(
122
+ (project.repositories || []).map((repo) => ({
123
+ id: repo.id,
124
+ name: repo.name,
125
+ path: repo.path,
126
+ isPrimary: repo.is_primary,
127
+ }))
128
+ );
129
+ // Reset folder picker state when project changes
130
+ setFolderPickerRepoId(null);
131
+ }
132
+ }, [project]);
133
+
134
+ // Reset folder picker when dialog closes
135
+ useEffect(() => {
136
+ if (!open) {
137
+ setFolderPickerRepoId(null);
138
+ }
139
+ }, [open]);
140
+
141
+ // Detect dev servers
142
+ const detectDevServers = async () => {
143
+ if (!workingDirectory) return;
144
+
145
+ setIsDetecting(true);
146
+ try {
147
+ const res = await fetch("/api/projects/detect", {
148
+ method: "POST",
149
+ headers: { "Content-Type": "application/json" },
150
+ body: JSON.stringify({ workingDirectory }),
151
+ });
152
+
153
+ if (res.ok) {
154
+ const data = await res.json();
155
+ const detected = (data.detected || []) as DetectedDevServer[];
156
+
157
+ // Add detected servers that don't already exist
158
+ const existingCommands = new Set(devServers.map((ds) => ds.command));
159
+ const newServers = detected
160
+ .filter((d) => !existingCommands.has(d.command))
161
+ .map((d, i) => ({
162
+ id: `new_${Date.now()}_${i}`,
163
+ name: d.name,
164
+ type: d.type,
165
+ command: d.command,
166
+ port: d.port,
167
+ portEnvVar: d.portEnvVar,
168
+ isNew: true,
169
+ }));
170
+
171
+ setDevServers((prev) => [...prev, ...newServers]);
172
+ }
173
+ } catch (err) {
174
+ console.error("Failed to detect dev servers:", err);
175
+ } finally {
176
+ setIsDetecting(false);
177
+ }
178
+ };
179
+
180
+ // Add new dev server config
181
+ const addDevServer = () => {
182
+ setDevServers((prev) => [
183
+ ...prev,
184
+ {
185
+ id: `new_${Date.now()}`,
186
+ name: "",
187
+ type: "node",
188
+ command: "",
189
+ isNew: true,
190
+ },
191
+ ]);
192
+ };
193
+
194
+ // Remove dev server config
195
+ const removeDevServer = (id: string) => {
196
+ setDevServers(
197
+ (prev) =>
198
+ prev
199
+ .map((ds) =>
200
+ ds.id === id
201
+ ? ds.isNew
202
+ ? null // Remove new items completely
203
+ : { ...ds, isDeleted: true } // Mark existing for deletion
204
+ : ds
205
+ )
206
+ .filter(Boolean) as DevServerConfig[]
207
+ );
208
+ };
209
+
210
+ // Update dev server config
211
+ const updateDevServer = (id: string, updates: Partial<DevServerConfig>) => {
212
+ setDevServers((prev) =>
213
+ prev.map((ds) => (ds.id === id ? { ...ds, ...updates } : ds))
214
+ );
215
+ };
216
+
217
+ // Detect git repositories in working directory
218
+ const detectRepositories = async () => {
219
+ if (!workingDirectory) return;
220
+
221
+ setIsDetectingRepos(true);
222
+ try {
223
+ // Check if the working directory itself is a git repo
224
+ const res = await fetch(
225
+ `/api/git/status?path=${encodeURIComponent(workingDirectory)}`
226
+ );
227
+ if (res.ok) {
228
+ const existingPaths = new Set(repositories.map((r) => r.path));
229
+ if (!existingPaths.has(workingDirectory)) {
230
+ // Extract repo name from path
231
+ const pathParts = workingDirectory.split("/").filter(Boolean);
232
+ const repoName = pathParts[pathParts.length - 1] || "Repository";
233
+
234
+ setRepositories((prev) => [
235
+ ...prev,
236
+ {
237
+ id: `new_${Date.now()}`,
238
+ name: repoName,
239
+ path: workingDirectory,
240
+ isPrimary: prev.length === 0,
241
+ isNew: true,
242
+ },
243
+ ]);
244
+ }
245
+ }
246
+ } catch {
247
+ // Not a git repo, that's okay
248
+ } finally {
249
+ setIsDetectingRepos(false);
250
+ }
251
+ };
252
+
253
+ // Add new repository config - opens folder picker directly
254
+ const addRepository = () => {
255
+ const newId = `new_${Date.now()}`;
256
+ setRepositories((prev) => [
257
+ ...prev,
258
+ {
259
+ id: newId,
260
+ name: "",
261
+ path: "",
262
+ isPrimary: prev.filter((r) => !r.isDeleted).length === 0,
263
+ isNew: true,
264
+ },
265
+ ]);
266
+ // Open folder picker for the new repository
267
+ setFolderPickerRepoId(newId);
268
+ };
269
+
270
+ // Remove repository config
271
+ const removeRepository = (id: string) => {
272
+ setRepositories(
273
+ (prev) =>
274
+ prev
275
+ .map((repo) =>
276
+ repo.id === id
277
+ ? repo.isNew
278
+ ? null // Remove new items completely
279
+ : { ...repo, isDeleted: true } // Mark existing for deletion
280
+ : repo
281
+ )
282
+ .filter(Boolean) as RepositoryConfig[]
283
+ );
284
+ };
285
+
286
+ // Update repository config
287
+ const updateRepository = (id: string, updates: Partial<RepositoryConfig>) => {
288
+ setRepositories((prev) =>
289
+ prev.map((repo) => (repo.id === id ? { ...repo, ...updates } : repo))
290
+ );
291
+ };
292
+
293
+ // Set a repository as primary
294
+ const setRepositoryPrimary = (id: string) => {
295
+ setRepositories((prev) =>
296
+ prev.map((repo) => ({
297
+ ...repo,
298
+ isPrimary: repo.id === id,
299
+ }))
300
+ );
301
+ };
302
+
303
+ const handleSubmit = async (e: React.FormEvent) => {
304
+ e.preventDefault();
305
+ if (!project) return;
306
+ setError(null);
307
+
308
+ if (!name.trim()) {
309
+ setError("Project name is required");
310
+ return;
311
+ }
312
+
313
+ setIsLoading(true);
314
+ try {
315
+ // Update project settings using mutation (properly invalidates cache)
316
+ await updateProject.mutateAsync({
317
+ projectId: project.id,
318
+ name: name.trim(),
319
+ workingDirectory,
320
+ agentType,
321
+ defaultModel,
322
+ initialPrompt: initialPrompt.trim() || null,
323
+ });
324
+
325
+ // Handle dev server changes
326
+ for (const ds of devServers) {
327
+ if (ds.isDeleted && !ds.isNew) {
328
+ // Delete existing dev server
329
+ await fetch(`/api/projects/${project.id}/dev-servers/${ds.id}`, {
330
+ method: "DELETE",
331
+ });
332
+ } else if (
333
+ ds.isNew &&
334
+ !ds.isDeleted &&
335
+ ds.name.trim() &&
336
+ ds.command.trim()
337
+ ) {
338
+ // Create new dev server
339
+ await fetch(`/api/projects/${project.id}/dev-servers`, {
340
+ method: "POST",
341
+ headers: { "Content-Type": "application/json" },
342
+ body: JSON.stringify({
343
+ name: ds.name.trim(),
344
+ type: ds.type,
345
+ command: ds.command.trim(),
346
+ port: ds.port || undefined,
347
+ portEnvVar: ds.portEnvVar || undefined,
348
+ }),
349
+ });
350
+ } else if (!ds.isNew && !ds.isDeleted) {
351
+ // Update existing dev server
352
+ await fetch(`/api/projects/${project.id}/dev-servers/${ds.id}`, {
353
+ method: "PATCH",
354
+ headers: { "Content-Type": "application/json" },
355
+ body: JSON.stringify({
356
+ name: ds.name.trim(),
357
+ type: ds.type,
358
+ command: ds.command.trim(),
359
+ port: ds.port || undefined,
360
+ portEnvVar: ds.portEnvVar || undefined,
361
+ }),
362
+ });
363
+ }
364
+ }
365
+
366
+ // Handle repository changes
367
+ for (const repo of repositories) {
368
+ if (repo.isDeleted && !repo.isNew) {
369
+ // Delete existing repository
370
+ await fetch(`/api/projects/${project.id}/repositories/${repo.id}`, {
371
+ method: "DELETE",
372
+ });
373
+ } else if (
374
+ repo.isNew &&
375
+ !repo.isDeleted &&
376
+ repo.name.trim() &&
377
+ repo.path.trim()
378
+ ) {
379
+ // Create new repository
380
+ await fetch(`/api/projects/${project.id}/repositories`, {
381
+ method: "POST",
382
+ headers: { "Content-Type": "application/json" },
383
+ body: JSON.stringify({
384
+ name: repo.name.trim(),
385
+ path: repo.path.trim(),
386
+ isPrimary: repo.isPrimary,
387
+ }),
388
+ });
389
+ } else if (!repo.isNew && !repo.isDeleted) {
390
+ // Update existing repository
391
+ await fetch(`/api/projects/${project.id}/repositories/${repo.id}`, {
392
+ method: "PATCH",
393
+ headers: { "Content-Type": "application/json" },
394
+ body: JSON.stringify({
395
+ name: repo.name.trim(),
396
+ path: repo.path.trim(),
397
+ isPrimary: repo.isPrimary,
398
+ }),
399
+ });
400
+ }
401
+ }
402
+
403
+ // Invalidate dev servers cache so list updates
404
+ queryClient.invalidateQueries({ queryKey: devServerKeys.list() });
405
+ // Invalidate repositories cache
406
+ queryClient.invalidateQueries({
407
+ queryKey: repositoryKeys.list(project.id),
408
+ });
409
+
410
+ handleClose();
411
+ onSave();
412
+ } catch (err) {
413
+ console.error("Failed to update project:", err);
414
+ setError("Failed to update project");
415
+ } finally {
416
+ setIsLoading(false);
417
+ }
418
+ };
419
+
420
+ const handleClose = () => {
421
+ setError(null);
422
+ setFolderPickerRepoId(null);
423
+ onClose();
424
+ };
425
+
426
+ const visibleDevServers = devServers.filter((ds) => !ds.isDeleted);
427
+ const visibleRepositories = repositories.filter((repo) => !repo.isDeleted);
428
+
429
+ if (!project) return null;
430
+
431
+ return (
432
+ <>
433
+ <Dialog
434
+ open={open && !folderPickerRepoId}
435
+ onOpenChange={(o) => !o && handleClose()}
436
+ >
437
+ <DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
438
+ <DialogHeader>
439
+ <DialogTitle>Project Settings</DialogTitle>
440
+ </DialogHeader>
441
+
442
+ <form onSubmit={handleSubmit} className="space-y-4">
443
+ {/* Project Name */}
444
+ <div className="space-y-2">
445
+ <label className="text-sm font-medium">Project Name</label>
446
+ <Input
447
+ value={name}
448
+ onChange={(e) => setName(e.target.value)}
449
+ placeholder="my-awesome-project"
450
+ />
451
+ </div>
452
+
453
+ {/* Working Directory */}
454
+ <div className="space-y-2">
455
+ <label className="text-sm font-medium">Working Directory</label>
456
+ <Input
457
+ value={workingDirectory}
458
+ onChange={(e) => setWorkingDirectory(e.target.value)}
459
+ placeholder="~/projects/my-app"
460
+ />
461
+ </div>
462
+
463
+ {/* Agent Type */}
464
+ <div className="space-y-2">
465
+ <label className="text-sm font-medium">Default Agent</label>
466
+ <Select
467
+ value={agentType}
468
+ onValueChange={(v) => setAgentType(v as AgentType)}
469
+ >
470
+ <SelectTrigger>
471
+ <SelectValue />
472
+ </SelectTrigger>
473
+ <SelectContent>
474
+ {AGENT_OPTIONS.map((opt) => (
475
+ <SelectItem key={opt.value} value={opt.value}>
476
+ {opt.label}
477
+ </SelectItem>
478
+ ))}
479
+ </SelectContent>
480
+ </Select>
481
+ </div>
482
+
483
+ {/* Default Model */}
484
+ <div className="space-y-2">
485
+ <label className="text-sm font-medium">Default Model</label>
486
+ <Select value={defaultModel} onValueChange={setDefaultModel}>
487
+ <SelectTrigger>
488
+ <SelectValue />
489
+ </SelectTrigger>
490
+ <SelectContent>
491
+ {MODEL_OPTIONS.map((opt) => (
492
+ <SelectItem key={opt.value} value={opt.value}>
493
+ {opt.label}
494
+ </SelectItem>
495
+ ))}
496
+ </SelectContent>
497
+ </Select>
498
+ </div>
499
+
500
+ {/* Initial Prompt */}
501
+ <div className="space-y-2">
502
+ <label className="text-sm font-medium">Initial Prompt</label>
503
+ <Textarea
504
+ value={initialPrompt}
505
+ onChange={(e) => setInitialPrompt(e.target.value)}
506
+ placeholder="This prompt will be prepended to all sessions in this project..."
507
+ rows={3}
508
+ className="resize-none"
509
+ />
510
+ <p className="text-muted-foreground text-xs">
511
+ This prompt will be automatically prepended to all new sessions
512
+ created in this project.
513
+ </p>
514
+ </div>
515
+
516
+ {/* Dev Servers */}
517
+ <div className="space-y-3">
518
+ <div className="flex items-center justify-between">
519
+ <label className="flex items-center gap-2 text-sm font-medium">
520
+ <Server className="h-4 w-4" />
521
+ Dev Servers
522
+ </label>
523
+ <div className="flex gap-1">
524
+ <Button
525
+ type="button"
526
+ variant="outline"
527
+ size="sm"
528
+ onClick={detectDevServers}
529
+ disabled={isDetecting || !workingDirectory}
530
+ >
531
+ {isDetecting ? (
532
+ <Loader2 className="mr-1 h-3 w-3 animate-spin" />
533
+ ) : (
534
+ <RefreshCw className="mr-1 h-3 w-3" />
535
+ )}
536
+ Detect
537
+ </Button>
538
+ <Button
539
+ type="button"
540
+ variant="outline"
541
+ size="sm"
542
+ onClick={addDevServer}
543
+ >
544
+ <Plus className="mr-1 h-3 w-3" />
545
+ Add
546
+ </Button>
547
+ </div>
548
+ </div>
549
+
550
+ {visibleDevServers.length === 0 ? (
551
+ <p className="text-muted-foreground py-2 text-sm">
552
+ No dev servers configured.
553
+ </p>
554
+ ) : (
555
+ <div className="space-y-2">
556
+ {visibleDevServers.map((ds) => (
557
+ <div
558
+ key={ds.id}
559
+ className="bg-accent/30 space-y-2 rounded-lg p-3"
560
+ >
561
+ <div className="flex items-center gap-2">
562
+ <Input
563
+ value={ds.name}
564
+ onChange={(e) =>
565
+ updateDevServer(ds.id, { name: e.target.value })
566
+ }
567
+ placeholder="Server name"
568
+ className="h-8 flex-1"
569
+ />
570
+ <Select
571
+ value={ds.type}
572
+ onValueChange={(v) =>
573
+ updateDevServer(ds.id, {
574
+ type: v as "node" | "docker",
575
+ })
576
+ }
577
+ >
578
+ <SelectTrigger className="h-8 w-24">
579
+ <SelectValue />
580
+ </SelectTrigger>
581
+ <SelectContent>
582
+ <SelectItem value="node">Node</SelectItem>
583
+ <SelectItem value="docker">Docker</SelectItem>
584
+ </SelectContent>
585
+ </Select>
586
+ <Button
587
+ type="button"
588
+ variant="ghost"
589
+ size="icon-sm"
590
+ onClick={() => removeDevServer(ds.id)}
591
+ className="text-red-500 hover:text-red-600"
592
+ >
593
+ <Trash2 className="h-3 w-3" />
594
+ </Button>
595
+ </div>
596
+ <Input
597
+ value={ds.command}
598
+ onChange={(e) =>
599
+ updateDevServer(ds.id, { command: e.target.value })
600
+ }
601
+ placeholder={
602
+ ds.type === "docker" ? "Service name" : "npm run dev"
603
+ }
604
+ className="h-8"
605
+ />
606
+ <div className="flex gap-2">
607
+ <Input
608
+ type="number"
609
+ value={ds.port || ""}
610
+ onChange={(e) =>
611
+ updateDevServer(ds.id, {
612
+ port: e.target.value
613
+ ? parseInt(e.target.value)
614
+ : undefined,
615
+ })
616
+ }
617
+ placeholder="Port"
618
+ className="h-8 w-24"
619
+ />
620
+ <Input
621
+ value={ds.portEnvVar || ""}
622
+ onChange={(e) =>
623
+ updateDevServer(ds.id, {
624
+ portEnvVar: e.target.value,
625
+ })
626
+ }
627
+ placeholder="Port env var (e.g., PORT)"
628
+ className="h-8 flex-1"
629
+ />
630
+ </div>
631
+ </div>
632
+ ))}
633
+ </div>
634
+ )}
635
+ </div>
636
+
637
+ {/* Repositories */}
638
+ <div className="space-y-3">
639
+ <div className="flex items-center justify-between">
640
+ <label className="flex items-center gap-2 text-sm font-medium">
641
+ <GitBranch className="h-4 w-4" />
642
+ Git Repositories
643
+ </label>
644
+ <div className="flex gap-1">
645
+ <Button
646
+ type="button"
647
+ variant="outline"
648
+ size="sm"
649
+ onClick={detectRepositories}
650
+ disabled={isDetectingRepos || !workingDirectory}
651
+ >
652
+ {isDetectingRepos ? (
653
+ <Loader2 className="mr-1 h-3 w-3 animate-spin" />
654
+ ) : (
655
+ <RefreshCw className="mr-1 h-3 w-3" />
656
+ )}
657
+ Detect
658
+ </Button>
659
+ <Button
660
+ type="button"
661
+ variant="outline"
662
+ size="sm"
663
+ onClick={addRepository}
664
+ >
665
+ <Plus className="mr-1 h-3 w-3" />
666
+ Add
667
+ </Button>
668
+ </div>
669
+ </div>
670
+
671
+ {visibleRepositories.length === 0 ? (
672
+ <p className="text-muted-foreground py-2 text-sm">
673
+ No repositories configured. Git changes will use the working
674
+ directory.
675
+ </p>
676
+ ) : (
677
+ <div className="space-y-2">
678
+ {visibleRepositories.map((repo) => (
679
+ <div
680
+ key={repo.id}
681
+ className="bg-accent/30 space-y-2 rounded-lg p-3"
682
+ >
683
+ <div className="flex items-center gap-2">
684
+ <Input
685
+ value={repo.name}
686
+ onChange={(e) =>
687
+ updateRepository(repo.id, { name: e.target.value })
688
+ }
689
+ placeholder="Repository name"
690
+ className="h-8 flex-1"
691
+ />
692
+ <Button
693
+ type="button"
694
+ variant={repo.isPrimary ? "default" : "ghost"}
695
+ size="icon-sm"
696
+ onClick={() => setRepositoryPrimary(repo.id)}
697
+ title={
698
+ repo.isPrimary
699
+ ? "Primary repository"
700
+ : "Set as primary"
701
+ }
702
+ className={repo.isPrimary ? "text-yellow-500" : ""}
703
+ >
704
+ <Star
705
+ className={`h-3 w-3 ${repo.isPrimary ? "fill-current" : ""}`}
706
+ />
707
+ </Button>
708
+ <Button
709
+ type="button"
710
+ variant="ghost"
711
+ size="icon-sm"
712
+ onClick={() => removeRepository(repo.id)}
713
+ className="text-red-500 hover:text-red-600"
714
+ >
715
+ <Trash2 className="h-3 w-3" />
716
+ </Button>
717
+ </div>
718
+ <div className="flex gap-2">
719
+ <Input
720
+ value={repo.path}
721
+ onChange={(e) =>
722
+ updateRepository(repo.id, { path: e.target.value })
723
+ }
724
+ placeholder="~/path/to/repository"
725
+ className="h-8 flex-1"
726
+ />
727
+ <Button
728
+ type="button"
729
+ variant="outline"
730
+ size="icon-sm"
731
+ onClick={() => setFolderPickerRepoId(repo.id)}
732
+ title="Browse folders"
733
+ >
734
+ <FolderOpen className="h-3.5 w-3.5" />
735
+ </Button>
736
+ </div>
737
+ </div>
738
+ ))}
739
+ </div>
740
+ )}
741
+ <p className="text-muted-foreground text-xs">
742
+ Configure multiple git repositories to track changes across
743
+ repos.
744
+ </p>
745
+ </div>
746
+
747
+ {error && <p className="text-sm text-red-500">{error}</p>}
748
+
749
+ <DialogFooter>
750
+ <Button type="button" variant="outline" onClick={handleClose}>
751
+ Cancel
752
+ </Button>
753
+ <Button type="submit" disabled={isLoading}>
754
+ {isLoading ? "Saving..." : "Save Changes"}
755
+ </Button>
756
+ </DialogFooter>
757
+ </form>
758
+ </DialogContent>
759
+ </Dialog>
760
+
761
+ {/* Folder Picker for repository path */}
762
+ {folderPickerRepoId && (
763
+ <FolderPicker
764
+ key={folderPickerRepoId}
765
+ initialPath={
766
+ repositories.find((r) => r.id === folderPickerRepoId)?.path ||
767
+ workingDirectory ||
768
+ "~"
769
+ }
770
+ onSelect={(path) => {
771
+ // Capture repoId immediately - must be done before any state updates
772
+ const repoId = folderPickerRepoId;
773
+ if (!repoId) return;
774
+
775
+ // Auto-fill name from path if empty
776
+ const pathParts = path.split("/").filter(Boolean);
777
+ const name = pathParts[pathParts.length - 1] || "Repository";
778
+
779
+ // First close the picker
780
+ setFolderPickerRepoId(null);
781
+
782
+ // Then update the repository
783
+ setRepositories((prev) =>
784
+ prev.map((r) =>
785
+ r.id === repoId ? { ...r, path, name: r.name || name } : r
786
+ )
787
+ );
788
+ }}
789
+ onClose={() => {
790
+ // Capture repoId immediately
791
+ const repoId = folderPickerRepoId;
792
+
793
+ // First close the picker
794
+ setFolderPickerRepoId(null);
795
+
796
+ // If the repo has no path (user cancelled on new repo), remove it
797
+ if (repoId) {
798
+ setRepositories((prev) => {
799
+ const repo = prev.find((r) => r.id === repoId);
800
+ if (repo?.isNew && !repo.path) {
801
+ return prev.filter((r) => r.id !== repoId);
802
+ }
803
+ return prev;
804
+ });
805
+ }
806
+ }}
807
+ />
808
+ )}
809
+ </>
810
+ );
811
+ }