openclacky 1.5.2 → 1.5.4

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +13 -0
  3. data/CHANGELOG.md +50 -0
  4. data/Dockerfile +21 -1
  5. data/README.md +26 -7
  6. data/README_CN.md +26 -7
  7. data/README_JA.md +26 -7
  8. data/lib/clacky/agent/message_compressor.rb +4 -29
  9. data/lib/clacky/agent/message_compressor_helper.rb +13 -15
  10. data/lib/clacky/agent/session_serializer.rb +6 -0
  11. data/lib/clacky/agent.rb +13 -1
  12. data/lib/clacky/agent_config.rb +42 -11
  13. data/lib/clacky/brand_config.rb +32 -12
  14. data/lib/clacky/client.rb +7 -0
  15. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +15 -0
  16. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +2 -6
  17. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +11 -4
  18. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +5 -1
  19. data/lib/clacky/default_parsers/xlsx_parser.py +66 -0
  20. data/lib/clacky/extension/api_extension.rb +3 -2
  21. data/lib/clacky/message_format/anthropic.rb +27 -1
  22. data/lib/clacky/message_format/open_ai.rb +92 -4
  23. data/lib/clacky/providers.rb +67 -14
  24. data/lib/clacky/server/http_server.rb +361 -36
  25. data/lib/clacky/server/project_manager.rb +150 -0
  26. data/lib/clacky/server/server_master.rb +7 -0
  27. data/lib/clacky/server/session_registry.rb +14 -1
  28. data/lib/clacky/session_manager.rb +5 -4
  29. data/lib/clacky/tools/file_reader.rb +2 -1
  30. data/lib/clacky/utils/model_pricing.rb +77 -18
  31. data/lib/clacky/utils/parser_manager.rb +115 -7
  32. data/lib/clacky/version.rb +1 -1
  33. data/lib/clacky/web/app.css +1053 -143
  34. data/lib/clacky/web/app.js +19 -5
  35. data/lib/clacky/web/components/code-editor.js +50 -12
  36. data/lib/clacky/web/components/notify.js +139 -22
  37. data/lib/clacky/web/core/aside.js +21 -0
  38. data/lib/clacky/web/features/billing/view.js +2 -1
  39. data/lib/clacky/web/features/brand/store.js +3 -1
  40. data/lib/clacky/web/features/brand/view.js +29 -5
  41. data/lib/clacky/web/features/extensions/store.js +15 -6
  42. data/lib/clacky/web/features/extensions/view.js +8 -4
  43. data/lib/clacky/web/features/new-session/view.js +2 -2
  44. data/lib/clacky/web/features/trash/store.js +23 -9
  45. data/lib/clacky/web/features/trash/view.js +51 -0
  46. data/lib/clacky/web/features/workspace/store.js +9 -0
  47. data/lib/clacky/web/features/workspace/view.js +8 -1
  48. data/lib/clacky/web/i18n.js +99 -4
  49. data/lib/clacky/web/index.html +70 -17
  50. data/lib/clacky/web/projects.js +1143 -0
  51. data/lib/clacky/web/sessions.js +318 -537
  52. data/lib/clacky/web/settings.js +146 -7
  53. data/lib/clacky/web/ws-dispatcher.js +59 -1
  54. data/scripts/build/src/install_system_deps.sh.cc +11 -3
  55. data/scripts/install_system_deps.sh +11 -3
  56. metadata +5 -2
  57. data/lib/clacky/default_parsers/xlsx_parser.rb +0 -121
