mulmoclaude 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/bin/mulmoclaude.js +7 -24
  2. package/client/assets/html2canvas-Cx501zZr-DiKaqnKs.js +5 -0
  3. package/client/assets/{index-eHWB79u5.js → index-C94GcmNa.js} +203 -198
  4. package/client/assets/index-CY-WpQUm.css +2 -0
  5. package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-5ipqh8Pe.js} +5 -5
  6. package/client/assets/material-symbols-outlined-NzYEeyps.woff2 +0 -0
  7. package/client/index.html +2 -4
  8. package/package.json +17 -15
  9. package/server/agent/attachmentConverter.ts +2 -2
  10. package/server/agent/backend/claude-code.ts +170 -0
  11. package/server/agent/backend/index.ts +14 -0
  12. package/server/agent/backend/types.ts +65 -0
  13. package/server/agent/index.ts +31 -159
  14. package/server/agent/mcp-server.ts +88 -10
  15. package/server/agent/mcp-tools/index.ts +8 -7
  16. package/server/agent/mcp-tools/notify.ts +76 -0
  17. package/server/agent/mcp-tools/x.ts +12 -2
  18. package/server/agent/plugin-names.ts +10 -4
  19. package/server/agent/prompt.ts +187 -26
  20. package/server/agent/resumeFailover.ts +5 -5
  21. package/server/agent/sandboxMounts.ts +3 -3
  22. package/server/api/auth/bearerAuth.ts +3 -3
  23. package/server/api/auth/token.ts +2 -2
  24. package/server/api/routes/agent.ts +99 -4
  25. package/server/api/routes/chart.ts +13 -0
  26. package/server/api/routes/chat-index.ts +2 -1
  27. package/server/api/routes/config.ts +35 -8
  28. package/server/api/routes/files.ts +75 -24
  29. package/server/api/routes/html.ts +15 -2
  30. package/server/api/routes/image.ts +75 -20
  31. package/server/api/routes/mulmo-script.ts +33 -31
  32. package/server/api/routes/news.ts +146 -0
  33. package/server/api/routes/notifications.ts +58 -2
  34. package/server/api/routes/pdf.ts +2 -2
  35. package/server/api/routes/plugins.ts +73 -91
  36. package/server/api/routes/presentHtml.ts +9 -0
  37. package/server/api/routes/roles.ts +12 -2
  38. package/server/api/routes/scheduler.ts +20 -11
  39. package/server/api/routes/schedulerTasks.ts +58 -21
  40. package/server/api/routes/sessions.ts +15 -4
  41. package/server/api/routes/sessionsCursor.ts +4 -4
  42. package/server/api/routes/skills.ts +26 -5
  43. package/server/api/routes/sources.ts +8 -7
  44. package/server/api/routes/todos.ts +30 -0
  45. package/server/api/routes/todosColumnsHandlers.ts +13 -27
  46. package/server/api/routes/todosHandlers.ts +1 -1
  47. package/server/api/routes/todosItemsHandlers.ts +14 -14
  48. package/server/api/routes/wiki/frontmatter.ts +86 -0
  49. package/server/api/routes/wiki.ts +335 -75
  50. package/server/api/sandboxStatus.ts +1 -1
  51. package/server/events/notifications.ts +32 -8
  52. package/server/events/pub-sub/index.ts +3 -3
  53. package/server/events/relay-client.ts +26 -16
  54. package/server/events/resolveRelayBridgeOptions.ts +125 -0
  55. package/server/index.ts +72 -49
  56. package/server/system/config.ts +5 -5
  57. package/server/system/credentials.ts +7 -5
  58. package/server/system/env.ts +15 -5
  59. package/server/system/macosNotify.ts +152 -0
  60. package/server/utils/errors.ts +11 -2
  61. package/server/utils/fetch.ts +54 -0
  62. package/server/utils/files/atomic.ts +18 -17
  63. package/server/utils/files/image-store.ts +19 -13
  64. package/server/utils/files/journal-io.ts +2 -2
  65. package/server/utils/files/json.ts +5 -5
  66. package/server/utils/files/markdown-image-fill.ts +131 -0
  67. package/server/utils/files/markdown-store.ts +22 -6
  68. package/server/utils/files/naming.ts +20 -10
  69. package/server/utils/files/reference-dirs-io.ts +3 -3
  70. package/server/utils/files/roles-io.ts +4 -4
  71. package/server/utils/files/safe.ts +14 -14
  72. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  73. package/server/utils/files/spreadsheet-store.ts +15 -10
  74. package/server/utils/files/workspace-io.ts +12 -12
  75. package/server/utils/gemini.ts +30 -4
  76. package/server/utils/gitignore.ts +9 -9
  77. package/server/utils/id.ts +40 -8
  78. package/server/utils/json.ts +5 -5
  79. package/server/utils/logBackgroundError.ts +12 -3
  80. package/server/utils/logPreview.ts +24 -0
  81. package/server/utils/markdown.ts +5 -5
  82. package/server/utils/port.d.mts +6 -0
  83. package/server/utils/port.mjs +48 -0
  84. package/server/utils/promptMeta.ts +32 -0
  85. package/server/utils/request.ts +12 -6
  86. package/server/utils/slug.ts +65 -4
  87. package/server/utils/spawn.ts +1 -1
  88. package/server/utils/types.ts +2 -2
  89. package/server/workspace/chat-index/index.ts +1 -1
  90. package/server/workspace/chat-index/summarizer.ts +5 -5
  91. package/server/workspace/custom-dirs.ts +5 -5
  92. package/server/workspace/helps/gemini.md +57 -0
  93. package/server/workspace/helps/index.md +2 -1
  94. package/server/workspace/helps/sources.md +42 -0
  95. package/server/workspace/helps/wiki.md +40 -5
  96. package/server/workspace/journal/archivist-cli.ts +121 -0
  97. package/server/workspace/journal/{archivist.ts → archivist-schemas.ts} +12 -120
  98. package/server/workspace/journal/dailyPass.ts +78 -38
  99. package/server/workspace/journal/diff.ts +2 -2
  100. package/server/workspace/journal/index.ts +56 -5
  101. package/server/workspace/journal/memoryExtractor.ts +1 -1
  102. package/server/workspace/journal/optimizationPass.ts +4 -5
  103. package/server/workspace/journal/paths.ts +8 -24
  104. package/server/workspace/journal/state.ts +18 -8
  105. package/server/workspace/news/reader.ts +248 -0
  106. package/server/workspace/paths.ts +4 -3
  107. package/server/workspace/reference-dirs.ts +3 -3
  108. package/server/workspace/skills/parser.ts +6 -6
  109. package/server/workspace/skills/scheduler.ts +5 -4
  110. package/server/workspace/skills/user-tasks.ts +3 -2
  111. package/server/workspace/skills/writer.ts +3 -3
  112. package/server/workspace/sources/arxivDiscovery.ts +2 -2
  113. package/server/workspace/sources/classifier.ts +1 -1
  114. package/server/workspace/sources/fetchers/rss.ts +5 -5
  115. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  116. package/server/workspace/sources/interests.ts +3 -3
  117. package/server/workspace/sources/paths.ts +6 -6
  118. package/server/workspace/sources/pipeline/fetch.ts +59 -13
  119. package/server/workspace/sources/pipeline/index.ts +59 -7
  120. package/server/workspace/sources/pipeline/notify.ts +13 -5
  121. package/server/workspace/sources/pipeline/plan.ts +11 -9
  122. package/server/workspace/sources/pipeline/summarize.ts +1 -1
  123. package/server/workspace/sources/pipeline/write.ts +5 -5
  124. package/server/workspace/sources/rateLimiter.ts +1 -1
  125. package/server/workspace/sources/sourceState.ts +9 -4
  126. package/server/workspace/sources/types.ts +9 -0
  127. package/server/workspace/sources/urls.ts +1 -1
  128. package/server/workspace/tool-trace/classify.ts +4 -4
  129. package/server/workspace/workspace.ts +7 -7
  130. package/src/App.vue +477 -251
  131. package/src/components/CanvasViewToggle.vue +12 -10
  132. package/src/components/ChatInput.vue +112 -105
  133. package/src/components/FileContentHeader.vue +10 -7
  134. package/src/components/FileContentRenderer.vue +37 -10
  135. package/src/components/FileTree.vue +34 -4
  136. package/src/components/FileTreePane.vue +32 -27
  137. package/src/components/FilesView.vue +5 -3
  138. package/src/components/FilterChip.vue +22 -0
  139. package/src/components/LockStatusPopup.vue +19 -13
  140. package/src/components/NewsView.vue +252 -0
  141. package/src/components/NotificationBell.vue +35 -9
  142. package/src/components/NotificationToast.vue +4 -1
  143. package/src/components/PageChatComposer.vue +101 -0
  144. package/src/components/PluginLauncher.vue +36 -62
  145. package/src/components/RightSidebar.vue +13 -10
  146. package/src/components/RoleSelector.vue +3 -2
  147. package/src/components/SessionHeaderControls.vue +63 -0
  148. package/src/components/SessionHistoryExpandButton.vue +30 -0
  149. package/src/components/SessionHistoryPanel.vue +64 -93
  150. package/src/components/SessionHistoryToggleButton.vue +40 -0
  151. package/src/components/SessionRoleIcon.vue +72 -0
  152. package/src/components/SessionSidebar.vue +96 -0
  153. package/src/components/SessionTabBar.vue +44 -51
  154. package/src/components/SettingsMcpTab.vue +361 -52
  155. package/src/components/SettingsModal.vue +203 -72
  156. package/src/components/SettingsReferenceDirsTab.vue +72 -51
  157. package/src/components/SettingsWorkspaceDirsTab.vue +74 -51
  158. package/src/components/SidebarHeader.vue +50 -16
  159. package/src/components/SourcesManager.vue +900 -0
  160. package/src/components/SourcesView.vue +45 -0
  161. package/src/components/StackView.vue +84 -48
  162. package/src/components/SuggestionsPanel.vue +25 -36
  163. package/src/components/SystemFileBanner.vue +106 -0
  164. package/src/components/ThinkingIndicator.vue +41 -0
  165. package/src/components/TodoExplorer.vue +72 -22
  166. package/src/components/todo/TodoAddDialog.vue +17 -12
  167. package/src/components/todo/TodoEditDialog.vue +7 -2
  168. package/src/components/todo/TodoEditPanel.vue +15 -10
  169. package/src/components/todo/TodoKanbanView.vue +16 -6
  170. package/src/components/todo/TodoListView.vue +14 -3
  171. package/src/components/todo/TodoTableView.vue +36 -5
  172. package/src/composables/favicon/conditions.ts +76 -0
  173. package/src/composables/favicon/resolveColor.ts +93 -0
  174. package/src/composables/favicon/types.ts +61 -0
  175. package/src/composables/useAppApi.ts +23 -0
  176. package/src/composables/useChatScroll.ts +5 -5
  177. package/src/composables/useCurrentRole.ts +32 -0
  178. package/src/composables/useDynamicFavicon.ts +174 -58
  179. package/src/composables/useEventListeners.ts +7 -12
  180. package/src/composables/useFaviconState.ts +93 -12
  181. package/src/composables/useFileSelection.ts +25 -6
  182. package/src/composables/useHealth.ts +76 -7
  183. package/src/composables/useLayoutMode.ts +27 -0
  184. package/src/composables/useNewsItems.ts +38 -0
  185. package/src/composables/useNewsReadState.ts +75 -0
  186. package/src/composables/useNotifications.ts +76 -13
  187. package/src/composables/usePendingCalls.ts +11 -1
  188. package/src/composables/useRoles.ts +6 -10
  189. package/src/composables/useRunElapsed.ts +80 -0
  190. package/src/composables/useSessionDerived.ts +21 -5
  191. package/src/composables/useSessionHistory.ts +7 -17
  192. package/src/composables/useSidePanelVisible.ts +25 -0
  193. package/src/composables/useViewLayout.ts +16 -37
  194. package/src/config/apiRoutes.ts +19 -6
  195. package/src/config/historyFilters.ts +30 -0
  196. package/src/config/mcpCatalog.ts +285 -0
  197. package/src/config/mcpTypes.ts +26 -0
  198. package/src/config/roles.ts +19 -51
  199. package/src/config/systemFileDescriptors.ts +170 -0
  200. package/src/config/toolNames.ts +6 -1
  201. package/src/config/workspacePaths.ts +1 -0
  202. package/src/index.css +14 -0
  203. package/src/lang/de.ts +706 -0
  204. package/src/lang/en.ts +726 -0
  205. package/src/lang/es.ts +712 -0
  206. package/src/lang/fr.ts +704 -0
  207. package/src/lang/ja.ts +707 -0
  208. package/src/lang/ko.ts +709 -0
  209. package/src/lang/pt-BR.ts +702 -0
  210. package/src/lang/zh.ts +705 -0
  211. package/src/lib/vue-i18n.ts +97 -0
  212. package/src/main.ts +3 -0
  213. package/src/plugins/canvas/View.vue +104 -186
  214. package/src/plugins/canvas/definition.ts +0 -8
  215. package/src/plugins/canvas/index.ts +3 -2
  216. package/src/plugins/chart/Preview.vue +1 -1
  217. package/src/plugins/chart/View.vue +9 -4
  218. package/src/plugins/chart/index.ts +3 -2
  219. package/src/plugins/editImage/index.ts +3 -2
  220. package/src/plugins/generateImage/index.ts +3 -2
  221. package/src/plugins/manageRoles/Preview.vue +4 -1
  222. package/src/plugins/manageRoles/View.vue +67 -46
  223. package/src/plugins/manageRoles/index.ts +3 -2
  224. package/src/plugins/manageSkills/Preview.vue +8 -3
  225. package/src/plugins/manageSkills/View.vue +39 -34
  226. package/src/plugins/manageSkills/index.ts +3 -2
  227. package/src/plugins/manageSource/Preview.vue +1 -1
  228. package/src/plugins/manageSource/View.vue +3 -687
  229. package/src/plugins/manageSource/index.ts +3 -2
  230. package/src/plugins/markdown/Preview.vue +1 -1
  231. package/src/plugins/markdown/View.vue +164 -73
  232. package/src/plugins/markdown/definition.ts +6 -4
  233. package/src/plugins/markdown/index.ts +3 -2
  234. package/src/plugins/presentForm/Preview.vue +99 -0
  235. package/src/plugins/presentForm/View.vue +675 -0
  236. package/src/plugins/presentForm/definition.ts +127 -0
  237. package/src/plugins/presentForm/index.ts +18 -0
  238. package/src/plugins/presentForm/plugin.ts +94 -0
  239. package/src/plugins/presentForm/types.ts +109 -0
  240. package/src/plugins/presentHtml/Preview.vue +1 -1
  241. package/src/plugins/presentHtml/View.vue +7 -4
  242. package/src/plugins/presentHtml/index.ts +3 -2
  243. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  244. package/src/plugins/presentMulmoScript/View.vue +36 -26
  245. package/src/plugins/presentMulmoScript/index.ts +3 -2
  246. package/src/plugins/scheduler/AutomationsPreview.vue +37 -0
  247. package/src/plugins/scheduler/AutomationsView.vue +23 -0
  248. package/src/plugins/scheduler/CalendarView.vue +23 -0
  249. package/src/plugins/scheduler/LegacySchedulerView.vue +32 -0
  250. package/src/plugins/scheduler/Preview.vue +7 -4
  251. package/src/plugins/scheduler/TasksTab.vue +119 -28
  252. package/src/plugins/scheduler/View.vue +75 -32
  253. package/src/plugins/scheduler/automationsDefinition.ts +58 -0
  254. package/src/plugins/scheduler/calendarDefinition.ts +46 -0
  255. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  256. package/src/plugins/scheduler/index.ts +68 -14
  257. package/src/plugins/scheduler/legacyShape.ts +34 -0
  258. package/src/plugins/spreadsheet/Preview.vue +9 -5
  259. package/src/plugins/spreadsheet/View.vue +43 -57
  260. package/src/plugins/spreadsheet/engine/responseDecoder.ts +2 -1
  261. package/src/plugins/spreadsheet/index.ts +3 -2
  262. package/src/plugins/textResponse/Preview.vue +15 -58
  263. package/src/plugins/textResponse/View.vue +42 -45
  264. package/src/plugins/textResponse/utils.ts +25 -0
  265. package/src/plugins/todo/Preview.vue +11 -6
  266. package/src/plugins/todo/View.vue +27 -13
  267. package/src/plugins/todo/composables/useTodos.ts +3 -1
  268. package/src/plugins/todo/index.ts +3 -2
  269. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  270. package/src/plugins/ui-image/ImageView.vue +7 -4
  271. package/src/plugins/wiki/Preview.vue +5 -2
  272. package/src/plugins/wiki/View.vue +539 -92
  273. package/src/plugins/wiki/index.ts +5 -2
  274. package/src/plugins/wiki/route.ts +121 -0
  275. package/src/router/guards.ts +43 -24
  276. package/src/router/index.ts +53 -26
  277. package/src/router/pageRoutes.ts +23 -0
  278. package/src/tools/index.ts +12 -5
  279. package/src/tools/legacyPluginNames.ts +13 -0
  280. package/src/types/notification.ts +31 -6
  281. package/src/types/vue-i18n.d.ts +20 -0
  282. package/src/utils/agent/eventDispatch.ts +3 -6
  283. package/src/utils/agent/formatElapsed.ts +37 -0
  284. package/src/utils/agent/request.ts +22 -1
  285. package/src/utils/canvas/layoutMode.ts +26 -0
  286. package/src/utils/canvas/sidePanelVisible.ts +19 -0
  287. package/src/utils/dom/scrollIntoViewByTestId.ts +38 -0
  288. package/src/utils/errors.ts +9 -2
  289. package/src/utils/files/filename.ts +24 -0
  290. package/src/utils/filesPreview/schedulerPreview.ts +9 -3
  291. package/src/utils/id.ts +18 -0
  292. package/src/utils/image/cacheBust.ts +16 -0
  293. package/src/utils/image/resolve.ts +16 -0
  294. package/src/utils/markdown/taskList.ts +175 -0
  295. package/src/utils/mcp/interpolateSpec.ts +97 -0
  296. package/src/utils/notification/dispatch.ts +51 -15
  297. package/src/utils/path/workspaceLinkRouter.ts +99 -0
  298. package/src/utils/session/mergeSessions.ts +5 -0
  299. package/src/utils/sources/filter.ts +69 -0
  300. package/src/vite-env.d.ts +9 -0
  301. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  302. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  303. package/client/assets/index-Bm70FDU2.css +0 -1
  304. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  305. package/server/workspace/journal/linkRewrite.ts +0 -4
  306. package/src/components/ToolResultsPanel.vue +0 -77
  307. package/src/composables/useCanvasViewMode.ts +0 -121
  308. package/src/plugins/scheduler/definition.ts +0 -57
  309. package/src/utils/canvas/viewMode.ts +0 -46
  310. package/src/utils/role/plugins.ts +0 -12
  311. package/src/utils/session/seedRoleDefault.ts +0 -35
  312. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
