repoburg 1.3.186 → 1.3.195

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 (262) hide show
  1. package/agent-pane/.prettierignore +5 -0
  2. package/agent-pane/.prettierrc +8 -0
  3. package/agent-pane/backend/coverage/base.css +224 -0
  4. package/agent-pane/backend/coverage/block-navigation.js +87 -0
  5. package/agent-pane/backend/coverage/clover.xml +887 -0
  6. package/agent-pane/backend/coverage/coverage-final.json +10 -0
  7. package/agent-pane/backend/coverage/favicon.png +0 -0
  8. package/agent-pane/backend/coverage/index.html +176 -0
  9. package/agent-pane/backend/coverage/prettify.css +1 -0
  10. package/agent-pane/backend/coverage/prettify.js +2 -0
  11. package/agent-pane/backend/coverage/sort-arrow-sprite.png +0 -0
  12. package/agent-pane/backend/coverage/sorter.js +210 -0
  13. package/agent-pane/backend/coverage/src/codex/codex-provider.ts.html +1402 -0
  14. package/agent-pane/backend/coverage/src/codex/index.html +131 -0
  15. package/agent-pane/backend/coverage/src/codex/item-mapper.ts.html +331 -0
  16. package/agent-pane/backend/coverage/src/db/index.html +131 -0
  17. package/agent-pane/backend/coverage/src/db/index.ts.html +436 -0
  18. package/agent-pane/backend/coverage/src/db/schema.ts.html +175 -0
  19. package/agent-pane/backend/coverage/src/index.html +116 -0
  20. package/agent-pane/backend/coverage/src/index.ts.html +262 -0
  21. package/agent-pane/backend/coverage/src/routes/index.html +146 -0
  22. package/agent-pane/backend/coverage/src/routes/sessions.ts.html +991 -0
  23. package/agent-pane/backend/coverage/src/routes/turns.ts.html +256 -0
  24. package/agent-pane/backend/coverage/src/routes/workspaces.ts.html +406 -0
  25. package/agent-pane/backend/coverage/src/ws/index.html +116 -0
  26. package/agent-pane/backend/coverage/src/ws/index.ts.html +238 -0
  27. package/agent-pane/backend/data.db +0 -0
  28. package/agent-pane/backend/data.db-shm +0 -0
  29. package/agent-pane/backend/data.db-wal +0 -0
  30. package/agent-pane/backend/dist/index.js +70869 -0
  31. package/agent-pane/backend/package-lock.json +4529 -1013
  32. package/agent-pane/backend/package.json +10 -6
  33. package/agent-pane/backend/src/claude/claude-message-mapper.test.ts +163 -0
  34. package/agent-pane/backend/src/claude/claude-message-mapper.ts +154 -0
  35. package/agent-pane/backend/src/claude/claude-provider.test.ts +65 -0
  36. package/agent-pane/backend/src/claude/claude-provider.ts +482 -0
  37. package/agent-pane/backend/src/claude/claude-types.ts +33 -0
  38. package/agent-pane/backend/src/codex/app-server-client.test.ts +183 -0
  39. package/agent-pane/backend/src/codex/app-server-client.ts +225 -0
  40. package/agent-pane/backend/src/codex/app-server-types.ts +259 -0
  41. package/agent-pane/backend/src/codex/codex-provider-runtime-config.test.ts +56 -0
  42. package/agent-pane/backend/src/codex/codex-provider.test.ts +60 -0
  43. package/agent-pane/backend/src/codex/codex-provider.ts +556 -197
  44. package/agent-pane/backend/src/codex/item-mapper.test.ts +221 -0
  45. package/agent-pane/backend/src/codex/item-mapper.ts +35 -8
  46. package/agent-pane/backend/src/codex/shared-app-server.ts +25 -0
  47. package/agent-pane/backend/src/db/index.test.ts +125 -0
  48. package/agent-pane/backend/src/db/index.ts +61 -6
  49. package/agent-pane/backend/src/db/schema.ts +28 -63
  50. package/agent-pane/backend/src/index.ts +31 -2
  51. package/agent-pane/backend/src/integration/__fixtures__/cancel-turn.json +29 -0
  52. package/agent-pane/backend/src/integration/__fixtures__/delta-streaming.json +40 -0
  53. package/agent-pane/backend/src/integration/__fixtures__/failed-turn.json +16 -0
  54. package/agent-pane/backend/src/integration/__fixtures__/mcp-tool-call.json +60 -0
  55. package/agent-pane/backend/src/integration/__fixtures__/missing-item-started.json +34 -0
  56. package/agent-pane/backend/src/integration/__fixtures__/multi-item-turn.json +85 -0
  57. package/agent-pane/backend/src/integration/__fixtures__/multi-turn.json +48 -0
  58. package/agent-pane/backend/src/integration/__fixtures__/simple-turn.json +35 -0
  59. package/agent-pane/backend/src/integration/delta-streaming.test.ts +130 -0
  60. package/agent-pane/backend/src/integration/helpers/app-server-mock.ts +272 -0
  61. package/agent-pane/backend/src/integration/helpers/test-app.ts +108 -0
  62. package/agent-pane/backend/src/integration/helpers/ws-capture.ts +64 -0
  63. package/agent-pane/backend/src/integration/item-lifecycle.test.ts +133 -0
  64. package/agent-pane/backend/src/integration/mcp-tool-call.test.ts +102 -0
  65. package/agent-pane/backend/src/integration/missing-item-started.test.ts +91 -0
  66. package/agent-pane/backend/src/integration/multi-turn.test.ts +121 -0
  67. package/agent-pane/backend/src/integration/session-operations.test.ts +168 -0
  68. package/agent-pane/backend/src/integration/title-generation.test.ts +109 -0
  69. package/agent-pane/backend/src/integration/turn-cancel.test.ts +111 -0
  70. package/agent-pane/backend/src/integration/turn-failure.test.ts +97 -0
  71. package/agent-pane/backend/src/integration/turn-lifecycle.test.ts +141 -0
  72. package/agent-pane/backend/src/providers/index.ts +14 -0
  73. package/agent-pane/backend/src/providers/registry.test.ts +92 -0
  74. package/agent-pane/backend/src/providers/registry.ts +43 -0
  75. package/agent-pane/backend/src/providers/runtime-provider.ts +25 -0
  76. package/agent-pane/backend/src/routes/files.ts +31 -0
  77. package/agent-pane/backend/src/routes/mcp.ts +33 -0
  78. package/agent-pane/backend/src/routes/models.test.ts +92 -0
  79. package/agent-pane/backend/src/routes/models.ts +16 -0
  80. package/agent-pane/backend/src/routes/plugins.ts +40 -0
  81. package/agent-pane/backend/src/routes/providers.test.ts +69 -0
  82. package/agent-pane/backend/src/routes/providers.ts +57 -0
  83. package/agent-pane/backend/src/routes/sessions.test.ts +424 -0
  84. package/agent-pane/backend/src/routes/sessions.ts +311 -21
  85. package/agent-pane/backend/src/routes/skills.test.ts +114 -0
  86. package/agent-pane/backend/src/routes/skills.ts +70 -0
  87. package/agent-pane/backend/src/routes/turns.test.ts +152 -0
  88. package/agent-pane/backend/src/routes/turns.ts +36 -16
  89. package/agent-pane/backend/src/routes/workspaces.test.ts +197 -0
  90. package/agent-pane/backend/src/routes/workspaces.ts +128 -0
  91. package/agent-pane/backend/src/ws/index.test.ts +107 -0
  92. package/agent-pane/backend/src/ws/index.ts +19 -11
  93. package/agent-pane/backend/tsconfig.json +4 -2
  94. package/agent-pane/backend/vitest.config.ts +20 -0
  95. package/agent-pane/eslint.config.js +33 -0
  96. package/agent-pane/frontend/coverage/base.css +224 -0
  97. package/agent-pane/frontend/coverage/block-navigation.js +87 -0
  98. package/agent-pane/frontend/coverage/clover.xml +2962 -0
  99. package/agent-pane/frontend/coverage/coverage-final.json +37 -0
  100. package/agent-pane/frontend/coverage/favicon.png +0 -0
  101. package/agent-pane/frontend/coverage/index.html +251 -0
  102. package/agent-pane/frontend/coverage/prettify.css +1 -0
  103. package/agent-pane/frontend/coverage/prettify.js +2 -0
  104. package/agent-pane/frontend/coverage/sort-arrow-sprite.png +0 -0
  105. package/agent-pane/frontend/coverage/sorter.js +210 -0
  106. package/agent-pane/frontend/coverage/src/App.tsx.html +217 -0
  107. package/agent-pane/frontend/coverage/src/AppInitializer.tsx.html +223 -0
  108. package/agent-pane/frontend/coverage/src/api/client.ts.html +187 -0
  109. package/agent-pane/frontend/coverage/src/api/index.html +161 -0
  110. package/agent-pane/frontend/coverage/src/api/sessions.ts.html +316 -0
  111. package/agent-pane/frontend/coverage/src/api/websocket.ts.html +544 -0
  112. package/agent-pane/frontend/coverage/src/api/workspaces.ts.html +199 -0
  113. package/agent-pane/frontend/coverage/src/components/CollapsibleToolCalls.tsx.html +226 -0
  114. package/agent-pane/frontend/coverage/src/components/CommandInputBar.tsx.html +382 -0
  115. package/agent-pane/frontend/coverage/src/components/CommandPalette.tsx.html +715 -0
  116. package/agent-pane/frontend/coverage/src/components/IdeWorkspace.tsx.html +160 -0
  117. package/agent-pane/frontend/coverage/src/components/ModelSelector.tsx.html +478 -0
  118. package/agent-pane/frontend/coverage/src/components/NewSessionDialog.tsx.html +472 -0
  119. package/agent-pane/frontend/coverage/src/components/RenderArea.tsx.html +574 -0
  120. package/agent-pane/frontend/coverage/src/components/RightPanel.tsx.html +205 -0
  121. package/agent-pane/frontend/coverage/src/components/RuntimeIcon.tsx.html +133 -0
  122. package/agent-pane/frontend/coverage/src/components/SessionTabs.tsx.html +217 -0
  123. package/agent-pane/frontend/coverage/src/components/StatusBar.tsx.html +271 -0
  124. package/agent-pane/frontend/coverage/src/components/StatusDot.tsx.html +109 -0
  125. package/agent-pane/frontend/coverage/src/components/Toast.tsx.html +313 -0
  126. package/agent-pane/frontend/coverage/src/components/TurnContent.tsx.html +1216 -0
  127. package/agent-pane/frontend/coverage/src/components/WorkspaceSidebar.tsx.html +1138 -0
  128. package/agent-pane/frontend/coverage/src/components/index.html +326 -0
  129. package/agent-pane/frontend/coverage/src/components/panels/ChangesPanel.tsx.html +247 -0
  130. package/agent-pane/frontend/coverage/src/components/panels/LogsPanel.tsx.html +160 -0
  131. package/agent-pane/frontend/coverage/src/components/panels/MetricsPanel.tsx.html +427 -0
  132. package/agent-pane/frontend/coverage/src/components/panels/TurnsPanel.tsx.html +397 -0
  133. package/agent-pane/frontend/coverage/src/components/panels/index.html +161 -0
  134. package/agent-pane/frontend/coverage/src/constants/index.html +116 -0
  135. package/agent-pane/frontend/coverage/src/constants/models.ts.html +406 -0
  136. package/agent-pane/frontend/coverage/src/hooks/index.html +146 -0
  137. package/agent-pane/frontend/coverage/src/hooks/useEscapeKey.ts.html +124 -0
  138. package/agent-pane/frontend/coverage/src/hooks/useOutsideClick.ts.html +142 -0
  139. package/agent-pane/frontend/coverage/src/hooks/useSessionStatus.ts.html +187 -0
  140. package/agent-pane/frontend/coverage/src/index.html +146 -0
  141. package/agent-pane/frontend/coverage/src/main.tsx.html +115 -0
  142. package/agent-pane/frontend/coverage/src/store/index.html +116 -0
  143. package/agent-pane/frontend/coverage/src/store/store.ts.html +2404 -0
  144. package/agent-pane/frontend/coverage/src/test/component-helpers.ts.html +190 -0
  145. package/agent-pane/frontend/coverage/src/test/fixtures.ts.html +367 -0
  146. package/agent-pane/frontend/coverage/src/test/index.html +131 -0
  147. package/agent-pane/frontend/coverage/src/types/index.html +116 -0
  148. package/agent-pane/frontend/coverage/src/types/index.ts.html +466 -0
  149. package/agent-pane/frontend/coverage/src/utils/changes.ts.html +202 -0
  150. package/agent-pane/frontend/coverage/src/utils/duration.ts.html +127 -0
  151. package/agent-pane/frontend/coverage/src/utils/index.html +131 -0
  152. package/agent-pane/frontend/dist/assets/index-Bg0PF2aV.css +1 -0
  153. package/agent-pane/frontend/dist/assets/index-BlybeX5e.js +281 -0
  154. package/agent-pane/frontend/dist/index.html +19 -0
  155. package/agent-pane/frontend/package-lock.json +2434 -213
  156. package/agent-pane/frontend/package.json +10 -3
  157. package/agent-pane/frontend/src/App.test.tsx +74 -0
  158. package/agent-pane/frontend/src/App.tsx +1 -1
  159. package/agent-pane/frontend/src/AppInitializer.test.tsx +142 -0
  160. package/agent-pane/frontend/src/AppInitializer.tsx +1 -1
  161. package/agent-pane/frontend/src/api/client.test.ts +126 -0
  162. package/agent-pane/frontend/src/api/client.ts +24 -19
  163. package/agent-pane/frontend/src/api/models.ts +7 -0
  164. package/agent-pane/frontend/src/api/providers.ts +12 -0
  165. package/agent-pane/frontend/src/api/sessions.test.ts +241 -0
  166. package/agent-pane/frontend/src/api/sessions.ts +60 -2
  167. package/agent-pane/frontend/src/api/skills.ts +12 -0
  168. package/agent-pane/frontend/src/api/websocket.test.ts +288 -0
  169. package/agent-pane/frontend/src/api/websocket.ts +37 -2
  170. package/agent-pane/frontend/src/api/workspaces.test.ts +112 -0
  171. package/agent-pane/frontend/src/api/workspaces.ts +38 -0
  172. package/agent-pane/frontend/src/components/CollapsibleToolCalls.test.tsx +49 -0
  173. package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +47 -0
  174. package/agent-pane/frontend/src/components/CommandInputBar.test.tsx +133 -0
  175. package/agent-pane/frontend/src/components/CommandInputBar.tsx +296 -17
  176. package/agent-pane/frontend/src/components/CommandPalette.test.tsx +230 -0
  177. package/agent-pane/frontend/src/components/CommandPalette.tsx +36 -22
  178. package/agent-pane/frontend/src/components/IdeWorkspace.tsx +1 -1
  179. package/agent-pane/frontend/src/components/ModelSelector.test.tsx +129 -0
  180. package/agent-pane/frontend/src/components/ModelSelector.tsx +191 -0
  181. package/agent-pane/frontend/src/components/NewSessionDialog.test.tsx +95 -0
  182. package/agent-pane/frontend/src/components/NewSessionDialog.tsx +58 -13
  183. package/agent-pane/frontend/src/components/RenderArea.test.tsx +160 -0
  184. package/agent-pane/frontend/src/components/RenderArea.tsx +76 -70
  185. package/agent-pane/frontend/src/components/RightPanel.test.tsx +43 -0
  186. package/agent-pane/frontend/src/components/RightPanel.tsx +1 -1
  187. package/agent-pane/frontend/src/components/RuntimeIcon.test.tsx +32 -0
  188. package/agent-pane/frontend/src/components/RuntimeIcon.tsx +1 -1
  189. package/agent-pane/frontend/src/components/SessionTabs.test.tsx +72 -0
  190. package/agent-pane/frontend/src/components/SessionTabs.tsx +13 -10
  191. package/agent-pane/frontend/src/components/StatusBar.test.tsx +56 -0
  192. package/agent-pane/frontend/src/components/StatusBar.tsx +26 -24
  193. package/agent-pane/frontend/src/components/StatusDot.test.tsx +25 -0
  194. package/agent-pane/frontend/src/components/StatusDot.tsx +2 -7
  195. package/agent-pane/frontend/src/components/Toast.test.tsx +79 -0
  196. package/agent-pane/frontend/src/components/Toast.tsx +57 -15
  197. package/agent-pane/frontend/src/components/TurnContent.test.tsx +287 -0
  198. package/agent-pane/frontend/src/components/TurnContent.tsx +201 -26
  199. package/agent-pane/frontend/src/components/WorkspaceSidebar.test.tsx +297 -0
  200. package/agent-pane/frontend/src/components/WorkspaceSidebar.tsx +327 -36
  201. package/agent-pane/frontend/src/components/input/OptionSubPopup.tsx +32 -0
  202. package/agent-pane/frontend/src/components/input/SkillMentionPopup.test.tsx +63 -0
  203. package/agent-pane/frontend/src/components/input/SkillMentionPopup.tsx +78 -0
  204. package/agent-pane/frontend/src/components/input/SlashPopup.test.tsx +96 -0
  205. package/agent-pane/frontend/src/components/input/SlashPopup.tsx +63 -0
  206. package/agent-pane/frontend/src/components/panels/ChangesPanel.test.tsx +61 -0
  207. package/agent-pane/frontend/src/components/panels/ChangesPanel.tsx +12 -35
  208. package/agent-pane/frontend/src/components/panels/LogsPanel.test.tsx +58 -0
  209. package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +1 -1
  210. package/agent-pane/frontend/src/components/panels/MetricsPanel.test.tsx +71 -0
  211. package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +64 -19
  212. package/agent-pane/frontend/src/components/panels/TurnsPanel.test.tsx +83 -0
  213. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +79 -59
  214. package/agent-pane/frontend/src/constants/models.test.ts +97 -0
  215. package/agent-pane/frontend/src/constants/models.ts +129 -0
  216. package/agent-pane/frontend/src/hooks/useEscapeKey.test.ts +42 -0
  217. package/agent-pane/frontend/src/hooks/useEscapeKey.ts +13 -0
  218. package/agent-pane/frontend/src/hooks/useOutsideClick.test.ts +92 -0
  219. package/agent-pane/frontend/src/hooks/useOutsideClick.ts +19 -0
  220. package/agent-pane/frontend/src/hooks/useProvider.ts +44 -0
  221. package/agent-pane/frontend/src/hooks/useSessionStatus.test.ts +114 -0
  222. package/agent-pane/frontend/src/hooks/useSessionStatus.ts +34 -0
  223. package/agent-pane/frontend/src/main.test.tsx +37 -0
  224. package/agent-pane/frontend/src/main.tsx +1 -1
  225. package/agent-pane/frontend/src/store/sessionSlice.ts +417 -0
  226. package/agent-pane/frontend/src/store/store.test.ts +1284 -0
  227. package/agent-pane/frontend/src/store/store.ts +18 -476
  228. package/agent-pane/frontend/src/store/uiSlice.ts +83 -0
  229. package/agent-pane/frontend/src/store/workspaceSlice.ts +77 -0
  230. package/agent-pane/frontend/src/store/wsSlice.ts +292 -0
  231. package/agent-pane/frontend/src/styles/app.css +191 -8
  232. package/agent-pane/frontend/src/test/component-helpers.ts +36 -0
  233. package/agent-pane/frontend/src/test/fixtures.ts +96 -0
  234. package/agent-pane/frontend/src/test-setup.ts +5 -0
  235. package/agent-pane/frontend/src/types/index.test.ts +128 -0
  236. package/agent-pane/frontend/src/types/index.ts +100 -86
  237. package/agent-pane/frontend/src/utils/changes.test.ts +81 -0
  238. package/agent-pane/frontend/src/utils/changes.ts +39 -0
  239. package/agent-pane/frontend/src/utils/duration.test.ts +71 -0
  240. package/agent-pane/frontend/src/utils/duration.ts +14 -0
  241. package/agent-pane/frontend/tsconfig.json +4 -1
  242. package/agent-pane/frontend/vite.config.ts +6 -0
  243. package/agent-pane/frontend/vitest.config.ts +29 -0
  244. package/agent-pane/package-lock.json +1527 -0
  245. package/agent-pane/package.json +19 -0
  246. package/agent-pane/shared/provider-types.ts +53 -0
  247. package/agent-pane/shared/types.ts +107 -0
  248. package/agentpane-branch-session.png +0 -0
  249. package/agentpane-expanded-all.png +0 -0
  250. package/agentpane-fixed-rendering.png +0 -0
  251. package/agentpane-focus-check.png +0 -0
  252. package/agentpane-initial.png +0 -0
  253. package/agentpane-mcp-detail.png +0 -0
  254. package/agentpane-no-focus-ring.png +0 -0
  255. package/agentpane-soft-focus.png +0 -0
  256. package/data.db +0 -0
  257. package/empty-state.png +0 -0
  258. package/frontend-home.png +0 -0
  259. package/model-selector-open.png +0 -0
  260. package/package.json +2 -2
  261. package/slash-popup-working.png +0 -0
  262. package/agent-pane/frontend/src/components/DiffViewer.tsx +0 -27