@@ -0,0 +1,1143 @@
1
+ // ── Projects — project state, CRUD, sidebar rendering ─────────────────────
2
+ //
3
+ // Responsibilities:
4
+ // - Maintain the canonical projects list (received via session_list WS event)
5
+ // - CRUD: create / update / delete projects via REST API
6
+ // - Move sessions in / out of projects via PATCH /api/sessions/:id/project
7
+ // - Render the #project-section sidebar area (two modes: grouped / flat)
8
+ //
9
+ // Depends on: Sessions (sessions.js), global $ / escapeHtml helpers
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+
12
+ const Projects = (() => {
13
+ let _projects = []; // [{ id, name, description, color, created_at, updated_at }]
14
+
15
+ // "grouped" (default) — each project is a collapsible row
16
+ // "flat" — all project-sessions listed in one flat list with project tag
17
+ let _viewMode = "grouped";
18
+
19
+ // "recent" (default) — sort by updated_at desc
20
+ // "priority" — oldest-created project first
21
+ const _LS_SORT_KEY = "clacky_projects_sort_mode";
22
+ const _LS_COLLAPSED_KEY = "clacky_projects_collapsed";
23
+ const _LS_SECTION_COLLAPSE_KEY = "clacky_projects_section_collapsed";
24
+ let _sortMode = localStorage.getItem(_LS_SORT_KEY) || "recent";
25
+ let _sectionCollapsed = localStorage.getItem(_LS_SECTION_COLLAPSE_KEY) === "1";
26
+
27
+ // Track which project groups are collapsed: Set of project IDs (persisted to localStorage)
28
+ const _collapsed = new Set(
29
+ JSON.parse(localStorage.getItem(_LS_COLLAPSED_KEY) || "[]")
30
+ );
31
+
32
+ function _saveCollapsed() {
33
+ localStorage.setItem(_LS_COLLAPSED_KEY, JSON.stringify([..._collapsed]));
34
+ }
35
+
36
+ /** Return a sorted copy of _projects according to _sortMode. */
37
+ function _sortProjects(projects) {
38
+ return [...projects].sort((a, b) => {
39
+ if (_sortMode === "priority") {
40
+ // Oldest project first (by created_at)
41
+ return (a.created_at || "").localeCompare(b.created_at || "");
42
+ }
43
+ // Default "recent": most recently updated first
44
+ const ta = a.updated_at || a.created_at || "";
45
+ const tb = b.updated_at || b.created_at || "";
46
+ return tb.localeCompare(ta);
47
+ });
48
+ }
49
+
50
+ // ── Shared icon helper ────────────────────────────────────────────────────
51
+ // Reads SVG path from the picker popup DOM and forces a consistent size.
52
+ // Falls back to a hard-coded folder icon when the popup isn't in the DOM yet.
53
+ const _FOLDER_PATH = `<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>`;
54
+ const _FOLDER_OPEN_PATH = `<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/>`;
55
+ function _getIconSvg(iconName, size = 14) {
56
+ // folder-open is not in the picker; use the built-in path directly
57
+ if (iconName === "folder-open") {
58
+ return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${_FOLDER_OPEN_PATH}</svg>`;
59
+ }
60
+ const popup = document.getElementById("project-picker-icons");
61
+ let inner = null;
62
+ if (popup) {
63
+ const btn = popup.querySelector(`[data-icon="${iconName}"]`);
64
+ if (btn) {
65
+ // Clone the SVG and override size attrs
66
+ const svg = btn.querySelector("svg");
67
+ if (svg) inner = svg.innerHTML;
68
+ }
69
+ if (!inner) {
70
+ const first = popup.querySelector("[data-icon] svg");
71
+ if (first) inner = first.innerHTML;
72
+ }
73
+ }
74
+ if (!inner) inner = _FOLDER_PATH;
75
+ return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
76
+ }
77
+
78
+ // ── State accessors ────────────────────────────────────────────────────────
79
+
80
+ function all() { return _projects; }
81
+
82
+ function find(id) { return id != null ? (_projects.find(p => p.id === String(id)) || null) : null; }
83
+
84
+ /** Called by ws-dispatcher when session_list arrives — replaces full list. */
85
+ function setAll(list) {
86
+ _projects = Array.isArray(list) ? [...list] : [];
87
+ }
88
+
89
+ // ── REST API ───────────────────────────────────────────────────────────────
90
+
91
+ /** POST /api/projects — create a project. Returns { project } or throws. */
92
+ async function create({ name, description = null, color = null, icon = null, working_dir = null } = {}) {
93
+ const res = await fetch("/api/projects", {
94
+ method: "POST",
95
+ headers: { "Content-Type": "application/json" },
96
+ body: JSON.stringify({ name, description, color, icon, working_dir }),
97
+ });
98
+ const data = await res.json();
99
+ if (!res.ok) throw new Error(data.error || "Failed to create project");
100
+
101
+ const project = data.project;
102
+ _projects.push(project);
103
+ return project;
104
+ }
105
+
106
+ /** PATCH /api/projects/:id — update a project's name / description / color. */
107
+ async function update(id, fields = {}) {
108
+ const res = await fetch(`/api/projects/${id}`, {
109
+ method: "PATCH",
110
+ headers: { "Content-Type": "application/json" },
111
+ body: JSON.stringify(fields),
112
+ });
113
+ const data = await res.json();
114
+ if (!res.ok) throw new Error(data.error || "Failed to update project");
115
+
116
+ const updated = data.project;
117
+ const idx = _projects.findIndex(p => p.id === id);
118
+ if (idx !== -1) _projects[idx] = updated;
119
+ return updated;
120
+ }
121
+
122
+ /**
123
+ * DELETE /api/projects/:id — delete a project and all its sessions.
124
+ * The server soft-deletes the sessions; we remove them from local state
125
+ * and trigger a re-render.
126
+ */
127
+ async function remove(id) {
128
+ const res = await fetch(`/api/projects/${id}`, { method: "DELETE" });
129
+ const data = await res.json();
130
+ if (!res.ok) throw new Error(data.error || "Failed to delete project");
131
+
132
+ _projects = _projects.filter(p => p.id !== id);
133
+ _collapsed.delete(id);
134
+ _saveCollapsed();
135
+ // Remove all sessions belonging to this project from local memory,
136
+ // then re-render both sections in one pass.
137
+ Sessions.removeByProjectId(id);
138
+ Sessions.renderList();
139
+ renderSection();
140
+ }
141
+
142
+ /**
143
+ * PATCH /api/sessions/:sessionId/project — assign or remove a session's project.
144
+ * Pass projectId=null to remove from project.
145
+ */
146
+ async function moveSession(sessionId, projectId) {
147
+ const res = await fetch(`/api/sessions/${sessionId}/project`, {
148
+ method: "PATCH",
149
+ headers: { "Content-Type": "application/json" },
150
+ body: JSON.stringify({ project_id: projectId }),
151
+ });
152
+ const data = await res.json();
153
+ if (!res.ok) throw new Error(data.error || "Failed to move session");
154
+
155
+ Sessions.patch(sessionId, { project_id: projectId });
156
+ Sessions.renderList();
157
+ renderSection();
158
+ }
159
+
160
+ /**
161
+ * Render the #project-list container.
162
+ * Reads _viewMode to decide between "grouped" and "flat" layout.
163
+ * Called by ws-dispatcher after every list/update/delete event.
164
+ */
165
+ function renderSection() {
166
+ const list = document.getElementById("project-list");
167
+ if (!list) return;
168
+ list.innerHTML = "";
169
+
170
+ if (_projects.length === 0) {
171
+ // Show an empty hint so the section doesn't look broken
172
+ const hint = document.createElement("div");
173
+ hint.className = "project-empty-hint";
174
+ hint.textContent = I18n.t("projects.emptyHint", "No projects yet");
175
+ list.appendChild(hint);
176
+ return;
177
+ }
178
+
179
+ if (_viewMode === "flat") {
180
+ _renderFlat(list);
181
+ } else {
182
+ _renderGrouped(list);
183
+ }
184
+ }
185
+
186
+ /** Flat mode: all project-sessions in one list, each tagged with project name. */
187
+ function _renderFlat(container) {
188
+ const sortedProjects = _sortProjects(_projects);
189
+ const allSessions = Sessions.all;
190
+ const projectSessions = allSessions
191
+ .filter(s => s.project_id)
192
+ .sort((a, b) => {
193
+ // Primary: group by project, ordered by sortedProjects
194
+ if (a.project_id !== b.project_id) {
195
+ const ai = sortedProjects.findIndex(p => p.id === a.project_id);
196
+ const bi = sortedProjects.findIndex(p => p.id === b.project_id);
197
+ return ai - bi;
198
+ }
199
+ // Secondary: within project, newest session first
200
+ const ta = a.updated_at || a.created_at || "";
201
+ const tb = b.updated_at || b.created_at || "";
202
+ return tb.localeCompare(ta);
203
+ });
204
+
205
+ // Render with project tag only on first session of each project group
206
+ let lastProjectId = null;
207
+ projectSessions.forEach(s => {
208
+ // Show project tag header when project changes
209
+ if (s.project_id !== lastProjectId) {
210
+ lastProjectId = s.project_id;
211
+ const project = find(s.project_id);
212
+ if (project) {
213
+ const tag = document.createElement("div");
214
+ tag.className = "project-session-tag";
215
+ tag.textContent = project.name;
216
+ tag.style.setProperty("--project-color", project.color || "var(--color-accent-primary)");
217
+ container.appendChild(tag);
218
+ }
219
+ }
220
+
221
+ Sessions.renderSessionItem(container, s);
222
+ });
223
+ }
224
+
225
+ /** Grouped mode: each project is a collapsible row with indented sessions. */
226
+ function _renderGrouped(container) {
227
+ const sortedProjects = _sortProjects(_projects);
228
+
229
+ sortedProjects.forEach(project => {
230
+ const sessions = Sessions.all
231
+ .filter(s => s.project_id === project.id)
232
+ .sort((a, b) => {
233
+ // Sessions within a project: always newest first
234
+ const ta = a.updated_at || a.created_at || "";
235
+ const tb = b.updated_at || b.created_at || "";
236
+ return tb.localeCompare(ta);
237
+ });
238
+
239
+ const isCollapsed = _collapsed.has(project.id);
240
+
241
+ // ── Project header row ──────────────────────────────────────────
242
+ const header = document.createElement("div");
243
+ header.className = "project-group-header" + (isCollapsed ? " collapsed" : "");
244
+ header.dataset.projectId = project.id;
245
+
246
+ // Icon or color dot — use shared _getIconSvg helper (size=14)
247
+ // When icon is "folder", switch to folder-open when the group is expanded
248
+ const color = project.color || null;
249
+ const iconName = project.icon || "folder";
250
+ const displayIcon = (iconName === "folder" && !isCollapsed) ? "folder-open" : iconName;
251
+ const iconSvg = _getIconSvg(displayIcon);
252
+
253
+ const dot = document.createElement("span");
254
+ dot.className = "project-icon-badge";
255
+ if (color) dot.style.color = color;
256
+ dot.innerHTML = iconSvg;
257
+ header.appendChild(dot);
258
+
259
+ // Project name
260
+ const nameEl = document.createElement("span");
261
+ nameEl.className = "project-name";
262
+ nameEl.textContent = project.name;
263
+ header.appendChild(nameEl);
264
+
265
+ // New session button (✎)
266
+ const newSessionBtn = document.createElement("button");
267
+ newSessionBtn.className = "project-actions-btn project-new-session-btn";
268
+ newSessionBtn.title = "New session";
269
+ newSessionBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
270
+ <path d="M7.9 20A9 9 0 1 0 4 16.1L2 22z"/>
271
+ <path d="M12 8v8"/>
272
+ <path d="M8 12h8"/>
273
+ </svg>`;
274
+ newSessionBtn.addEventListener("click", async e => {
275
+ e.stopPropagation();
276
+ try {
277
+ const name = Sessions.nextDefaultName();
278
+ const res = await fetch("/api/sessions", {
279
+ method: "POST",
280
+ headers: { "Content-Type": "application/json" },
281
+ body: JSON.stringify({
282
+ name,
283
+ agent_profile: "general",
284
+ source: "manual",
285
+ project_id: project.id,
286
+ }),
287
+ });
288
+ const data = await res.json();
289
+ if (res.ok && data.session) {
290
+ Sessions.select(data.session.id);
291
+ }
292
+ } catch (err) {
293
+ console.error("Failed to create session:", err);
294
+ }
295
+ });
296
+ // Actions button (⋯)
297
+ const actionsBtn = document.createElement("button");
298
+ actionsBtn.className = "project-actions-btn";
299
+ actionsBtn.title = "Project options";
300
+ actionsBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
301
+ <circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/>
302
+ </svg>`;
303
+ actionsBtn.addEventListener("click", e => {
304
+ e.stopPropagation();
305
+ _clearTooltip();
306
+ _showProjectMenu(project.id, actionsBtn);
307
+ });
308
+ header.appendChild(actionsBtn);
309
+ header.appendChild(newSessionBtn);
310
+
311
+ // Tooltip on hover — show after short delay; hide via shared _hideTimer
312
+ header.addEventListener("mouseenter", () => {
313
+ _cancelHide();
314
+ clearTimeout(_tooltipTimer);
315
+ _tooltipTimer = setTimeout(() => {
316
+ _showProjectTooltip(project, sessions, header);
317
+ }, 300);
318
+ });
319
+ header.addEventListener("mouseleave", () => {
320
+ clearTimeout(_tooltipTimer);
321
+ _scheduleHide();
322
+ });
323
+
324
+ // Toggle collapse on header click — in-place DOM toggle, no full re-render
325
+ header.addEventListener("click", () => {
326
+ _clearTooltip();
327
+ const nowCollapsed = _collapsed.has(project.id);
328
+ if (nowCollapsed) {
329
+ _collapsed.delete(project.id);
330
+ } else {
331
+ _collapsed.add(project.id);
332
+ }
333
+ const willCollapse = !nowCollapsed;
334
+ _saveCollapsed();
335
+
336
+ // Toggle header class
337
+ header.classList.toggle("collapsed", willCollapse);
338
+
339
+ // Toggle folder icon in-place
340
+ const iconName = project.icon || "folder";
341
+ if (iconName === "folder") {
342
+ const dot = header.querySelector(".project-icon-badge");
343
+ if (dot) dot.innerHTML = _getIconSvg(willCollapse ? "folder" : "folder-open");
344
+ }
345
+
346
+ // Toggle session group visibility
347
+ const sessionGroup = header.nextElementSibling;
348
+ if (sessionGroup && sessionGroup.classList.contains("project-session-group")) {
349
+ sessionGroup.style.display = willCollapse ? "none" : "";
350
+ }
351
+ });
352
+
353
+ container.appendChild(header);
354
+
355
+ // ── Session items — always in DOM, hidden when collapsed ────────
356
+ const sessionGroup = document.createElement("div");
357
+ sessionGroup.className = "project-session-group";
358
+ if (isCollapsed) sessionGroup.style.display = "none";
359
+
360
+ if (sessions.length === 0) {
361
+ const empty = document.createElement("div");
362
+ empty.className = "project-sessions-empty";
363
+ empty.textContent = I18n.t("projects.noSessions", "No sessions");
364
+ sessionGroup.appendChild(empty);
365
+ } else {
366
+ sessions.forEach(s => Sessions.renderSessionItem(sessionGroup, s));
367
+ }
368
+
369
+ container.appendChild(sessionGroup);
370
+ });
371
+ }
372
+
373
+ // ── Project tooltip ────────────────────────────────────────────────────────
374
+
375
+ let _tooltipEl = null;
376
+ let _tooltipTimer = null; // show delay
377
+ let _hideTimer = null; // hide delay — shared by header + tooltip
378
+
379
+ /** Start hide countdown. Cancelled if mouse re-enters header or tooltip. */
380
+ function _scheduleHide() {
381
+ clearTimeout(_hideTimer);
382
+ _hideTimer = setTimeout(_clearTooltip, 150);
383
+ }
384
+
385
+ /** Cancel pending hide (mouse is back on header or tooltip). */
386
+ function _cancelHide() {
387
+ clearTimeout(_hideTimer);
388
+ _hideTimer = null;
389
+ }
390
+
391
+ /** Create and position the hover tooltip for a project. */
392
+ function _showProjectTooltip(project, sessions, anchorEl) {
393
+ _clearTooltip();
394
+
395
+ const tip = document.createElement("div");
396
+ tip.className = "project-tooltip";
397
+
398
+ // ── Header: icon + name + session count ──────────────────────────
399
+ const header = document.createElement("div");
400
+ header.className = "project-tooltip-header";
401
+
402
+ // Project icon — use shared helper (12px, matches text size)
403
+ const iconWrap = document.createElement("span");
404
+ iconWrap.className = "project-tooltip-icon";
405
+ iconWrap.innerHTML = _getIconSvg(project.icon || "folder", 12);
406
+ if (project.color) iconWrap.style.color = project.color;
407
+ header.appendChild(iconWrap);
408
+
409
+ const nameEl = document.createElement("span");
410
+ nameEl.className = "project-tooltip-name";
411
+ nameEl.textContent = project.name;
412
+ header.appendChild(nameEl);
413
+
414
+ tip.appendChild(header);
415
+
416
+ // ── Info rows ─────────────────────────────────────────────────────
417
+ const rows = document.createElement("div");
418
+ rows.className = "project-tooltip-rows";
419
+
420
+ // Helper: build one row with icon (12px) + text
421
+ const addRow = (iconSvgHtml, text, titleText) => {
422
+ const row = document.createElement("div");
423
+ row.className = "project-tooltip-row";
424
+
425
+ const iconSpan = document.createElement("span");
426
+ iconSpan.className = "project-tooltip-row-icon";
427
+ iconSpan.innerHTML = iconSvgHtml;
428
+ row.appendChild(iconSpan);
429
+
430
+ const textSpan = document.createElement("span");
431
+ textSpan.className = "project-tooltip-row-text";
432
+ textSpan.textContent = text;
433
+ if (titleText) textSpan.title = titleText;
434
+ row.appendChild(textSpan);
435
+
436
+ rows.appendChild(row);
437
+ };
438
+
439
+ // message-circle icon — simple chat bubble, no "+" (used for session count display)
440
+ const chatIcon = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22z"/></svg>`;
441
+
442
+ // Sessions row
443
+ const sessionText = sessions.length === 0
444
+ ? I18n.t("projects.noSessions", "No sessions")
445
+ : I18n.t("projects.tooltip.sessions", "{n} sessions").replace("{n}", sessions.length);
446
+ addRow(chatIcon, sessionText);
447
+
448
+ // Working dir row (only if set) — reuse picker folder icon at 12px
449
+ if (project.working_dir) {
450
+ const divider = document.createElement("div");
451
+ divider.className = "project-tooltip-divider";
452
+ rows.appendChild(divider);
453
+
454
+ const dirRow = document.createElement("div");
455
+ dirRow.className = "project-tooltip-row project-tooltip-row--clickable";
456
+ dirRow.title = project.working_dir;
457
+
458
+ const iconSpan = document.createElement("span");
459
+ iconSpan.className = "project-tooltip-row-icon";
460
+ iconSpan.innerHTML = _getIconSvg("folder", 12);
461
+ dirRow.appendChild(iconSpan);
462
+
463
+ const textSpan = document.createElement("span");
464
+ textSpan.className = "project-tooltip-row-text";
465
+ textSpan.textContent = project.working_dir;
466
+ dirRow.appendChild(textSpan);
467
+
468
+ // Click: reveal in Finder/Explorer
469
+ dirRow.addEventListener("click", () => {
470
+ fetch("/api/file-action", {
471
+ method: "POST",
472
+ headers: { "Content-Type": "application/json" },
473
+ body: JSON.stringify({ path: project.working_dir, action: "reveal" }),
474
+ }).catch(err => console.error("[Projects] reveal error:", err));
475
+ _clearTooltip();
476
+ });
477
+
478
+ rows.appendChild(dirRow);
479
+ }
480
+
481
+ tip.appendChild(rows);
482
+
483
+ // ── Positioning: right side of the sidebar ───────────────────────
484
+ document.body.appendChild(tip);
485
+ _tooltipEl = tip;
486
+
487
+ // Hide when mouse leaves tooltip — use shared timer so gap crossing works
488
+ tip.addEventListener("mouseenter", () => _cancelHide());
489
+ tip.addEventListener("mouseleave", () => _scheduleHide());
490
+
491
+ const rect = anchorEl.getBoundingClientRect();
492
+ const tipRect = tip.getBoundingClientRect();
493
+ const margin = 8; // gap between sidebar and tooltip card
494
+
495
+ // Position tooltip 8px right of anchor; ::before pseudo fills the physical gap
496
+ let left = rect.right + margin;
497
+ if (left + tipRect.width > window.innerWidth - margin) {
498
+ left = rect.left - tipRect.width - margin;
499
+ }
500
+
501
+ // Vertically align top with anchor row, but keep inside viewport
502
+ let top = rect.top;
503
+ if (top + tipRect.height > window.innerHeight - margin) {
504
+ top = window.innerHeight - tipRect.height - margin;
505
+ }
506
+
507
+ tip.style.left = `${Math.max(margin, left)}px`;
508
+ tip.style.top = `${Math.max(margin, top)}px`;
509
+ }
510
+
511
+ function _clearTooltip() {
512
+ clearTimeout(_tooltipTimer);
513
+ _tooltipTimer = null;
514
+ clearTimeout(_hideTimer);
515
+ _hideTimer = null;
516
+ if (_tooltipEl) {
517
+ _tooltipEl.remove();
518
+ _tooltipEl = null;
519
+ }
520
+ }
521
+
522
+ /** Show a small context menu for project actions (rename / delete). */
523
+ function _showProjectMenu(projectId, anchor) {
524
+ // Remove any existing project menu
525
+ document.querySelectorAll(".project-actions-menu").forEach(m => m.remove());
526
+
527
+ const menu = document.createElement("div");
528
+ menu.className = "project-actions-menu";
529
+
530
+ const addItem = (labelKey, defaultLabel, danger, onClick) => {
531
+ const item = document.createElement("button");
532
+ item.className = "project-actions-menu-item" + (danger ? " danger" : "");
533
+ item.textContent = I18n.t(labelKey, defaultLabel);
534
+ item.addEventListener("click", e => {
535
+ e.stopPropagation();
536
+ menu.remove();
537
+ onClick();
538
+ });
539
+ menu.appendChild(item);
540
+ };
541
+
542
+ addItem("projects.menu.rename", "Rename", false, () => promptRename(projectId));
543
+ addItem("projects.menu.delete", "Delete", true, () => promptDelete(projectId));
544
+
545
+ // Position near anchor
546
+ const rect = anchor.getBoundingClientRect();
547
+ menu.style.position = "fixed";
548
+ menu.style.top = `${rect.bottom + 4}px`;
549
+ menu.style.left = `${rect.left}px`;
550
+ document.body.appendChild(menu);
551
+
552
+ // Close on click outside
553
+ const dismiss = e => {
554
+ if (!menu.contains(e.target)) {
555
+ menu.remove();
556
+ document.removeEventListener("mousedown", dismiss, true);
557
+ }
558
+ };
559
+ document.addEventListener("mousedown", dismiss, true);
560
+ }
561
+
562
+ /** Show the organize menu (toggle view mode + sort mode). */
563
+ function _showOrganizeMenu(anchor) {
564
+ document.querySelectorAll(".project-organize-menu").forEach(m => m.remove());
565
+
566
+ const menu = document.createElement("div");
567
+ menu.className = "project-organize-menu";
568
+
569
+ // ── View mode section ──────────────────────────────────────────────
570
+ const viewSection = document.createElement("div");
571
+ viewSection.className = "project-organize-section";
572
+
573
+ const addViewItem = (labelKey, defaultLabel, mode) => {
574
+ const item = document.createElement("button");
575
+ item.className = "project-organize-item" + (_viewMode === mode ? " active" : "");
576
+ item.textContent = I18n.t(labelKey, defaultLabel);
577
+ item.addEventListener("click", e => {
578
+ e.stopPropagation();
579
+ menu.remove();
580
+ _viewMode = mode;
581
+ renderSection();
582
+ });
583
+ viewSection.appendChild(item);
584
+ };
585
+
586
+ addViewItem("projects.organize.grouped", "By Project", "grouped");
587
+ addViewItem("projects.organize.flat", "In a flat list", "flat");
588
+ menu.appendChild(viewSection);
589
+
590
+ // ── Divider ────────────────────────────────────────────────────────
591
+ const divider = document.createElement("div");
592
+ divider.className = "project-organize-divider";
593
+ menu.appendChild(divider);
594
+
595
+ // ── Sort mode section ──────────────────────────────────────────────
596
+ const sortLabel = document.createElement("div");
597
+ sortLabel.className = "project-organize-section-label";
598
+ sortLabel.textContent = I18n.t("projects.organize.sortLabel", "Sort by");
599
+ menu.appendChild(sortLabel);
600
+
601
+ const sortSection = document.createElement("div");
602
+ sortSection.className = "project-organize-section";
603
+
604
+ const addSortItem = (labelKey, defaultLabel, mode) => {
605
+ const item = document.createElement("button");
606
+ item.className = "project-organize-item" + (_sortMode === mode ? " active" : "");
607
+ item.textContent = I18n.t(labelKey, defaultLabel);
608
+ item.addEventListener("click", e => {
609
+ e.stopPropagation();
610
+ menu.remove();
611
+ _sortMode = mode;
612
+ localStorage.setItem(_LS_SORT_KEY, mode);
613
+ renderSection();
614
+ });
615
+ sortSection.appendChild(item);
616
+ };
617
+
618
+ addSortItem("projects.organize.sortPriority", "Priority", "priority");
619
+ addSortItem("projects.organize.sortRecent", "Last updated", "recent");
620
+ menu.appendChild(sortSection);
621
+
622
+ const rect = anchor.getBoundingClientRect();
623
+ menu.style.position = "fixed";
624
+ menu.style.top = `${rect.bottom + 4}px`;
625
+ menu.style.right = `${window.innerWidth - rect.right}px`;
626
+ document.body.appendChild(menu);
627
+
628
+ const dismiss = e => {
629
+ if (!menu.contains(e.target)) {
630
+ menu.remove();
631
+ document.removeEventListener("mousedown", dismiss, true);
632
+ }
633
+ };
634
+ document.addEventListener("mousedown", dismiss, true);
635
+ }
636
+
637
+ // ── Create project modal ───────────────────────────────────────────────────
638
+
639
+ // ── Create project modal ───────────────────────────────────────────────────
640
+
641
+ /**
642
+ * Lazily inject the picker popup + create-project modal DOM into <body>.
643
+ * Called once before the modal is shown; subsequent calls are no-ops.
644
+ */
645
+ function _ensureModalDOM() {
646
+ if (document.getElementById("project-picker-popup")) return;
647
+ const tpl = document.createElement("div");
648
+ tpl.innerHTML = `
649
+ <div id="project-picker-popup" class="project-picker-popup" style="display:none">
650
+ <!-- Color row -->
651
+ <div class="project-picker-colors" id="project-picker-colors">
652
+ <button class="project-picker-color-option project-picker-color-option--selected project-picker-color-none" data-color="" title="Default"></button>
653
+ <button class="project-picker-color-option" data-color="#ef4444" style="background:#ef4444" title="Red"></button>
654
+ <button class="project-picker-color-option" data-color="#f97316" style="background:#f97316" title="Orange"></button>
655
+ <button class="project-picker-color-option" data-color="#eab308" style="background:#eab308" title="Yellow"></button>
656
+ <button class="project-picker-color-option" data-color="#22c55e" style="background:#22c55e" title="Green"></button>
657
+ <button class="project-picker-color-option" data-color="#3b82f6" style="background:#3b82f6" title="Blue"></button>
658
+ <button class="project-picker-color-option" data-color="#8b5cf6" style="background:#8b5cf6" title="Purple"></button>
659
+ <button class="project-picker-color-option" data-color="#ec4899" style="background:#ec4899" title="Pink"></button>
660
+ </div>
661
+ <!-- Divider -->
662
+ <div class="project-picker-divider"></div>
663
+ <!-- Icon grid -->
664
+ <div class="project-picker-icons" id="project-picker-icons">
665
+ <button class="project-picker-icon-option project-picker-icon-option--selected" data-icon="folder" title="Folder">
666
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
667
+ </button>
668
+ <button class="project-picker-icon-option" data-icon="code" title="Code">
669
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
670
+ </button>
671
+ <button class="project-picker-icon-option" data-icon="book" title="Book">
672
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
673
+ </button>
674
+ <button class="project-picker-icon-option" data-icon="globe" title="Globe">
675
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
676
+ </button>
677
+ <button class="project-picker-icon-option" data-icon="zap" title="Zap">
678
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
679
+ </button>
680
+ <button class="project-picker-icon-option" data-icon="star" title="Star">
681
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
682
+ </button>
683
+ <button class="project-picker-icon-option" data-icon="heart" title="Heart">
684
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
685
+ </button>
686
+ <button class="project-picker-icon-option" data-icon="tool" title="Tool">
687
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
688
+ </button>
689
+ <button class="project-picker-icon-option" data-icon="database" title="Database">
690
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
691
+ </button>
692
+ <button class="project-picker-icon-option" data-icon="image" title="Image">
693
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="16" height="16" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
694
+ </button>
695
+ <button class="project-picker-icon-option" data-icon="music" title="Music">
696
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
697
+ </button>
698
+ <button class="project-picker-icon-option" data-icon="coffee" title="Coffee">
699
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg>
700
+ </button>
701
+ <button class="project-picker-icon-option" data-icon="briefcase" title="Briefcase">
702
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>
703
+ </button>
704
+ <button class="project-picker-icon-option" data-icon="flask" title="Flask">
705
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6"/><path d="M10 3v6l-4 8a1 1 0 0 0 .9 1.5h10.2a1 1 0 0 0 .9-1.5L14 9V3"/></svg>
706
+ </button>
707
+ <button class="project-picker-icon-option" data-icon="chart" title="Chart">
708
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
709
+ </button>
710
+ <button class="project-picker-icon-option" data-icon="rocket" title="Rocket">
711
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg>
712
+ </button>
713
+ <button class="project-picker-icon-option" data-icon="dollar" title="Dollar">
714
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
715
+ </button>
716
+ <button class="project-picker-icon-option" data-icon="cpu" title="CPU">
717
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>
718
+ </button>
719
+ <button class="project-picker-icon-option" data-icon="paint" title="Paint">
720
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r=".5"/><circle cx="17.5" cy="10.5" r=".5"/><circle cx="8.5" cy="7.5" r=".5"/><circle cx="6.5" cy="12.5" r=".5"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg>
721
+ </button>
722
+ <button class="project-picker-icon-option" data-icon="leaf" title="Leaf">
723
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>
724
+ </button>
725
+ <button class="project-picker-icon-option" data-icon="trophy" title="Trophy">
726
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="8 21 8 14 4 9 4 4 20 4 20 9 16 14 16 21"/><line x1="8" y1="21" x2="16" y2="21"/><path d="M4 9H2a1 1 0 0 0-1 1 6 6 0 0 0 6 6"/><path d="M20 9h2a1 1 0 0 1 1 1 6 6 0 0 1-6 6"/></svg>
727
+ </button>
728
+ <button class="project-picker-icon-option" data-icon="smile" title="Smile">
729
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
730
+ </button>
731
+ <button class="project-picker-icon-option" data-icon="map" title="Map">
732
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg>
733
+ </button>
734
+ <button class="project-picker-icon-option" data-icon="flower" title="Flower">
735
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7.5a4.5 4.5 0 1 1 4.5 4.5M12 7.5A4.5 4.5 0 1 0 7.5 12M12 7.5V9m-4.5 3a4.5 4.5 0 1 0 4.5 4.5M7.5 12H9m7.5 0a4.5 4.5 0 1 1-4.5 4.5m4.5-4.5H15m-3 4.5V15"/><circle cx="12" cy="12" r="3"/></svg>
736
+ </button>
737
+ </div>
738
+ <!-- Done button -->
739
+ <div class="project-picker-footer">
740
+ <button id="picker-popup-done" class="project-picker-done-btn" data-i18n="projects.picker.done">Done</button>
741
+ </div>
742
+ </div>
743
+ <div id="create-project-modal-overlay" class="modal-overlay" style="display:none">
744
+ <div class="modal-box sm">
745
+ <div class="modal-header">
746
+ <h3 class="modal-title" data-i18n="projects.create.title">New Project</h3>
747
+ </div>
748
+ <div class="modal-body">
749
+ <div class="create-project-input-row">
750
+ <button id="create-project-icon-trigger" class="project-icon-trigger" type="button" title="Choose icon &amp; color">
751
+ <svg id="create-project-trigger-svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
752
+ </button>
753
+ <input type="text" id="create-project-modal-input" class="modal-input create-project-name-input" autocomplete="off" spellcheck="false" placeholder="" data-i18n-placeholder="projects.create.placeholder">
754
+ </div>
755
+ <div class="create-project-folder-section">
756
+ <div class="create-project-folder-label" data-i18n="projects.create.sourceFolders">Source folders</div>
757
+ <div id="create-project-folder-area" class="create-project-folder-area">
758
+ <div id="create-project-folder-empty" class="create-project-folder-empty">
759
+ <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
760
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
761
+ <line x1="12" y1="11" x2="12" y2="17"/><line x1="9" y1="14" x2="15" y2="14"/>
762
+ </svg>
763
+ <span data-i18n="projects.create.folderHint">Add a working folder</span>
764
+ </div>
765
+ <div id="create-project-folder-card" class="create-project-folder-card" style="display:none">
766
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
767
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
768
+ </svg>
769
+ <span id="create-project-folder-name" class="create-project-folder-name"></span>
770
+ <button id="create-project-folder-remove" class="create-project-folder-remove" type="button" title="Remove">
771
+ <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
772
+ </button>
773
+ </div>
774
+ </div>
775
+ </div>
776
+ </div>
777
+ <div class="modal-footer">
778
+ <button id="create-project-modal-cancel" class="btn-secondary" data-i18n="modal.cancel">Cancel</button>
779
+ <button id="create-project-modal-save" class="btn-primary" data-i18n="projects.create.submit">Create Project</button>
780
+ </div>
781
+ </div>
782
+ </div>`;
783
+ // Append all top-level nodes to body
784
+ while (tpl.firstChild) document.body.appendChild(tpl.firstChild);
785
+ // Apply i18n to newly injected nodes
786
+ if (typeof I18n !== "undefined") I18n.applyAll();
787
+ }
788
+
789
+ /**
790
+ * Show the create-project modal and resolve with { name, color, icon, working_dir }
791
+ * or null if cancelled. Owned here because all DOM nodes belong to project UI.
792
+ */
793
+ function _showCreateProjectModal() {
794
+ _ensureModalDOM();
795
+ return new Promise(resolve => {
796
+ const $ = id => document.getElementById(id);
797
+ const overlay = $("create-project-modal-overlay");
798
+ const input = $("create-project-modal-input");
799
+ const trigger = $("create-project-icon-trigger");
800
+ const popup = $("project-picker-popup");
801
+ const popupColors = $("project-picker-colors");
802
+ const popupIcons = $("project-picker-icons");
803
+ const doneBtn = $("picker-popup-done");
804
+ const folderArea = $("create-project-folder-area");
805
+ const folderEmpty = $("create-project-folder-empty");
806
+ const folderCard = $("create-project-folder-card");
807
+ const folderName = $("create-project-folder-name");
808
+ const folderRemoveBtn = $("create-project-folder-remove");
809
+
810
+ // State
811
+ let selectedColor = ""; // "" = default (accent)
812
+ let selectedIcon = "folder";
813
+ let selectedDir = null; // absolute path chosen by directory picker
814
+ let popupOpen = false;
815
+ let pickerBusy = false; // prevent double-open of directory picker
816
+
817
+ // Icon SVGs lookup (same set as popup, keyed by data-icon)
818
+ const _getIconSvg = (icon) => {
819
+ const btn = popupIcons.querySelector(`[data-icon="${icon}"]`);
820
+ return btn ? btn.innerHTML : popupIcons.querySelector("[data-icon]").innerHTML;
821
+ };
822
+
823
+ // Update trigger button appearance
824
+ const _updateTrigger = () => {
825
+ if (selectedColor) {
826
+ trigger.style.background = selectedColor;
827
+ trigger.style.color = "#fff";
828
+ trigger.style.borderColor = "transparent";
829
+ } else {
830
+ trigger.style.background = "";
831
+ trigger.style.color = "";
832
+ trigger.style.borderColor = "";
833
+ }
834
+ trigger.innerHTML = _getIconSvg(selectedIcon);
835
+ };
836
+
837
+ // Helper: extract the last path segment (folder name) from an absolute path
838
+ const _dirBasename = (p) => p ? p.replace(/\/+$/, "").split("/").pop() || p : "";
839
+
840
+ // Show / hide folder card vs empty state; also auto-fill project name from dir
841
+ const _updateFolderArea = () => {
842
+ if (selectedDir) {
843
+ folderEmpty.style.display = "none";
844
+ folderCard.style.display = "flex";
845
+ folderName.textContent = selectedDir;
846
+ folderName.title = selectedDir;
847
+ folderArea.style.cursor = "default";
848
+ // Auto-fill name if user hasn't typed anything yet
849
+ if (!input.value.trim()) {
850
+ input.value = _dirBasename(selectedDir);
851
+ input.classList.remove("input-error");
852
+ }
853
+ } else {
854
+ folderEmpty.style.display = "";
855
+ folderCard.style.display = "none";
856
+ folderArea.style.cursor = "pointer";
857
+ }
858
+ };
859
+
860
+ // Reset all state
861
+ input.value = "";
862
+ input.classList.remove("input-error");
863
+ selectedColor = "";
864
+ selectedIcon = "folder";
865
+ selectedDir = null;
866
+ _updateTrigger();
867
+ _updateFolderArea();
868
+
869
+ // Reset popup selections
870
+ popupColors.querySelectorAll(".project-picker-color-option").forEach(b => {
871
+ b.classList.toggle("project-picker-color-option--selected", b.dataset.color === "");
872
+ });
873
+ popupIcons.querySelectorAll(".project-picker-icon-option").forEach(b => {
874
+ b.classList.toggle("project-picker-icon-option--selected", b.dataset.icon === "folder");
875
+ });
876
+
877
+ // Show/hide icon/color popup
878
+ const _openPopup = () => {
879
+ popupOpen = true;
880
+ const rect = trigger.getBoundingClientRect();
881
+ popup.style.display = "block";
882
+ requestAnimationFrame(() => {
883
+ const pw = popup.offsetWidth;
884
+ const ph = popup.offsetHeight;
885
+ let left = rect.left;
886
+ let top = rect.bottom + 6;
887
+ if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
888
+ if (top + ph > window.innerHeight - 8) top = rect.top - ph - 6;
889
+ popup.style.left = `${left}px`;
890
+ popup.style.top = `${top}px`;
891
+ });
892
+ };
893
+ const _closePopup = () => {
894
+ popupOpen = false;
895
+ popup.style.display = "none";
896
+ };
897
+
898
+ // Move popup / overlay to top of body to avoid clipping
899
+ if (popup.parentNode !== document.body) document.body.appendChild(popup);
900
+ if (overlay.parentNode !== document.body || overlay.nextSibling) document.body.appendChild(overlay);
901
+
902
+ overlay.style.display = "flex";
903
+ setTimeout(() => { input.focus(); }, 50);
904
+
905
+ // ── Event handlers ──────────────────────────────────────────────
906
+
907
+ const onTriggerClick = (e) => {
908
+ e.stopPropagation();
909
+ if (popupOpen) _closePopup(); else _openPopup();
910
+ };
911
+
912
+ const onPopupColorClick = (e) => {
913
+ const btn = e.target.closest(".project-picker-color-option");
914
+ if (!btn) return;
915
+ selectedColor = btn.dataset.color;
916
+ popupColors.querySelectorAll(".project-picker-color-option").forEach(b =>
917
+ b.classList.toggle("project-picker-color-option--selected", b === btn)
918
+ );
919
+ _updateTrigger();
920
+ };
921
+
922
+ const onPopupIconClick = (e) => {
923
+ const btn = e.target.closest(".project-picker-icon-option");
924
+ if (!btn) return;
925
+ selectedIcon = btn.dataset.icon;
926
+ popupIcons.querySelectorAll(".project-picker-icon-option").forEach(b =>
927
+ b.classList.toggle("project-picker-icon-option--selected", b === btn)
928
+ );
929
+ _updateTrigger();
930
+ };
931
+
932
+ const onDoneClick = (e) => {
933
+ e.stopPropagation();
934
+ _closePopup();
935
+ };
936
+
937
+ const onDocClick = (e) => {
938
+ if (popupOpen && !popup.contains(e.target) && e.target !== trigger) {
939
+ _closePopup();
940
+ }
941
+ };
942
+
943
+ // Folder area click: open directory picker (session-less mode)
944
+ const onFolderAreaClick = async (e) => {
945
+ if (e.target.closest(".create-project-folder-remove")) return;
946
+ if (selectedDir) return;
947
+ if (pickerBusy) return;
948
+ pickerBusy = true;
949
+ try {
950
+ let startDir = null;
951
+ try {
952
+ const r = await fetch("/api/dirs");
953
+ if (r.ok) { const d = await r.json(); startDir = d.default || d.root || null; }
954
+ } catch (_) { /* ignore, picker will fall back to home */ }
955
+ const chosen = await window.openDirectoryPicker(startDir, null);
956
+ if (chosen) {
957
+ selectedDir = chosen;
958
+ _updateFolderArea();
959
+ }
960
+ } finally {
961
+ pickerBusy = false;
962
+ }
963
+ };
964
+
965
+ // Remove button clears the selected folder
966
+ const onFolderRemove = (e) => {
967
+ e.stopPropagation();
968
+ const prevDir = selectedDir;
969
+ selectedDir = null;
970
+ if (input.value === _dirBasename(prevDir)) input.value = "";
971
+ _updateFolderArea();
972
+ };
973
+
974
+ trigger.addEventListener("click", onTriggerClick);
975
+ popupColors.addEventListener("click", onPopupColorClick);
976
+ popupIcons.addEventListener("click", onPopupIconClick);
977
+ doneBtn.addEventListener("click", onDoneClick);
978
+ document.addEventListener("click", onDocClick);
979
+ folderArea.addEventListener("click", onFolderAreaClick);
980
+ folderRemoveBtn.addEventListener("click", onFolderRemove);
981
+
982
+ // ── Cleanup & resolve ───────────────────────────────────────────
983
+ const cleanup = (result) => {
984
+ _closePopup();
985
+ overlay.style.display = "none";
986
+ $("create-project-modal-save").onclick = null;
987
+ $("create-project-modal-cancel").onclick = null;
988
+ overlay.onclick = null;
989
+ input.onkeydown = null;
990
+ input.oninput = null;
991
+ trigger.removeEventListener("click", onTriggerClick);
992
+ popupColors.removeEventListener("click", onPopupColorClick);
993
+ popupIcons.removeEventListener("click", onPopupIconClick);
994
+ doneBtn.removeEventListener("click", onDoneClick);
995
+ document.removeEventListener("click", onDocClick);
996
+ folderArea.removeEventListener("click", onFolderAreaClick);
997
+ folderRemoveBtn.removeEventListener("click", onFolderRemove);
998
+ unbindEnter();
999
+ resolve(result);
1000
+ };
1001
+
1002
+ const saveHandler = () => {
1003
+ const rawName = input.value.trim();
1004
+ const name = rawName || _dirBasename(selectedDir) || I18n.t("projects.defaultName", "New Project");
1005
+ const color = selectedColor || null;
1006
+ const icon = selectedIcon || null;
1007
+ const working_dir = selectedDir || null;
1008
+ cleanup({ name, color, icon, working_dir });
1009
+ };
1010
+
1011
+ input.oninput = () => input.classList.remove("input-error");
1012
+ $("create-project-modal-save").onclick = saveHandler;
1013
+ $("create-project-modal-cancel").onclick = () => cleanup(null);
1014
+
1015
+ const unbindEnter = IME.bindEnter(input, saveHandler);
1016
+ input.onkeydown = (e) => { if (e.key === "Escape") cleanup(null); };
1017
+
1018
+ overlay.onclick = (e) => {
1019
+ if (e.target.id === "create-project-modal-overlay") cleanup(null);
1020
+ };
1021
+ });
1022
+ }
1023
+
1024
+ // ── UI helpers ─────────────────────────────────────────────────────────────
1025
+
1026
+ /**
1027
+ * Prompt for a project name and create it.
1028
+ * Returns the created project, or null if the user cancels.
1029
+ */
1030
+ async function promptCreate() {
1031
+ const result = await _showCreateProjectModal();
1032
+ if (!result || !result.name || !result.name.trim()) return null;
1033
+
1034
+ try {
1035
+ const project = await create({
1036
+ name: result.name.trim(),
1037
+ color: result.color || null,
1038
+ icon: result.icon || null,
1039
+ working_dir: result.working_dir || null,
1040
+ });
1041
+ renderSection();
1042
+ return project;
1043
+ } catch (err) {
1044
+ console.error("[Projects] create error:", err);
1045
+ return null;
1046
+ }
1047
+ }
1048
+
1049
+ /** Prompt for a new name and update an existing project. */
1050
+ async function promptRename(id) {
1051
+ const project = find(id);
1052
+ if (!project) return;
1053
+
1054
+ const name = await Modal.rename(project.name, "projects.modal.name");
1055
+ if (!name || name === project.name) return;
1056
+
1057
+ try {
1058
+ await update(id, { name: name.trim() });
1059
+ renderSection();
1060
+ } catch (err) {
1061
+ console.error("[Projects] rename error:", err);
1062
+ }
1063
+ }
1064
+
1065
+ /** Confirm and delete a project. */
1066
+ async function promptDelete(id) {
1067
+ const project = find(id);
1068
+ if (!project) return;
1069
+
1070
+ const confirmed = await Modal.confirm(
1071
+ I18n.t("projects.delete.confirm", { name: project.name })
1072
+ );
1073
+ if (!confirmed) return;
1074
+
1075
+ try {
1076
+ await remove(id);
1077
+ // remove() already calls Sessions.renderList() + renderSection()
1078
+ } catch (err) {
1079
+ console.error("[Projects] delete error:", err);
1080
+ }
1081
+ }
1082
+
1083
+ // ── Init ───────────────────────────────────────────────────────────────────
1084
+
1085
+ function init() {
1086
+ const btnNew = document.getElementById("btn-new-project");
1087
+ const btnOrganize = document.getElementById("btn-projects-organize");
1088
+ const sectionHeader = document.getElementById("projects-section-header");
1089
+ const projectList = document.getElementById("project-list");
1090
+
1091
+ // Section-level collapse (click on title / chevron area, not on action buttons)
1092
+ if (sectionHeader && projectList) {
1093
+ // Apply persisted state on init
1094
+ if (_sectionCollapsed) {
1095
+ projectList.classList.add("project-list-collapsed");
1096
+ sectionHeader.classList.add("projects-section-collapsed");
1097
+ }
1098
+ sectionHeader.addEventListener("click", e => {
1099
+ // Don't collapse when clicking the action buttons
1100
+ if (e.target.closest("#btn-new-project") || e.target.closest("#btn-projects-organize")) return;
1101
+ _sectionCollapsed = !_sectionCollapsed;
1102
+ localStorage.setItem(_LS_SECTION_COLLAPSE_KEY, _sectionCollapsed ? "1" : "0");
1103
+ projectList.classList.toggle("project-list-collapsed", _sectionCollapsed);
1104
+ sectionHeader.classList.toggle("projects-section-collapsed", _sectionCollapsed);
1105
+ });
1106
+ }
1107
+
1108
+ if (btnNew) {
1109
+ btnNew.addEventListener("click", () => promptCreate());
1110
+ }
1111
+
1112
+ if (btnOrganize) {
1113
+ btnOrganize.addEventListener("click", e => {
1114
+ e.stopPropagation();
1115
+ _showOrganizeMenu(btnOrganize);
1116
+ });
1117
+ }
1118
+ }
1119
+
1120
+ // ── Public API ─────────────────────────────────────────────────────────────
1121
+
1122
+ return {
1123
+ all,
1124
+ find,
1125
+ setAll,
1126
+ create,
1127
+ update,
1128
+ remove,
1129
+ moveSession,
1130
+ promptCreate,
1131
+ promptRename,
1132
+ promptDelete,
1133
+ renderSection,
1134
+ init,
1135
+ };
1136
+ })();
1137
+
1138
+ // Auto-init when DOM is ready
1139
+ if (document.readyState === "loading") {
1140
+ document.addEventListener("DOMContentLoaded", () => Projects.init());
1141
+ } else {
1142
+ Projects.init();
1143
+ }