@@ -1,38 +1,107 @@
1
1
  // Composable for the server /api/health probe.
2
2
  //
3
- // Owns two refs that the UI reads (gemini key availability + sandbox
4
- // toggle) plus the one-shot fetch that populates them on mount. On
5
- // fetch failure we assume Gemini is unavailable so dependent UI
3
+ // Owns three refs that the UI reads (gemini key availability +
4
+ // sandbox toggle + server CPU load ratio) plus a one-shot fetch
5
+ // that populates them on mount, plus an optional periodic refresh
6
+ // for the CPU ratio (the favicon's "overloaded" rule needs a live
7
+ // signal, not a boot-time snapshot).
8
+ //
9
+ // On fetch failure we assume Gemini is unavailable so dependent UI
6
10
  // (e.g. the "generate image" plugin buttons) falls back gracefully
7
11
  // — the sandbox flag keeps its initial `true` so the lock indicator
8
- // doesn't momentarily flash "sandbox disabled" on a transient error.
12
+ // doesn't momentarily flash "sandbox disabled" on a transient error,
13
+ // and the CPU ratio goes to null so the favicon resolver skips the
14
+ // overloaded rule rather than guessing.
9
15
 
10
- import { ref, type Ref } from "vue";
16
+ import { computed, onScopeDispose, ref, type ComputedRef, type Ref } from "vue";
11
17
  import { API_ROUTES } from "../config/apiRoutes";