@@ -1,60 +1,351 @@
1
+ import { useState, useRef, useEffect } from 'react';
1
2
  import { useStore } from '../store/store.ts';
2
- import { statusToDot } from '../types/index.ts';
3
+ import { deriveSessionStatus } from '../hooks/useSessionStatus.ts';
4
+ import { useOutsideClick } from '../hooks/useOutsideClick.ts';
5
+ import { useEscapeKey } from '../hooks/useEscapeKey.ts';
6
+ import type { Session, Workspace } from '../types/index.ts';
3
7
  import StatusDot from './StatusDot.tsx';
4
- import { Plus, Trash2 } from 'lucide-react';
8
+ import RuntimeIcon from './RuntimeIcon.tsx';
9
+ import { Plus, ChevronDown, Folder, FolderPlus, Trash2, Edit3, CheckCircle2, RotateCcw } from 'lucide-react';
10
+
11
+ interface ContextMenuState {
12
+ x: number;
13
+ y: number;
14
+ type: 'workspace' | 'session';
15
+ path?: string;
16
+ sessionId?: string;
17
+ sessionTitle?: string | null;
18
+ sessionLifecycle?: string;
19
+ }
20
+
21
+ interface WorkspaceGroup {
22
+ path: string;
23
+ label: string;
24
+ sessions: Session[];
25
+ workspace: Workspace | null;
26
+ }
27
+
28
+ /** Build workspace groups from active workspaces + session cwd values.
29
+ * Deleted workspace paths are excluded from implicit groups. */
30
+ function buildGroups(workspaces: Workspace[], sessions: Session[]): WorkspaceGroup[] {
31
+ const sessionByCwd = new Map<string, Session[]>();
32
+
33
+ for (const s of sessions) {
34
+ const key = s.cwd || '(no directory)';
35
+ const list = sessionByCwd.get(key) ?? [];
36
+ list.push(s);
37
+ sessionByCwd.set(key, list);
38
+ }
39
+
40
+ const seenPaths = new Set<string>();
41
+ const deletedPaths = new Set(workspaces.filter((w) => w.deleted === 1).map((w) => w.path));
42
+ const groups: WorkspaceGroup[] = [];
43
+
44
+ // Active explicit workspaces first (deleted = 0)
45
+ for (const ws of workspaces) {
46
+ if (ws.deleted === 1) {
47
+ seenPaths.add(ws.path);
48
+ continue;
49
+ }
50
+ seenPaths.add(ws.path);
51
+ groups.push({
52
+ path: ws.path,
53
+ label: ws.label || ws.path.split('/').pop() || ws.path,
54
+ sessions: sessionByCwd.get(ws.path) ?? [],
55
+ workspace: ws,
56
+ });
57
+ }
58
+
59
+ // Implicit groups — session cwd values not in any workspace (active or deleted)
60
+ for (const [path, sess] of sessionByCwd) {
61
+ if (seenPaths.has(path) || deletedPaths.has(path)) continue;
62
+ groups.push({
63
+ path,
64
+ label: path === '(no directory)' ? 'Other' : path.split('/').pop() || path,
65
+ sessions: sess,
66
+ workspace: null,
67
+ });
68
+ }
69
+
70
+ return groups;
71
+ }
5
72
 