12
18
  import { apiGet } from "../utils/api";
13
19
 
20
+ // Once every 15 s is enough for a sustained load spike to light the
21
+ // favicon. Shorter would mostly flap on short-lived spikes that
22
+ // aren't actually user-visible as lag.
23
+ const HEALTH_REFRESH_MS = 15_000;
24
+
25
+ interface CpuPayload {
26
+ load1?: unknown;
27
+ cores?: unknown;
28
+ }
29
+
14
30
  interface HealthResponse {
15
31
  geminiAvailable?: unknown;
16
32
  sandboxEnabled?: unknown;
33
+ cpu?: CpuPayload;
17
34
  }
18
35
 
19
36
  export function useHealth(): {
20
37
  geminiAvailable: Ref<boolean>;
21
38
  sandboxEnabled: Ref<boolean>;
39
+ cpuLoadRatio: ComputedRef<number | null>;
22
40
  fetchHealth: () => Promise<void>;
23
41
  } {
24
42
  const geminiAvailable = ref(true);
25
43
  const sandboxEnabled = ref(true);
44
+ const cpuLoad1 = ref<number | null>(null);
45
+ const cpuCores = ref<number | null>(null);
46
+
47
+ // Separate flag so transient poll failures don't flip
48
+ // `geminiAvailable` back to false after a successful boot-time
49
+ // fetch. `geminiAvailable` / `sandboxEnabled` are config-derived
50
+ // and don't change at runtime — once we've observed them once,
51
+ // the next 15 s poll's network blip shouldn't mask them.
52
+ let bootFetchCompleted = false;
26
53
 
27
54
  async function fetchHealth(): Promise<void> {
28
55
  const result = await apiGet<HealthResponse>(API_ROUTES.health);
29
56
  if (!result.ok) {
30
- geminiAvailable.value = false;
57
+ // Only the CPU figures get nulled — the favicon resolver
58
+ // reads null as "skip overloaded" which is the correct fail-
59
+ // closed behaviour. The config flags keep their last-known
60
+ // values, and stay at the initial defaults if we never
61
+ // succeeded (gemini=true → request lands, gets an auth error
62
+ // handled elsewhere; sandbox=true → lock indicator reads on).
63
+ cpuLoad1.value = null;
64
+ cpuCores.value = null;
65
+ if (!bootFetchCompleted) {
66
+ // On the FIRST fetch we do still flip gemini → false so
67
+ // the "Gemini key required" banner can show immediately
68
+ // without waiting for a second attempt. Subsequent poll
69
+ // failures don't re-enter this branch.
70
+ geminiAvailable.value = false;
71
+ }
31
72
  return;
32
73
  }
33
74
  geminiAvailable.value = !!result.data.geminiAvailable;
34
75
  sandboxEnabled.value = !!result.data.sandboxEnabled;
76
+ bootFetchCompleted = true;
77
+ const cpu = result.data.cpu;
78
+ if (cpu && typeof cpu.load1 === "number" && Number.isFinite(cpu.load1) && typeof cpu.cores === "number" && cpu.cores > 0) {
79
+ cpuLoad1.value = cpu.load1;
80
+ cpuCores.value = cpu.cores;
81
+ } else {
82
+ cpuLoad1.value = null;
83
+ cpuCores.value = null;
84
+ }
35
85
  }
36
86
 
37
- return { geminiAvailable, sandboxEnabled, fetchHealth };
87
+ // Refresh the CPU figure periodically. The flag-style booleans
88
+ // (gemini / sandbox) don't change at runtime so re-fetching them
89
+ // is waste; but piggy-backing on the same endpoint keeps the
90
+ // server side to a single route and the client to a single poll.
91
+ const refreshHandle = window.setInterval(() => {
92
+ fetchHealth().catch(() => {
93
+ /* intentionally swallowed — a failed poll just stalls the
94
+ favicon's overloaded rule, not user-visible UI */
95
+ });
96
+ }, HEALTH_REFRESH_MS);
97
+ onScopeDispose(() => window.clearInterval(refreshHandle));
98
+
99
+ // Expose the normalised ratio the favicon resolver expects (load
100
+ // per logical core). Null when either component is missing.
101
+ const cpuLoadRatio = computed<number | null>(() => {
102
+ if (cpuLoad1.value === null || cpuCores.value === null) return null;
103
+ return cpuLoad1.value / cpuCores.value;
104
+ });
105
+
106
+ return { geminiAvailable, sandboxEnabled, cpuLoadRatio, fetchHealth };
38
107
  }
@@ -0,0 +1,27 @@
1
+ // Layout preference (single vs. stack) for the /chat page, persisted
2
+ // in localStorage. Independent of which page the user is on — pages
3
+ // like Files/Todos/Wiki live in the router, not here.
4
+ //
5
+ // One-time cleanup: deletes the legacy `canvas_view_mode` key that
6
+ // conflated layout with page navigation. The value is intentionally
7
+ // not migrated — users land on "single" on first load after the
8
+ // split.
9
+
10
+ import { ref, type Ref } from "vue";
11
+ import { LAYOUT_MODE_STORAGE_KEY, LEGACY_VIEW_MODE_STORAGE_KEY, parseStoredLayoutMode, type LayoutMode } from "../utils/canvas/layoutMode";
12
+
13
+ export function useLayoutMode(): {
14
+ layoutMode: Ref<LayoutMode>;
15
+ setLayoutMode: (mode: LayoutMode) => void;
16
+ } {
17
+ localStorage.removeItem(LEGACY_VIEW_MODE_STORAGE_KEY);
18
+
19
+ const layoutMode = ref<LayoutMode>(parseStoredLayoutMode(localStorage.getItem(LAYOUT_MODE_STORAGE_KEY)));
20
+
21
+ function setLayoutMode(mode: LayoutMode): void {
22
+ layoutMode.value = mode;
23
+ localStorage.setItem(LAYOUT_MODE_STORAGE_KEY, mode);
24
+ }
25
+
26
+ return { layoutMode, setLayoutMode };
27
+ }
@@ -0,0 +1,38 @@
1
+ // Composable: aggregate recent news items via /api/news/items.
2
+ // Mirrors the server-side `NewsItem` shape from
3
+ // `server/workspace/news/reader.ts`. Re-declared here so the
4
+ // frontend doesn't pull a server import.
5
+
6
+ import { ref } from "vue";
7
+ import { API_ROUTES } from "../config/apiRoutes";
8
+ import { apiGet } from "../utils/api";
9
+
10
+ export interface NewsItem {
11
+ id: string;
12
+ title: string;
13
+ url: string;
14
+ publishedAt: string;
15
+ categories: string[];
16
+ sourceSlug: string;
17
+ severity?: string;
18
+ }
19
+
20
+ export function useNewsItems() {
21
+ const items = ref<NewsItem[]>([]);
22
+ const loading = ref(false);
23
+ const error = ref<string | null>(null);
24
+
25
+ async function load(days = 30): Promise<void> {
26
+ loading.value = true;
27
+ error.value = null;
28
+ const result = await apiGet<{ items: NewsItem[] }>(`${API_ROUTES.news.items}?days=${days}`);
29
+ loading.value = false;
30
+ if (!result.ok) {
31
+ error.value = result.error;
32
+ return;
33
+ }
34
+ items.value = result.data.items;
35
+ }
36
+
37
+ return { items, loading, error, load };
38
+ }
@@ -0,0 +1,75 @@
1
+ // Composable: own the news viewer's per-item read flags. The server
2
+ // persists the list as `config/news-read-state.json`; the composable
3
+ // keeps a `Set<string>` for O(1) lookup and a queue of pending writes
4
+ // so a fast click sequence doesn't pile up overlapping PUTs.
5
+
6
+ import { ref, computed } from "vue";
7
+ import { API_ROUTES } from "../config/apiRoutes";
8
+ import { apiGet, apiPut } from "../utils/api";
9
+
10
+ export function useNewsReadState() {
11
+ const readIds = ref(new Set<string>());
12
+ const error = ref<string | null>(null);
13
+
14
+ // Single in-flight chain — successive markRead / markAllRead calls
15
+ // queue rather than overlap. Keeps the server's view consistent
16
+ // with the most recent intent.
17
+ let inflight: Promise<unknown> = Promise.resolve();
18
+
19
+ async function load(): Promise<void> {
20
+ const result = await apiGet<{ readIds: string[] }>(API_ROUTES.news.readState);
21
+ if (!result.ok) {
22
+ error.value = result.error;
23
+ return;
24
+ }
25
+ error.value = null;
26
+ readIds.value = new Set(result.data.readIds);
27
+ }
28
+
29
+ async function persist(): Promise<void> {
30
+ const snapshot = Array.from(readIds.value);
31
+ const task = inflight
32
+ .catch(() => undefined)
33
+ .then(async () => {
34
+ const result = await apiPut<{ readIds: string[] }>(API_ROUTES.news.readState, { readIds: snapshot });
35
+ if (!result.ok) {
36
+ error.value = result.error;
37
+ return;
38
+ }
39
+ error.value = null;
40
+ // Reflect the server's sanitized list so dedupe / cap come back.
41
+ readIds.value = new Set(result.data.readIds);
42
+ });
43
+ inflight = task;
44
+ return task;
45
+ }
46
+
47
+ function markRead(itemId: string): void {
48
+ if (readIds.value.has(itemId)) return;
49
+ readIds.value.add(itemId);
50
+ // Trigger reactivity — `Set` mutation isn't reactive on its own.
51
+ readIds.value = new Set(readIds.value);
52
+ void persist();
53
+ }
54
+
55
+ function markAllRead(allIds: readonly string[]): void {
56
+ let changed = false;
57
+ for (const itemId of allIds) {
58
+ if (!readIds.value.has(itemId)) {
59
+ readIds.value.add(itemId);
60
+ changed = true;
61
+ }
62
+ }
63
+ if (!changed) return;
64
+ readIds.value = new Set(readIds.value);
65
+ void persist();
66
+ }
67
+
68
+ function isRead(itemId: string): boolean {
69
+ return readIds.value.has(itemId);
70
+ }
71
+
72
+ const readCount = computed(() => readIds.value.size);
73
+
74
+ return { readIds, error, load, markRead, markAllRead, isRead, readCount };
75
+ }
@@ -4,18 +4,27 @@
4
4
  // Uses a singleton subscription pattern: the first component that