6
73
  export default function WorkspaceSidebar() {
7
- const { sessions, activeSessionId, selectSession, setShowNewSessionDialog, deleteSession } = useStore();
74
+ const {
75
+ sessions,
76
+ workspaces,
77
+ activeSessionId,
78
+ selectSession,
79
+ deleteSession,
80
+ removeWorkspace,
81
+ createSessionInWorkspace,
82
+ pickAndAddWorkspace,
83
+ renameSession,
84
+ markFinished,
85
+ reopenSession,
86
+ } = useStore();
87
+ const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
88
+ const [subCollapsed, setSubCollapsed] = useState<Set<string>>(new Set());
89
+ const [ctxMenu, setCtxMenu] = useState<ContextMenuState | null>(null);
90
+ const [renamingId, setRenamingId] = useState<string | null>(null);
91
+ const [renameValue, setRenameValue] = useState('');
92
+ const ctxRef = useRef<HTMLDivElement>(null);
93
+ const renameRef = useRef<HTMLInputElement>(null);
94
+
95
+ useOutsideClick(ctxRef, () => setCtxMenu(null), !!ctxMenu);
96
+ useEscapeKey(() => setCtxMenu(null), !!ctxMenu);
97
+
98
+ function toggleSection(path: string) {
99
+ setCollapsed((prev) => {
100
+ const next = new Set(prev);
101
+ if (next.has(path)) next.delete(path);
102
+ else next.add(path);
103
+ return next;
104
+ });
105
+ }
106
+
107
+ function toggleSubGroup(key: string) {
108
+ setSubCollapsed((prev) => {
109
+ const next = new Set(prev);
110
+ if (next.has(key)) next.delete(key);
111
+ else next.add(key);
112
+ return next;
113
+ });
114
+ }
115
+
116
+ // Focus rename input when it appears
117
+ useEffect(() => {
118
+ if (renamingId && renameRef.current) {
119
+ renameRef.current.focus();
120
+ renameRef.current.select();
121
+ }
122
+ }, [renamingId]);
123
+
124
+ function startRename(id: string, currentTitle: string) {
125
+ setRenamingId(id);
126
+ setRenameValue(currentTitle || id.slice(0, 8));
127
+ }
128
+
129
+ function confirmRename() {
130
+ if (renamingId) {
131
+ renameSession(renamingId, renameValue);
132
+ }
133
+ setRenamingId(null);
134
+ }
135
+
136
+ function cancelRename() {
137
+ setRenamingId(null);
138
+ setRenameValue('');
139
+ }
140
+
141
+ const groups = buildGroups(workspaces, sessions);
142
+
143
+ function renderSession(s: Session) {
144
+ const isActive = activeSessionId === s.id;
145
+ const status = deriveSessionStatus(s);
146
+ return (
147
+ <div
148
+ key={s.id}
149
+ className={`tree-item ${isActive ? 'active' : ''} ${status.isFinished ? 'tree-item-finished' : ''}`}
150
+ onClick={() => selectSession(s.id)}
151
+ onContextMenu={(e) => {
152
+ e.preventDefault();
153
+ setCtxMenu({
154
+ x: e.clientX,
155
+ y: e.clientY,
156
+ type: 'session',
157
+ sessionId: s.id,
158
+ sessionTitle: s.title,
159
+ sessionLifecycle: s.lifecycle,
160
+ });
161
+ }}
162
+ >
163
+ <RuntimeIcon iconClass={status.iconClass} iconText={status.iconText} size={16} />
164
+ {renamingId === s.id ? (
165
+ <input
166
+ ref={renameRef}
167
+ className="tree-rename-input"
168
+ value={renameValue}
169
+ onChange={(e) => setRenameValue(e.target.value)}
170
+ onClick={(e) => e.stopPropagation()}
171
+ onKeyDown={(e) => {
172
+ if (e.key === 'Enter') confirmRename();
173
+ if (e.key === 'Escape') cancelRename();
174
+ }}
175
+ onBlur={confirmRename}
176
+ />
177
+ ) : (
178
+ <span className="tree-label">{s.title || s.id.slice(0, 8)}</span>
179
+ )}
180
+ {status.dot && <StatusDot status={status.dot} />}
181
+ </div>
182
+ );
183
+ }
8
184
 
9
185
  return (
10
186
  <div className="ide-sidebar">
11
187
  <div className="sidebar-header">
12
- <span className="sidebar-header-title">Sessions</span>
13
- <button className="sidebar-add" title="New Session" onClick={() => setShowNewSessionDialog(true)}>
188
+ <span className="sidebar-header-title">Workspaces</span>
189
+ <button className="sidebar-add" title="Add workspace" onClick={() => pickAndAddWorkspace()}>
14
190
  <Plus size={14} />
15
191
  </button>
16
192
  </div>
17
193
 
18
- {sessions.length === 0 ? (
194
+ {groups.length === 0 ? (
19
195
  <div className="empty-state" style={{ padding: '16px' }}>
20
- No sessions yet. Click + to create one.
196
+ No workspaces yet. Click + to add one.
21
197
  </div>
22
198
  ) : (
23
- <div className="tree-children">
24
- {sessions.map((s) => {
25
- const isActive = activeSessionId === s.id;
26
- const dot = statusToDot(s.status, s.is_running === 1);
27
- return (
199
+ groups.map((group) => {
200
+ const isCollapsed = collapsed.has(group.path);
201
+ const workingSessions = group.sessions.filter((s) => s.lifecycle !== 'finished');
202
+ const finishedSessions = group.sessions.filter((s) => s.lifecycle === 'finished');
203
+ const finishedKey = `${group.path}:finished`;
204
+ const finishedCollapsed = subCollapsed.has(finishedKey);
205
+
206
+ return (
207
+ <div key={group.path} className={`tree-section ${isCollapsed ? 'collapsed' : ''}`}>
28
208
  <div
29
- key={s.id}
30
- className={`tree-item ${isActive ? 'active' : ''}`}
31
- onClick={() => selectSession(s.id)}
209
+ className="tree-section-header"
210
+ onClick={() => toggleSection(group.path)}
211
+ onContextMenu={(e) => {
212
+ e.preventDefault();
213
+ setCtxMenu({ x: e.clientX, y: e.clientY, type: 'workspace', path: group.path });
214
+ }}
32
215
  >
33
- <span className="tree-label">{s.title || s.id.slice(0, 8)}</span>
34
- <StatusDot status={dot} />
35
- {isActive && (
36
- <span
37
- className="ide-tab-close"
38
- title="Delete session"
39
- onClick={(e) => {
40
- e.stopPropagation();
41
- deleteSession(s.id);
42
- }}
43
- >
44
- <Trash2 size={11} />
45
- </span>
216
+ <ChevronDown size={9} className="tree-chevron" />
217
+ <Folder size={14} />
218
+ <span className="tree-section-label">{group.label}</span>
219
+ <button
220
+ className="tree-section-add"
221
+ title="New session in this workspace"
222
+ onClick={(e) => {
223
+ e.stopPropagation();
224
+ createSessionInWorkspace(group.path);
225
+ }}
226
+ >
227
+ <Plus size={11} />
228
+ </button>
229
+ <span className="tree-count">{group.sessions.length}</span>
230
+ </div>
231
+ <div className="tree-children">
232
+ {/* Working sessions — rendered directly, no sub-group header */}
233
+ {workingSessions.length === 0 && finishedSessions.length === 0 ? (
234
+ <div className="tree-empty">No sessions</div>
235
+ ) : (
236
+ workingSessions.map(renderSession)
237
+ )}
238
+
239
+ {/* Finished sub-group — only show if there are finished sessions */}
240
+ {finishedSessions.length > 0 && (
241
+ <div className={`tree-subgroup ${finishedCollapsed ? 'collapsed' : ''}`}>
242
+ <div className="tree-subgroup-header" onClick={() => toggleSubGroup(finishedKey)}>
243
+ <ChevronDown size={8} className="tree-chevron" />
244
+ <span className="tree-subgroup-label">Finished</span>
245
+ <span className="tree-subgroup-count">{finishedSessions.length}</span>
246
+ </div>
247
+ {!finishedCollapsed && (
248
+ <div className="tree-subgroup-items">{finishedSessions.map(renderSession)}</div>
249
+ )}
250
+ </div>
46
251
  )}
47
252
  </div>
48
- );
49
- })}
50
- </div>
253
+ </div>
254
+ );
255
+ })
51
256
  )}
52
257
 
53
- <div className="sidebar-footer">
54
- <span style={{ color: 'var(--text-muted)', fontSize: '11px' }}>
55
- {sessions.length} session{sessions.length !== 1 ? 's' : ''}
56
- </span>
258
+ <div className="sidebar-footer" onClick={() => pickAndAddWorkspace()}>
259
+ <FolderPlus size={14} />
260
+ <span>Add workspace</span>
57
261
  </div>
262
+
263
+ {ctxMenu && (
264
+ <div
265
+ ref={ctxRef}
266
+ className="ctx-menu"
267
+ style={{ position: 'fixed', left: ctxMenu.x, top: ctxMenu.y, zIndex: 500 }}
268
+ >
269
+ {ctxMenu.type === 'workspace' && (
270
+ <>
271
+ <div
272
+ className="ctx-menu-item"
273
+ onClick={() => {
274
+ createSessionInWorkspace(ctxMenu.path!);
275
+ setCtxMenu(null);
276
+ }}
277
+ >
278
+ <Plus size={12} /> New Session
279
+ </div>
280
+ <div
281
+ className="ctx-menu-item ctx-danger"
282
+ onClick={() => {
283
+ removeWorkspace(ctxMenu.path!);
284
+ setCtxMenu(null);
285
+ }}
286
+ >
287
+ <Trash2 size={12} /> Hide Workspace
288
+ </div>
289
+ </>
290
+ )}
291
+ {ctxMenu.type === 'session' && (
292
+ <>
293
+ {ctxMenu.sessionLifecycle !== 'finished' ? (
294
+ <>
295
+ <div
296
+ className="ctx-menu-item"
297
+ onClick={() => {
298
+ startRename(ctxMenu.sessionId!, ctxMenu.sessionTitle ?? '');
299
+ setCtxMenu(null);
300
+ }}
301
+ >
302
+ <Edit3 size={12} /> Rename
303
+ </div>
304
+ <div
305
+ className="ctx-menu-item"
306
+ onClick={() => {
307
+ markFinished(ctxMenu.sessionId!);
308
+ setCtxMenu(null);
309
+ }}
310
+ >
311
+ <CheckCircle2 size={12} /> Mark as Finished
312
+ </div>
313
+ <div
314
+ className="ctx-menu-item ctx-danger"
315
+ onClick={() => {
316
+ deleteSession(ctxMenu.sessionId!);
317
+ setCtxMenu(null);
318
+ }}
319
+ >
320
+ <Trash2 size={12} /> Delete Session
321
+ </div>
322
+ </>
323
+ ) : (
324
+ <>
325
+ <div
326
+ className="ctx-menu-item"
327
+ onClick={() => {
328
+ reopenSession(ctxMenu.sessionId!);
329
+ setCtxMenu(null);
330
+ }}
331
+ >
332
+ <RotateCcw size={12} /> Reopen
333
+ </div>
334
+ <div
335
+ className="ctx-menu-item ctx-danger"
336
+ onClick={() => {
337
+ deleteSession(ctxMenu.sessionId!);
338
+ setCtxMenu(null);
339
+ }}
340
+ >
341
+ <Trash2 size={12} /> Delete Session
342
+ </div>
343
+ </>
344
+ )}
345
+ </>
346
+ )}
347
+ </div>
348
+ )}
58
349
  </div>
59
350
  );
60
- }
351
+ }
@@ -0,0 +1,32 @@
1
+ interface OptionSubPopupProps {
2
+ title: string;
3
+ options: { value: string; label: string; description?: string }[];
4
+ currentValue: string | null | undefined;
5
+ onSelect: (value: string) => void;
6
+ onClose: () => void;
7
+ }
8
+
9
+ /** Radio-style sub-popup for /sandbox, /websearch, /network commands. */
10
+ export default function OptionSubPopup({ title, options, currentValue, onSelect, onClose }: OptionSubPopupProps) {
11
+ return (
12
+ <div className="slash-popup">
13
+ <div className="slash-popup-header">
14
+ <button className="slash-popup-back" onClick={onClose}>← Back</button>
15
+ <span>{title}</span>
16
+ </div>
17
+ {options.map((opt) => (
18
+ <div
19
+ key={opt.value}
20
+ className={`slash-popup-item ${opt.value === currentValue ? 'selected' : ''}`}
21
+ onClick={() => onSelect(opt.value)}
22
+ >
23
+ <div className="slash-popup-item-main">
24
+ <span className="slash-popup-label">{opt.label}</span>
25
+ {opt.description && <span className="slash-popup-hint">{opt.description}</span>}
26
+ </div>
27
+ {opt.value === currentValue && <span className="slash-popup-check">✓</span>}
28
+ </div>
29
+ ))}
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
3
+
4
+ // Mock the skills API
5
+ vi.mock('../../api/skills.ts', () => ({
6
+ fetchSkills: vi.fn(),
7
+ }));
8
+
9
+ import SkillMentionPopup from './SkillMentionPopup.tsx';
10
+ import { fetchSkills } from '../../api/skills.ts';
11
+
12
+ const MOCK_SKILLS = [
13
+ { id: 'cloudflare', name: 'cloudflare', description: 'Cloudflare skill', type: 'skill' as const, enabled: true, metadata: { isSystem: false } },
14
+ { id: '.system/imagegen', name: 'imagegen', description: 'Image gen skill', type: 'skill' as const, enabled: true, metadata: { isSystem: true } },
15
+ ];
16
+
17
+ describe('SkillMentionPopup', () => {
18
+ beforeEach(() => {
19
+ vi.clearAllMocks();
20
+ vi.mocked(fetchSkills).mockResolvedValue(MOCK_SKILLS);
21
+ });
22
+ afterEach(cleanup);
23
+
24
+ it('renders loading state then skills', async () => {
25
+ render(<SkillMentionPopup filter="" onSelect={() => {}} onClose={() => {}} />);
26
+ expect(screen.getByText('Loading skills…')).toBeInTheDocument();
27
+ await waitFor(() => expect(screen.getByText('cloudflare')).toBeInTheDocument());
28
+ expect(screen.getByText('imagegen')).toBeInTheDocument();
29
+ });
30
+
31
+ it('filters skills by name', async () => {
32
+ render(<SkillMentionPopup filter="cloud" onSelect={() => {}} onClose={() => {}} />);
33
+ await waitFor(() => expect(screen.getByText('cloudflare')).toBeInTheDocument());
34
+ expect(screen.queryByText('imagegen')).not.toBeInTheDocument();
35
+ });
36
+
37
+ it('shows system badge for system skills', async () => {
38
+ render(<SkillMentionPopup filter="" onSelect={() => {}} onClose={() => {}} />);
39
+ await waitFor(() => expect(screen.getByText('imagegen')).toBeInTheDocument());
40
+ expect(screen.getByText('system')).toBeInTheDocument();
41
+ });
42
+
43
+ it('calls onSelect when skill is clicked', async () => {
44
+ const onSelect = vi.fn();
45
+ render(<SkillMentionPopup filter="" onSelect={onSelect} onClose={() => {}} />);
46
+ await waitFor(() => expect(screen.getByText('cloudflare')).toBeInTheDocument());
47
+ fireEvent.click(screen.getByText('cloudflare'));
48
+ expect(onSelect).toHaveBeenCalledWith(MOCK_SKILLS[0]);
49
+ });
50
+
51
+ it('calls onClose on Escape key', async () => {
52
+ const onClose = vi.fn();
53
+ render(<SkillMentionPopup filter="" onSelect={() => {}} onClose={onClose} />);
54
+ await waitFor(() => expect(screen.getByText('cloudflare')).toBeInTheDocument());
55
+ fireEvent.keyDown(window, { key: 'Escape' });
56
+ expect(onClose).toHaveBeenCalled();
57
+ });
58
+
59
+ it('shows empty state when no skills match', async () => {
60
+ render(<SkillMentionPopup filter="xyz" onSelect={() => {}} onClose={() => {}} />);
61
+ await waitFor(() => expect(screen.getByText('No matching skills')).toBeInTheDocument());
62
+ });
63
+ });
@@ -0,0 +1,78 @@
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import { fetchSkills } from '../../api/skills.ts';
3
+ import type { Extension } from '../../types/index.ts';
4
+
5
+ interface SkillMentionPopupProps {
6
+ filter: string;
7
+ onSelect: (skill: Extension) => void;
8
+ onClose: () => void;
9
+ }
10
+
11
+ /** Skill search popup — triggered by `$` or `/skills` command. */
12
+ export default function SkillMentionPopup({ filter, onSelect, onClose }: SkillMentionPopupProps) {
13
+ const [skills, setSkills] = useState<Extension[]>([]);
14
+ const [loading, setLoading] = useState(true);
15
+ const [selectedIndex, setSelectedIndex] = useState(0);
16
+ const listRef = useRef<HTMLDivElement>(null);
17
+
18
+ useEffect(() => {
19
+ fetchSkills()
20
+ .then(setSkills)
21
+ .catch(() => setSkills([]))
22
+ .finally(() => setLoading(false));
23
+ }, []);
24
+
25
+ const filtered = skills.filter((s) =>
26
+ s.name.toLowerCase().includes(filter.toLowerCase()) ||
27
+ s.description.toLowerCase().includes(filter.toLowerCase()),
28
+ );
29
+
30
+ useEffect(() => {
31
+ setSelectedIndex(0);
32
+ }, [filter]);
33
+
34
+ useEffect(() => {
35
+ function onKey(e: KeyboardEvent) {
36
+ if (e.key === 'ArrowDown') {
37
+ e.preventDefault();
38
+ setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
39
+ } else if (e.key === 'ArrowUp') {
40
+ e.preventDefault();
41
+ setSelectedIndex((i) => Math.max(i - 1, 0));
42
+ } else if (e.key === 'Enter') {
43
+ e.preventDefault();
44
+ if (filtered[selectedIndex]) onSelect(filtered[selectedIndex]);
45
+ } else if (e.key === 'Escape') {
46
+ e.preventDefault();
47
+ onClose();
48
+ }
49
+ }
50
+ window.addEventListener('keydown', onKey);
51
+ return () => window.removeEventListener('keydown', onKey);
52
+ }, [filtered, selectedIndex, onSelect, onClose]);
53
+
54
+ return (
55
+ <div className="slash-popup" ref={listRef}>
56
+ <div className="slash-popup-header">
57
+ <button className="slash-popup-back" onClick={onClose}>← Back</button>
58
+ <span>Skills</span>
59
+ </div>
60
+ {loading && <div className="slash-popup-empty">Loading skills…</div>}
61
+ {!loading && filtered.length === 0 && <div className="slash-popup-empty">No matching skills</div>}
62
+ {filtered.map((skill, i) => (
63
+ <div
64
+ key={skill.id}
65
+ className={`slash-popup-item ${i === selectedIndex ? 'selected' : ''}`}
66
+ onMouseEnter={() => setSelectedIndex(i)}
67
+ onClick={() => onSelect(skill)}
68
+ >
69
+ <div className="slash-popup-item-main">
70
+ <span className="slash-popup-label">{skill.name}</span>
71
+ {skill.description && <span className="slash-popup-hint">{skill.description}</span>}
72
+ </div>
73
+ {Boolean(skill.metadata.isSystem) && <span className="slash-popup-hint">system</span>}
74
+ </div>
75
+ ))}
76
+ </div>
77
+ );
78
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react';
3
+ import SlashPopup, { filterCommands, type SlashCommand } from './SlashPopup.tsx';
4
+
5
+ const ALL_CAPS = {
6
+ dynamicModels: true,
7
+ skills: true,
8
+ plugins: true,
9
+ mcpServers: true,
10
+ imageAttachments: true,
11
+ sandbox: true,
12
+ webSearch: true,
13
+ networkAccess: true,
14
+ fileReferences: true,
15
+ };
16
+
17
+ const NO_CAPS = {
18
+ dynamicModels: false,
19
+ skills: false,
20
+ plugins: false,
21
+ mcpServers: false,
22
+ imageAttachments: false,
23
+ sandbox: false,
24
+ webSearch: false,
25
+ networkAccess: false,
26
+ fileReferences: false,
27
+ };
28
+
29
+ describe('filterCommands', () => {
30
+ it('returns all commands when all capabilities enabled and no filter', () => {
31
+ const cmds = filterCommands('', ALL_CAPS);
32
+ expect(cmds.length).toBe(7);
33
+ });
34
+
35
+ it('filters by text', () => {
36
+ const cmds = filterCommands('sand', ALL_CAPS);
37
+ expect(cmds).toHaveLength(1);
38
+ expect(cmds[0].id).toBe('sandbox');
39
+ });
40
+
41
+ it('hides commands when capability is false', () => {
42
+ const cmds = filterCommands('', NO_CAPS);
43
+ // All commands have a capability requirement, so none should show
44
+ expect(cmds).toHaveLength(0);
45
+ });
46
+
47
+ it('shows only commands with matching capability', () => {
48
+ const partial = { ...NO_CAPS, sandbox: true, webSearch: true };
49
+ const cmds = filterCommands('', partial);
50
+ expect(cmds).toHaveLength(2);
51
+ expect(cmds.map((c) => c.id)).toEqual(['sandbox', 'websearch']);
52
+ });
53
+ });
54
+
55
+ describe('SlashPopup', () => {
56
+ afterEach(cleanup);
57
+
58
+ const commands: SlashCommand[] = [
59
+ { id: 'sandbox', label: '/sandbox', hint: 'Sandbox mode', capability: 'sandbox', subPopup: 'sandbox' },
60
+ { id: 'websearch', label: '/websearch', hint: 'Web search', capability: 'webSearch', subPopup: 'websearch' },
61
+ ];
62
+
63
+ it('renders all commands', () => {
64
+ render(<SlashPopup commands={commands} selectedIndex={0} onSelect={() => {}} onHover={() => {}} />);
65
+ expect(screen.getByText('/sandbox')).toBeInTheDocument();
66
+ expect(screen.getByText('/websearch')).toBeInTheDocument();
67
+ });
68
+
69
+ it('shows empty state when no commands', () => {
70
+ render(<SlashPopup commands={[]} selectedIndex={0} onSelect={() => {}} onHover={() => {}} />);
71
+ expect(screen.getByText('No matching commands')).toBeInTheDocument();
72
+ });
73
+
74
+ it('calls onSelect when command is clicked', () => {
75
+ const onSelect = vi.fn();
76
+ render(<SlashPopup commands={commands} selectedIndex={0} onSelect={onSelect} onHover={() => {}} />);
77
+ fireEvent.click(screen.getByText('/sandbox'));
78
+ expect(onSelect).toHaveBeenCalledWith(commands[0]);
79
+ });
80
+
81
+ it('applies selected class to the selected index', () => {
82
+ const { container } = render(
83
+ <SlashPopup commands={commands} selectedIndex={1} onSelect={() => {}} onHover={() => {}} />,
84
+ );
85
+ const items = container.querySelectorAll('.slash-popup-item');
86
+ expect(items[1]).toHaveClass('selected');
87
+ expect(items[0]).not.toHaveClass('selected');
88
+ });
89
+
90
+ it('calls onHover on mouse enter', () => {
91
+ const onHover = vi.fn();
92
+ render(<SlashPopup commands={commands} selectedIndex={0} onSelect={() => {}} onHover={onHover} />);
93
+ fireEvent.mouseEnter(screen.getByText('/websearch'));
94
+ expect(onHover).toHaveBeenCalledWith(1);
95
+ });
96
+ });