5
5
  // calls useNotifications() subscribes to the pub-sub channel; the
6
6
  // last one to unmount unsubscribes. All consumers share the same
7
- // module-level state (notifications + readAt).
7
+ // module-level state (notifications + readIds).
8
+ //
9
+ // Read tracking is per-id via a Set. The unread badge decreases
10
+ // only when the user **interacts** with a notification — either
11
+ // clicking it (markRead) or dismissing it via × (dismiss removes
12
+ // the notification entirely, so it leaves the unread tally as a
13
+ // side effect). Opening the panel does NOT auto-mark everything
14
+ // read; the user has to explicitly act on each item, or hit the
15
+ // "Mark all read" button.
8
16
 
9
17
  import { onUnmounted, ref, computed, type Ref, type ComputedRef } from "vue";
10
18
  import { PUBSUB_CHANNELS } from "../config/pubsubChannels";
11
19
  import { usePubSub } from "./usePubSub";
12
- import { NOTIFICATION_KINDS } from "../types/notification";
20
+ import { NOTIFICATION_ACTION_TYPES, NOTIFICATION_KINDS, NOTIFICATION_VIEWS } from "../types/notification";
13
21
  import type { NotificationPayload } from "../types/notification";
14
22
  import { isRecord } from "../utils/types";
15
23
 
16
24
  const MAX_RECENT = 50;
17
25
 
18
26
  const VALID_KINDS = new Set<string>(Object.values(NOTIFICATION_KINDS));
27
+ const VALID_VIEWS = new Set<string>(Object.values(NOTIFICATION_VIEWS));
19
28
 
20
29
  function isNotificationPayload(value: unknown): value is NotificationPayload {
21
30
  if (!isRecord(value)) return false;
@@ -27,14 +36,25 @@ function isNotificationPayload(value: unknown): value is NotificationPayload {
27
36
  return true;
28
37
  }
29
38
 
39
+ // Tighter than a plain `typeof type === "string"` check — confirms
40
+ // the discriminator is one we know AND, for `navigate`, that the
41
+ // target carries a known view. Stops malformed payloads from
42
+ // landing in the panel and crashing later in the click handler.
30
43
  function isValidAction(action: unknown): boolean {
31
44
  if (!isRecord(action)) return false;
32
- return typeof action.type === "string";
45
+ if (action.type === NOTIFICATION_ACTION_TYPES.none) return true;
46
+ if (action.type !== NOTIFICATION_ACTION_TYPES.navigate) return false;
47
+ const target = action.target;
48
+ if (!isRecord(target)) return false;
49
+ return typeof target.view === "string" && VALID_VIEWS.has(target.view);
33
50
  }
34
51
 
35
- // Module-level state so all components share the same list.
52
+ // Module-level state so all components share the same list and the
53
+ // same per-id read state.
36
54
  const notifications = ref<NotificationPayload[]>([]);
37
- const readAt = ref<string | null>(null);
55
+ // Set of notification ids the user has explicitly read (clicked or
56
+ // dismissed-as-read). A Set so add/lookup are O(1) per entry.
57
+ const readIds = ref<Set<string>>(new Set());
38
58
 
39
59
  // Singleton subscription — ref-counted across consumers.
40
60
  let subscriberCount = 0;
@@ -45,10 +65,29 @@ function ensureSubscribed(subscribe: ReturnType<typeof usePubSub>["subscribe"]):
45
65
  if (unsubscribeFn) return; // already listening
46
66
  unsubscribeFn = subscribe(PUBSUB_CHANNELS.notifications, (data) => {
47
67
  if (!isNotificationPayload(data)) return;
48
- notifications.value = [data, ...notifications.value].slice(0, MAX_RECENT);
68
+ const next = [data, ...notifications.value].slice(0, MAX_RECENT);
69
+ notifications.value = next;
70
+ // Drop read-state entries for notifications that just rolled
71
+ // off the end of the bounded list — readIds is otherwise an
72
+ // unbounded leak across a long-lived session.
73
+ pruneReadIds(next);
49
74
  });
50
75
  }
51
76
 
77
+ function pruneReadIds(currentList: readonly NotificationPayload[]): void {
78
+ if (readIds.value.size === 0) return;
79
+ const liveIds = new Set(currentList.map((notif) => notif.id));
80
+ const next = new Set<string>();
81
+ for (const readId of readIds.value) {
82
+ if (liveIds.has(readId)) next.add(readId);
83
+ }
84
+ // Only assign when the contents actually changed — avoids
85
+ // unnecessary reactive churn when nothing rolled off.
86
+ if (next.size !== readIds.value.size) {
87
+ readIds.value = next;
88
+ }
89
+ }
90
+
52
91
  function releaseSubscription(): void {
53
92
  subscriberCount--;
54
93
  if (subscriberCount <= 0 && unsubscribeFn) {
@@ -62,6 +101,8 @@ export function useNotifications(): {
62
101
  notifications: Ref<NotificationPayload[]>;
63
102
  latest: ComputedRef<NotificationPayload | null>;
64
103
  unreadCount: ComputedRef<number>;
104
+ isRead: (id: string) => boolean;
105
+ markRead: (id: string) => void;
65
106
  markAllRead: () => void;
66
107
  dismiss: (id: string) => void;
67
108
  } {
@@ -71,20 +112,42 @@ export function useNotifications(): {
71
112
 
72
113
  const latest = computed(() => notifications.value[0] ?? null);
73
114
 
74
- const unreadCount = computed(() => {
75
- if (!readAt.value) return notifications.value.length;
76
- return notifications.value.filter((notif) => notif.firedAt > readAt.value!).length;
77
- });
115
+ const unreadCount = computed(() => notifications.value.filter((notif) => !readIds.value.has(notif.id)).length);
116
+
117
+ function isRead(notifId: string): boolean {
118
+ return readIds.value.has(notifId);
119
+ }
120
+
121
+ function markRead(notifId: string): void {
122
+ if (readIds.value.has(notifId)) return;
123
+ // Replace the Set so Vue's reactivity fires on consumers that
124
+ // depend on `readIds` via `unreadCount` / `isRead`.
125
+ const next = new Set(readIds.value);
126
+ next.add(notifId);
127
+ readIds.value = next;
128
+ }
78
129
 
79
130
  function markAllRead(): void {
80
- if (notifications.value.length > 0) {
81
- readAt.value = notifications.value[0].firedAt;
131
+ if (notifications.value.length === 0) return;
132
+ const next = new Set(readIds.value);
133
+ for (const notif of notifications.value) {
134
+ next.add(notif.id);
82
135
  }
136
+ readIds.value = next;
83
137
  }
84
138
 
85
139
  function dismiss(notifId: string): void {
86
140
  notifications.value = notifications.value.filter((notif) => notif.id !== notifId);
141
+ // Drop the matching readIds entry too. Without this, a long
142
+ // session that dismisses thousands of notifications leaks one
143
+ // ~36-char id per dismissal even though the user can't see
144
+ // them — pruneReadIds keeps the Set tied to `notifications`.
145
+ if (readIds.value.has(notifId)) {
146
+ const next = new Set(readIds.value);
147
+ next.delete(notifId);
148
+ readIds.value = next;
149
+ }
87
150
  }
88
151
 
89
- return { notifications, latest, unreadCount, markAllRead, dismiss };
152
+ return { notifications, latest, unreadCount, isRead, markRead, markAllRead, dismiss };
90
153
  }
@@ -59,7 +59,17 @@ export function usePendingCalls(opts: UsePendingCallsOptions) {
59
59
  // unused.
60
60
  const __tickDep = displayTick.value;
61
61
  const now = Date.now();
62
- return opts.toolCallHistory.value.filter((entry) => __tickDep >= 0 && isCallStillPending(entry, now));
62
+ // Project to a narrower shape that carries `elapsedMs` so the
63
+ // consumer doesn't need its own ticker for the per-tool badge —
64
+ // the 50ms re-evaluation here already drives the display
65
+ // (#731 PR2).
66
+ return opts.toolCallHistory.value
67
+ .filter((entry) => __tickDep >= 0 && isCallStillPending(entry, now))
68
+ .map((entry) => ({
69
+ toolUseId: entry.toolUseId,
70
+ toolName: entry.toolName,
71
+ elapsedMs: now - entry.timestamp,
72
+ }));
63
73
  });
64
74
 
65
75
  function teardown(): void {
@@ -1,9 +1,9 @@
1
- // Composable that owns the active role list, the currently
2
- // selected role id, and the refresh-from-server fetch. The merge
3
- // rule lives in src/utils/roleMerge so it can be unit-tested
4
- // independently.
1
+ // Composable that owns the active role list and its server-merge
2
+ // fetch. The selected role is owned by SessionHeaderControls via
3
+ // useCurrentRole selection is a UI-local concern and lives next
4
+ // to the dropdown that drives it.
5
5
 
6
- import { computed, ref, type ComputedRef, type Ref } from "vue";
6
+ import { ref, type Ref } from "vue";
7
7
  import { API_ROUTES } from "../config/apiRoutes";
8
8
  import { ROLES, type Role } from "../config/roles";
9
9
  import { mergeRoles } from "../utils/role/merge";
@@ -11,13 +11,9 @@ import { apiGet } from "../utils/api";
11
11
 
12
12
  export function useRoles(): {
13
13
  roles: Ref<Role[]>;
14
- currentRoleId: Ref<string>;
15
- currentRole: ComputedRef<Role>;
16
14
  refreshRoles: () => Promise<void>;
17
15
  } {
18
16
  const roles = ref<Role[]>(ROLES);
19
- const currentRoleId = ref(ROLES[0].id);
20
- const currentRole = computed(() => roles.value.find((role) => role.id === currentRoleId.value) ?? roles.value[0]);
21
17
 
22
18
  async function refreshRoles(): Promise<void> {
23
19
  const result = await apiGet<Role[]>(API_ROUTES.roles.list);
@@ -30,5 +26,5 @@ export function useRoles(): {
30
26
  roles.value = mergeRoles(ROLES, result.data);
31
27
  }
32
28
 
33
- return { roles, currentRoleId, currentRole, refreshRoles };
29
+ return { roles, refreshRoles };
34
30
  }
@@ -0,0 +1,80 @@
1
+ // Tracks how long the active agent run has been going. While
2
+ // `isRunning` is true, `elapsedMs` updates once per second so the
3
+ // rendered string ("12s" / "1m 23s") moves visibly. When the run
4
+ // ends, `elapsedMs` flips back to null and the timer is cleared.
5
+ //
6
+ // Separated from `usePendingCalls` (which ticks every 50ms for the
7
+ // minimum-visible-duration trick) — the run-elapsed display only
8
+ // needs second-granularity, and the consumer renders one badge per
9
+ // run rather than one per pending row, so a tighter tick would just
10
+ // burn re-renders.
11
+ //
12
+ // Why a watcher + setInterval rather than a `requestAnimationFrame`
13
+ // driven computed: tab-throttled rAF freezes when the tab is in the
14
+ // background, and the user expects the elapsed counter to keep
15
+ // running across tab switches.
16
+
17
+ import { computed, ref, watch, type ComputedRef, type Ref, type WatchStopHandle } from "vue";
18
+
19
+ const ONE_SECOND_MS = 1000;
20
+
21
+ interface UseRunElapsedOptions {
22
+ isRunning: ComputedRef<boolean> | Ref<boolean>;
23
+ }
24
+
25
+ export function useRunElapsed(opts: UseRunElapsedOptions): {
26
+ elapsedMs: ComputedRef<number | null>;
27
+ teardown: () => void;
28
+ } {
29
+ const startedAt = ref<number | null>(null);
30
+ const now = ref(0);
31
+ let interval: ReturnType<typeof setInterval> | null = null;
32
+ let stopWatch: WatchStopHandle | null = null;
33
+
34
+ stopWatch = watch(
35
+ opts.isRunning,
36
+ (running) => {
37
+ if (running) {
38
+ // Guard against double-start: if the watcher fires twice with
39
+ // running=true (e.g. immediate + a synchronous flip), don't
40
+ // stack a second interval.
41
+ if (interval !== null) return;
42
+ startedAt.value = Date.now();
43
+ now.value = startedAt.value;
44
+ interval = setInterval(() => {
45
+ now.value = Date.now();
46
+ }, ONE_SECOND_MS);
47
+ return;
48
+ }
49
+ if (interval !== null) {
50
+ clearInterval(interval);
51
+ interval = null;
52
+ }
53
+ startedAt.value = null;
54
+ },
55
+ // immediate so a composable created while a run is already in
56
+ // flight (mounted mid-stream) starts ticking right away.
57
+ { immediate: true },
58
+ );
59
+
60
+ const elapsedMs = computed<number | null>(() => {
61
+ if (startedAt.value === null) return null;
62
+ return now.value - startedAt.value;
63
+ });
64
+
65
+ function teardown(): void {
66
+ // Stop the watcher first — otherwise an isRunning flip after
67
+ // teardown would recreate the interval (Codex iter-1 #798).
68
+ if (stopWatch !== null) {
69
+ stopWatch();
70
+ stopWatch = null;
71
+ }
72
+ if (interval !== null) {
73
+ clearInterval(interval);
74
+ interval = null;
75
+ }
76
+ startedAt.value = null;
77
+ }
78
+
79
+ return { elapsedMs, teardown };
80
+ }
@@ -18,12 +18,27 @@ export function useSessionDerived(opts: { sessionMap: Map<string, ActiveSession>
18
18
 
19
19
  const currentSummary = computed(() => sessions.value.find((summary) => summary.id === currentSessionId.value));
20
20
 
21
- // The server-side summary already merges pendingGenerations into
22
- // `isRunning` (see server/api/routes/sessions.ts), but pub/sub events
23
- // for background generations arrive faster than the next sessions
24
- // refetch fold the in-memory map in so ChatInput reflects the new
25
- // state immediately.
21
+ // Global "is anything running" across every known session — in-memory
22
+ // map (which reflects pub/sub events faster than server refetch) and
23
+ // server-side summaries (for sessions not yet hydrated into the map).
24
+ // Used for consumers that must stay true across page navigation:
25
+ // favicon spinner and the FilesView refresh watcher (which would
26
+ // otherwise fire before a background run actually finishes, because
27
+ // leaving /chat drops activeSession to undefined).
26
28
  const isRunning = computed(() => {
29
+ for (const session of sessionMap.values()) {
30
+ if (session.isRunning) return true;
31
+ if (Object.keys(session.pendingGenerations).length > 0) return true;
32
+ }
33
+ return sessions.value.some((summary) => summary.isRunning);
34
+ });
35
+
36
+ // True only when the session on screen has a run in flight. Drives
37
+ // UX touchpoints that should react per-session — ChatInput disable,
38
+ // sendMessage guard, chat-list auto-scroll, pending-call row tick —
39
+ // so a background run in session B doesn't disable the composer
40
+ // while the user is actively chatting in session A.
41
+ const activeSessionRunning = computed(() => {
27
42
  const active = activeSession.value;
28
43
  const pending = active ? Object.keys(active.pendingGenerations).length > 0 : false;
29
44
  return currentSummary.value?.isRunning || active?.isRunning || pending || false;
@@ -43,6 +58,7 @@ export function useSessionDerived(opts: { sessionMap: Map<string, ActiveSession>
43
58
  sidebarResults,
44
59
  currentSummary,
45
60
  isRunning,
61
+ activeSessionRunning,
46
62
  statusMessage,
47
63
  toolCallHistory,
48
64
  activeSessionCount,
@@ -1,10 +1,10 @@
1
- // Composable for the session-history dropdown in the header.
1
+ // Composable for the session-history view at `/history`.
2
2
  //
3
- // Owns the `sessions` list (what the server knows about) and the
4
- // `showHistory` open/closed flag, plus the fetch + toggle helpers.
5
- // The dropdown lazy-loads the list only when opened, and callers
6
- // can invoke `fetchSessions()` directly after an end-of-run so the
7
- // sidebar title cache stays fresh.
3
+ // Owns the `sessions` list (what the server knows about) plus the
4
+ // fetch helper. The view's open/closed state is now URL-backed (see
5
+ // plans/done/feat-history-url-route.md) callers watch `route.name` and
6
+ // invoke `fetchSessions()` on route enter rather than going through
7
+ // an in-memory toggle flag.
8
8
  //
9
9
  // Since #205, `fetchSessions()` sends the server's last-issued
10
10
  // cursor back as `?since=<cursor>` so the server can reply with
@@ -26,15 +26,12 @@ interface SessionsResponse {
26
26
 
27
27
  export function useSessionHistory(): {
28
28
  sessions: Ref<SessionSummary[]>;
29
- showHistory: Ref<boolean>;
30
29
  historyError: Ref<string | null>;
31
30
  fetchSessions: () => Promise<SessionSummary[]>;
32
- toggleHistory: () => Promise<void>;
33
31
  } {
34
32
  const sessions = ref<SessionSummary[]>([]);
35
- const showHistory = ref(false);
36
33
  // Surfaces the most recent fetch failure. Kept alongside the (stale)
37
- // sessions list rather than wiping it — a dropdown that goes blank
34
+ // sessions list rather than wiping it — a panel that goes blank
38
35
  // the moment the network hiccups is worse UX than one that shows
39
36
  // "⚠ using cached list" with the last-known good entries.
40
37
  const historyError = ref<string | null>(null);
@@ -66,16 +63,9 @@ export function useSessionHistory(): {
66
63
  return sessions.value;
67
64
  }
68
65
 
69
- async function toggleHistory(): Promise<void> {
70
- showHistory.value = !showHistory.value;
71
- if (showHistory.value) await fetchSessions();
72
- }
73
-
74
66
  return {
75
67
  sessions,
76
- showHistory,
77
68
  historyError,
78
69
  fetchSessions,
79
- toggleHistory,
80
70
  };
81
71
  }
@@ -0,0 +1,25 @@
1
+ // localStorage-backed ref for the session-history side-panel flag.
2
+ // Mirrors the shape of useLayoutMode — one source of truth per chat
3
+ // UI preference, kept out of App.vue's already-large state surface.
4
+
5
+ import { ref, type Ref } from "vue";
6
+ import { SIDE_PANEL_VISIBLE_STORAGE_KEY, parseStoredSidePanelVisible, serializeSidePanelVisible } from "../utils/canvas/sidePanelVisible";
7
+
8
+ export function useSidePanelVisible(): {
9
+ sidePanelVisible: Ref<boolean>;
10
+ setSidePanelVisible: (value: boolean) => void;
11
+ toggleSidePanelVisible: () => void;
12
+ } {
13
+ const sidePanelVisible = ref<boolean>(parseStoredSidePanelVisible(localStorage.getItem(SIDE_PANEL_VISIBLE_STORAGE_KEY)));
14
+
15
+ function setSidePanelVisible(value: boolean): void {
16
+ sidePanelVisible.value = value;
17
+ localStorage.setItem(SIDE_PANEL_VISIBLE_STORAGE_KEY, serializeSidePanelVisible(value));
18
+ }
19
+
20
+ function toggleSidePanelVisible(): void {
21
+ setSidePanelVisible(!sidePanelVisible.value);
22
+ }
23
+
24
+ return { sidePanelVisible, setSidePanelVisible, toggleSidePanelVisible };
25
+ }