mulmoclaude 0.1.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/bin/mulmoclaude.js +7 -24
  2. package/client/assets/html2canvas-Cx501zZr-Cv5snK9D.js +5 -0
  3. package/client/assets/index-CubzmCVK.css +2 -0
  4. package/client/assets/{index-D8rhwXLq.js → index-DtcyExH9.js} +80 -61
  5. package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-DnizuhIY.js} +5 -5
  6. package/client/index.html +2 -4
  7. package/package.json +13 -13
  8. package/server/agent/attachmentConverter.ts +2 -2
  9. package/server/agent/config.ts +12 -12
  10. package/server/agent/index.ts +9 -3
  11. package/server/agent/mcp-server.ts +19 -19
  12. package/server/agent/mcp-tools/index.ts +6 -6
  13. package/server/agent/mcp-tools/x.ts +7 -6
  14. package/server/agent/prompt.ts +195 -29
  15. package/server/agent/resumeFailover.ts +5 -5
  16. package/server/agent/sandboxMounts.ts +10 -10
  17. package/server/agent/stream.ts +4 -4
  18. package/server/api/auth/bearerAuth.ts +3 -3
  19. package/server/api/auth/token.ts +2 -2
  20. package/server/api/routes/agent.ts +21 -3
  21. package/server/api/routes/config.ts +1 -1
  22. package/server/api/routes/files.ts +22 -21
  23. package/server/api/routes/html.ts +2 -2
  24. package/server/api/routes/image.ts +7 -7
  25. package/server/api/routes/mulmo-script.ts +33 -31
  26. package/server/api/routes/pdf.ts +2 -2
  27. package/server/api/routes/plugins.ts +16 -6
  28. package/server/api/routes/roles.ts +2 -2
  29. package/server/api/routes/scheduler.ts +14 -12
  30. package/server/api/routes/schedulerHandlers.ts +12 -12
  31. package/server/api/routes/schedulerTasks.ts +19 -17
  32. package/server/api/routes/sessions.ts +26 -26
  33. package/server/api/routes/sessionsCursor.ts +4 -4
  34. package/server/api/routes/skills.ts +5 -5
  35. package/server/api/routes/sources.ts +3 -3
  36. package/server/api/routes/todosColumnsHandlers.ts +30 -30
  37. package/server/api/routes/todosHandlers.ts +1 -1
  38. package/server/api/routes/todosItemsHandlers.ts +14 -14
  39. package/server/api/routes/wiki.ts +36 -22
  40. package/server/api/sandboxStatus.ts +1 -1
  41. package/server/events/notifications.ts +6 -6
  42. package/server/events/pub-sub/index.ts +3 -3
  43. package/server/events/relay-client.ts +17 -16
  44. package/server/events/scheduler-adapter.ts +20 -20
  45. package/server/events/session-store/index.ts +10 -10
  46. package/server/events/task-manager/index.ts +7 -7
  47. package/server/index.ts +59 -65
  48. package/server/system/config.ts +5 -5
  49. package/server/system/credentials.ts +7 -5
  50. package/server/system/env.ts +5 -5
  51. package/server/utils/date.ts +18 -18
  52. package/server/utils/files/atomic.ts +16 -16
  53. package/server/utils/files/html-io.ts +5 -5
  54. package/server/utils/files/image-store.ts +19 -8
  55. package/server/utils/files/journal-io.ts +4 -4
  56. package/server/utils/files/json.ts +5 -5
  57. package/server/utils/files/markdown-store.ts +4 -4
  58. package/server/utils/files/naming.ts +2 -2
  59. package/server/utils/files/reference-dirs-io.ts +3 -3
  60. package/server/utils/files/roles-io.ts +12 -12
  61. package/server/utils/files/safe.ts +14 -14
  62. package/server/utils/files/scheduler-io.ts +5 -5
  63. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  64. package/server/utils/files/session-io.ts +35 -35
  65. package/server/utils/files/spreadsheet-store.ts +7 -7
  66. package/server/utils/files/todos-io.ts +9 -9
  67. package/server/utils/files/user-tasks-io.ts +5 -5
  68. package/server/utils/files/workspace-io.ts +12 -12
  69. package/server/utils/gemini.ts +2 -2
  70. package/server/utils/gitignore.ts +9 -9
  71. package/server/utils/json.ts +5 -5
  72. package/server/utils/logBackgroundError.ts +12 -3
  73. package/server/utils/markdown.ts +5 -5
  74. package/server/utils/port.d.mts +6 -0
  75. package/server/utils/port.mjs +48 -0
  76. package/server/utils/request.ts +12 -6
  77. package/server/utils/spawn.ts +1 -1
  78. package/server/utils/types.ts +2 -2
  79. package/server/workspace/chat-index/indexer.ts +15 -15
  80. package/server/workspace/chat-index/summarizer.ts +4 -4
  81. package/server/workspace/custom-dirs.ts +16 -16
  82. package/server/workspace/journal/archivist.ts +35 -35
  83. package/server/workspace/journal/dailyPass.ts +31 -28
  84. package/server/workspace/journal/diff.ts +2 -2
  85. package/server/workspace/journal/index.ts +4 -4
  86. package/server/workspace/journal/indexFile.ts +29 -25
  87. package/server/workspace/journal/optimizationPass.ts +2 -2
  88. package/server/workspace/journal/state.ts +6 -6
  89. package/server/workspace/paths.ts +3 -3
  90. package/server/workspace/reference-dirs.ts +20 -20
  91. package/server/workspace/roles.ts +6 -6
  92. package/server/workspace/skills/discovery.ts +4 -4
  93. package/server/workspace/skills/parser.ts +6 -6
  94. package/server/workspace/skills/scheduler.ts +3 -3
  95. package/server/workspace/skills/user-tasks.ts +34 -34
  96. package/server/workspace/skills/writer.ts +3 -3
  97. package/server/workspace/sources/arxivDiscovery.ts +10 -10
  98. package/server/workspace/sources/classifier.ts +7 -7
  99. package/server/workspace/sources/fetchers/arxiv.ts +7 -7
  100. package/server/workspace/sources/fetchers/githubIssues.ts +7 -7
  101. package/server/workspace/sources/fetchers/githubReleases.ts +7 -7
  102. package/server/workspace/sources/fetchers/rss.ts +5 -5
  103. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  104. package/server/workspace/sources/interests.ts +12 -12
  105. package/server/workspace/sources/paths.ts +6 -6
  106. package/server/workspace/sources/pipeline/fetch.ts +36 -13
  107. package/server/workspace/sources/pipeline/index.ts +8 -13
  108. package/server/workspace/sources/pipeline/notify.ts +3 -3
  109. package/server/workspace/sources/pipeline/plan.ts +15 -13
  110. package/server/workspace/sources/pipeline/write.ts +5 -5
  111. package/server/workspace/sources/rateLimiter.ts +1 -1
  112. package/server/workspace/sources/registry.ts +16 -16
  113. package/server/workspace/sources/robots.ts +14 -14
  114. package/server/workspace/sources/sourceState.ts +17 -10
  115. package/server/workspace/sources/types.ts +9 -0
  116. package/server/workspace/sources/urls.ts +1 -1
  117. package/server/workspace/tool-trace/classify.ts +4 -4
  118. package/server/workspace/tool-trace/index.ts +1 -1
  119. package/server/workspace/tool-trace/writeSearch.ts +26 -16
  120. package/server/workspace/wiki-backlinks/index.ts +8 -8
  121. package/server/workspace/wiki-backlinks/sessionBacklinks.ts +15 -15
  122. package/server/workspace/workspace.ts +7 -7
  123. package/src/App.vue +315 -141
  124. package/src/components/CanvasViewToggle.vue +10 -7
  125. package/src/components/ChatInput.vue +67 -33
  126. package/src/components/FileContentHeader.vue +7 -4
  127. package/src/components/FileContentRenderer.vue +20 -6
  128. package/src/components/FileTree.vue +6 -3
  129. package/src/components/FileTreePane.vue +11 -8
  130. package/src/components/FilesView.vue +5 -3
  131. package/src/components/LockStatusPopup.vue +17 -14
  132. package/src/components/NotificationBell.vue +14 -5
  133. package/src/components/NotificationToast.vue +6 -3
  134. package/src/components/PluginLauncher.vue +19 -56
  135. package/src/components/RightSidebar.vue +13 -10
  136. package/src/components/RoleSelector.vue +2 -2
  137. package/src/components/SessionHistoryPanel.vue +38 -34
  138. package/src/components/SessionTabBar.vue +8 -10
  139. package/src/components/SettingsMcpTab.vue +49 -36
  140. package/src/components/SettingsModal.vue +24 -22
  141. package/src/components/SettingsReferenceDirsTab.vue +39 -34
  142. package/src/components/SettingsWorkspaceDirsTab.vue +37 -27
  143. package/src/components/SidebarHeader.vue +25 -4
  144. package/src/components/StackView.vue +4 -1
  145. package/src/components/SuggestionsPanel.vue +7 -4
  146. package/src/components/TodoExplorer.vue +26 -15
  147. package/src/components/ToolResultsPanel.vue +27 -13
  148. package/src/components/todo/TodoAddDialog.vue +19 -14
  149. package/src/components/todo/TodoEditDialog.vue +7 -2
  150. package/src/components/todo/TodoEditPanel.vue +17 -12
  151. package/src/components/todo/TodoKanbanView.vue +10 -5
  152. package/src/components/todo/TodoListView.vue +10 -7
  153. package/src/components/todo/TodoTableView.vue +5 -2
  154. package/src/composables/useAppApi.ts +9 -0
  155. package/src/composables/useClickOutside.ts +2 -2
  156. package/src/composables/useDynamicFavicon.ts +172 -37
  157. package/src/composables/useEventListeners.ts +7 -8
  158. package/src/composables/useFaviconState.ts +13 -2
  159. package/src/composables/useFileSelection.ts +24 -6
  160. package/src/composables/useFreshPluginData.ts +3 -3
  161. package/src/composables/useKeyNavigation.ts +11 -11
  162. package/src/composables/useLayoutMode.ts +32 -0
  163. package/src/composables/useMcpTools.ts +2 -2
  164. package/src/composables/useNotifications.ts +3 -3
  165. package/src/composables/usePdfDownload.ts +4 -4
  166. package/src/composables/usePendingCalls.ts +1 -1
  167. package/src/composables/usePubSub.ts +10 -10
  168. package/src/composables/useRoles.ts +1 -1
  169. package/src/composables/useSandboxStatus.ts +1 -1
  170. package/src/composables/useSessionDerived.ts +3 -3
  171. package/src/composables/useSessionHistory.ts +7 -17
  172. package/src/composables/useSessionSync.ts +8 -8
  173. package/src/composables/useViewLayout.ts +20 -34
  174. package/src/config/roles.ts +2 -2
  175. package/src/lang/de.ts +536 -0
  176. package/src/lang/en.ts +558 -0
  177. package/src/lang/es.ts +543 -0
  178. package/src/lang/fr.ts +536 -0
  179. package/src/lang/ja.ts +536 -0
  180. package/src/lang/ko.ts +540 -0
  181. package/src/lang/pt-BR.ts +534 -0
  182. package/src/lang/zh.ts +537 -0
  183. package/src/lib/vue-i18n.ts +97 -0
  184. package/src/main.ts +2 -0
  185. package/src/plugins/canvas/View.vue +102 -186
  186. package/src/plugins/canvas/definition.ts +0 -8
  187. package/src/plugins/chart/Preview.vue +5 -5
  188. package/src/plugins/chart/View.vue +9 -4
  189. package/src/plugins/manageRoles/Preview.vue +4 -1
  190. package/src/plugins/manageRoles/View.vue +59 -43
  191. package/src/plugins/manageSkills/Preview.vue +8 -3
  192. package/src/plugins/manageSkills/View.vue +29 -25
  193. package/src/plugins/manageSource/Preview.vue +2 -2
  194. package/src/plugins/manageSource/View.vue +73 -52
  195. package/src/plugins/markdown/Preview.vue +1 -1
  196. package/src/plugins/markdown/View.vue +26 -36
  197. package/src/plugins/presentHtml/Preview.vue +1 -1
  198. package/src/plugins/presentHtml/View.vue +7 -4
  199. package/src/plugins/presentHtml/helpers.ts +8 -8
  200. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  201. package/src/plugins/presentMulmoScript/View.vue +40 -30
  202. package/src/plugins/presentMulmoScript/helpers.ts +1 -1
  203. package/src/plugins/scheduler/Preview.vue +13 -10
  204. package/src/plugins/scheduler/TasksTab.vue +57 -28
  205. package/src/plugins/scheduler/View.vue +28 -19
  206. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  207. package/src/plugins/spreadsheet/Preview.vue +8 -3
  208. package/src/plugins/spreadsheet/View.vue +21 -12
  209. package/src/plugins/textResponse/Preview.vue +15 -58
  210. package/src/plugins/textResponse/View.vue +29 -9
  211. package/src/plugins/todo/Preview.vue +13 -8
  212. package/src/plugins/todo/View.vue +38 -24
  213. package/src/plugins/todo/composables/useTodos.ts +5 -5
  214. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  215. package/src/plugins/ui-image/ImageView.vue +7 -4
  216. package/src/plugins/wiki/Preview.vue +10 -7
  217. package/src/plugins/wiki/View.vue +202 -81
  218. package/src/plugins/wiki/helpers.ts +4 -4
  219. package/src/plugins/wiki/route.ts +112 -0
  220. package/src/router/guards.ts +46 -28
  221. package/src/router/index.ts +41 -26
  222. package/src/types/session.ts +4 -3
  223. package/src/types/vue-i18n.d.ts +20 -0
  224. package/src/utils/agent/request.ts +22 -3
  225. package/src/utils/canvas/layoutMode.ts +26 -0
  226. package/src/utils/dom/scrollable.ts +2 -2
  227. package/src/utils/files/expandedDirs.ts +1 -1
  228. package/src/utils/files/sortChildren.ts +6 -6
  229. package/src/utils/format/frontmatter.ts +6 -6
  230. package/src/utils/image/cacheBust.ts +16 -0
  231. package/src/utils/image/resolve.ts +16 -0
  232. package/src/utils/image/rewriteMarkdownImageRefs.ts +5 -5
  233. package/src/utils/markdown/extractFirstH1.ts +2 -2
  234. package/src/utils/path/relativeLink.ts +15 -15
  235. package/src/utils/path/workspaceLinkRouter.ts +81 -0
  236. package/src/utils/role/icon.ts +2 -2
  237. package/src/utils/role/merge.ts +2 -2
  238. package/src/utils/role/plugins.ts +1 -1
  239. package/src/utils/session/sessionFactory.ts +2 -2
  240. package/src/utils/session/sessionHelpers.ts +2 -2
  241. package/src/utils/tools/dedup.ts +4 -4
  242. package/src/utils/tools/result.ts +3 -3
  243. package/src/utils/types.ts +2 -2
  244. package/src/vite-env.d.ts +9 -0
  245. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  246. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  247. package/client/assets/index-KNLBjwuh.css +0 -1
  248. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  249. package/src/composables/useCanvasViewMode.ts +0 -121
  250. package/src/utils/canvas/viewMode.ts +0 -46
  251. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
@@ -1,11 +1,21 @@
1
1
  // Dynamic favicon that changes color based on agent state (#470).
2
2
  //
3
- // Uses Canvas API to draw a rounded-square icon with the letter "M"
4
- // in the center. Color reflects the current state:
3
+ // Renders the MulmoClaude mascot on a colored rounded square. The
4
+ // background color reflects state, and the mascot floats on top:
5
5
  // idle (gray) → running (blue, pulse) → done (green) → error (red)
6
6
  // notification badge (orange dot) overlaid when unread count > 0.
7
+ //
8
+ // The logo PNG has an opaque white background, which would otherwise
9
+ // hide the state color. On first load we pre-process the pixels,
10
+ // punching out near-white pixels to transparency so the colored
11
+ // backing shows through. The processed image is cached as an
12
+ // offscreen canvas for the lifetime of the page.
13
+ //
14
+ // If the logo fails to load we fall back to the earlier "M"-letter
15
+ // variant so the tab icon never disappears entirely.
7
16
 
8
17
  import { watch, type Ref, type ComputedRef } from "vue";
18
+ import logoUrl from "../assets/mulmo_bw.png";
9
19
 
10
20
  export const FAVICON_STATES = {
11
21
  idle: "idle",
@@ -23,9 +33,88 @@ const STATE_COLORS: Record<FaviconState, string> = {
23
33
  error: "#EF4444", // red-500
24
34
  };
25
35
 
26
- const NOTIFICATION_DOT_COLOR = "#F97316"; // orange-500
36
+ const NOTIFICATION_DOT_COLOR = "#DC2626"; // red-600 — stands out against the gray/blue/green state backgrounds
27
37
  const SIZE = 32;
28
38
  const RADIUS = 6;
39
+ // How much of the inner rounded square the mascot fills. 2 px of
40
+ // padding on each side keeps it off the rounded corners and leaves
41
+ // room for the colored backing to peek around the outline.
42
+ const MASCOT_INSET = 2;
43
+
44
+ // Pixels whose RGB channels are all above this are treated as the
45
+ // PNG's white backing and punched to transparent. The PNG uses a soft
46
+ // pastel palette so the mascot itself never hits all three channels
47
+ // this high.
48
+ const WHITE_TO_ALPHA_THRESHOLD = 235;
49
+ // Pixels in the [FEATHER_LOW, WHITE_TO_ALPHA_THRESHOLD] band get a
50
+ // partial-alpha ramp so the mascot's anti-aliased outline blends with
51
+ // the colored background instead of showing a hard seam.
52
+ const FEATHER_LOW = 205;
53
+
54
+ // ── Asset loading ──────────────────────────────────────────────
55
+
56
+ let logoCanvas: HTMLCanvasElement | null = null;
57
+ let logoLoadFailed = false;
58
+ let logoLoadPromise: Promise<HTMLCanvasElement> | null = null;
59
+
60
+ function loadLogo(): Promise<HTMLCanvasElement> {
61
+ if (logoCanvas) return Promise.resolve(logoCanvas);
62
+ if (logoLoadPromise) return logoLoadPromise;
63
+ logoLoadPromise = decodeAndPunchOutWhite();
64
+ return logoLoadPromise;
65
+ }
66
+
67
+ function decodeAndPunchOutWhite(): Promise<HTMLCanvasElement> {
68
+ return new Promise((resolve, reject) => {
69
+ const img = new Image();
70
+ img.onload = () => {
71
+ try {
72
+ logoCanvas = buildTransparentLogoCanvas(img);
73
+ resolve(logoCanvas);
74
+ } catch (err) {
75
+ logoLoadFailed = true;
76
+ reject(err instanceof Error ? err : new Error(String(err)));
77
+ }
78
+ };
79
+ img.onerror = (err) => {
80
+ logoLoadFailed = true;
81
+ reject(err instanceof Error ? err : new Error("favicon logo failed to load"));
82
+ };
83
+ img.src = logoUrl;
84
+ });
85
+ }
86
+
87
+ // Copy the decoded <img> into an offscreen canvas and scan every
88
+ // pixel, replacing near-white with transparency. The PNG is opaque so
89
+ // the background would otherwise cover the state color backing.
90
+ function buildTransparentLogoCanvas(img: HTMLImageElement): HTMLCanvasElement {
91
+ const canvas = document.createElement("canvas");
92
+ canvas.width = img.naturalWidth;
93
+ canvas.height = img.naturalHeight;
94
+ const ctx = canvas.getContext("2d");
95
+ if (!ctx) throw new Error("2d context unavailable");
96
+ ctx.drawImage(img, 0, 0);
97
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
98
+ const pixels = imageData.data;
99
+ for (let i = 0; i < pixels.length; i += 4) {
100
+ const red = pixels[i];
101
+ const green = pixels[i + 1];
102
+ const blue = pixels[i + 2];
103
+ const minChannel = Math.min(red, green, blue);
104
+ if (minChannel >= WHITE_TO_ALPHA_THRESHOLD) {
105
+ pixels[i + 3] = 0; // fully transparent
106
+ } else if (minChannel >= FEATHER_LOW) {
107
+ // Linear ramp across the feather band. At minChannel = FEATHER_LOW
108
+ // alpha stays 255; at threshold it drops to 0.
109
+ const ratio = (minChannel - FEATHER_LOW) / (WHITE_TO_ALPHA_THRESHOLD - FEATHER_LOW);
110
+ pixels[i + 3] = Math.round(255 * (1 - ratio));
111
+ }
112
+ }
113
+ ctx.putImageData(imageData, 0, 0);
114
+ return canvas;
115
+ }
116
+
117
+ // ── Drawing primitives ─────────────────────────────────────────
29
118
 
30
119
  function drawRoundedRect(ctx: CanvasRenderingContext2D, posX: number, posY: number, width: number, height: number, radius: number): void {
31
120
  ctx.beginPath();
@@ -41,54 +130,94 @@ function drawRoundedRect(ctx: CanvasRenderingContext2D, posX: number, posY: numb
41
130
  ctx.closePath();
42
131
  }
43
132
 
44
- function renderFavicon(state: FaviconState, hasNotification: boolean): string {
45
- const canvas = document.createElement("canvas");
46
- canvas.width = SIZE;
47
- canvas.height = SIZE;
48
- const ctx = canvas.getContext("2d");
49
- if (!ctx) return "";
133
+ // Aspect-preserving letterbox: scale the logo to fit the inner area
134
+ // without distorting the mascot, then center the leftover space.
135
+ function drawLogoCentered(ctx: CanvasRenderingContext2D, source: HTMLCanvasElement, inset: number): void {
136
+ const available = SIZE - inset * 2;
137
+ const aspect = source.width / source.height;
138
+ const drawW = aspect >= 1 ? available : available * aspect;
139
+ const drawH = aspect >= 1 ? available / aspect : available;
140
+ const drawX = inset + (available - drawW) / 2;
141
+ const drawY = inset + (available - drawH) / 2;
142
+ ctx.drawImage(source, drawX, drawY, drawW, drawH);
143
+ }
50
144
 
51
- // Background: rounded square
52
- const color = STATE_COLORS[state];
53
- drawRoundedRect(ctx, 1, 1, SIZE - 2, SIZE - 2, RADIUS);
54
- ctx.fillStyle = color;
145
+ function drawNotificationDot(ctx: CanvasRenderingContext2D): void {
146
+ const dotR = 5;
147
+ const dotX = SIZE - dotR - 1;
148
+ const dotY = dotR + 1;
149
+ ctx.beginPath();
150
+ ctx.arc(dotX, dotY, dotR, 0, Math.PI * 2);
151
+ ctx.fillStyle = NOTIFICATION_DOT_COLOR;
55
152
  ctx.fill();
153
+ ctx.strokeStyle = "white";
154
+ ctx.lineWidth = 1.5;
155
+ ctx.stroke();
156
+ }
157
+
158
+ // ── Composition ────────────────────────────────────────────────
56
159
 
57
- // Subtle shadow/depth
58
- ctx.strokeStyle = "rgba(0,0,0,0.15)";
59
- ctx.lineWidth = 1;
160
+ // Fallback for when the logo PNG fails to decode (or before the first
161
+ // decode completes). Mirrors the earlier "M"-on-colored-square design
162
+ // so the favicon always has a valid first paint.
163
+ function renderFallbackFavicon(ctx: CanvasRenderingContext2D, state: FaviconState, hasNotification: boolean): void {
60
164
  drawRoundedRect(ctx, 1, 1, SIZE - 2, SIZE - 2, RADIUS);
61
- ctx.stroke();
165
+ ctx.fillStyle = STATE_COLORS[state];
166
+ ctx.fill();
62
167
 
63
- // "M" letter
64
168
  ctx.fillStyle = "white";
65
169
  ctx.font = "bold 20px -apple-system, BlinkMacSystemFont, sans-serif";
66
170
  ctx.textAlign = "center";
67
171
  ctx.textBaseline = "middle";
68
172
  ctx.fillText("M", SIZE / 2, SIZE / 2 + 1);
69
173
 
70
- // Running state: subtle glow ring
174
+ if (hasNotification) drawNotificationDot(ctx);
175
+ }
176
+
177
+ function renderLogoFavicon(ctx: CanvasRenderingContext2D, logo: HTMLCanvasElement, state: FaviconState, hasNotification: boolean): void {
178
+ // Colored rounded-square backing — the dynamic cue.
179
+ drawRoundedRect(ctx, 0, 0, SIZE, SIZE, RADIUS);
180
+ ctx.fillStyle = STATE_COLORS[state];
181
+ ctx.fill();
182
+
183
+ // Clip subsequent draws to the rounded square so the mascot's
184
+ // anti-aliased edges don't spill past the corners.
185
+ ctx.save();
186
+ drawRoundedRect(ctx, 0, 0, SIZE, SIZE, RADIUS);
187
+ ctx.clip();
188
+ drawLogoCentered(ctx, logo, MASCOT_INSET);
189
+ ctx.restore();
190
+
191
+ // Running state: subtle inner glow ring reinforces the pulse cue
192
+ // without overpowering the colored backing.
71
193
  if (state === FAVICON_STATES.running) {
72
- ctx.strokeStyle = "rgba(255,255,255,0.4)";
73
- ctx.lineWidth = 2;
74
- drawRoundedRect(ctx, 3, 3, SIZE - 6, SIZE - 6, RADIUS - 1);
194
+ ctx.strokeStyle = "rgba(255, 255, 255, 0.55)";
195
+ ctx.lineWidth = 1.5;
196
+ drawRoundedRect(ctx, 2.25, 2.25, SIZE - 4.5, SIZE - 4.5, Math.max(RADIUS - 1, 2));
75
197
  ctx.stroke();
76
198
  }
77
199
 
78
- // Notification badge (orange dot, top-right)
79
- if (hasNotification) {
80
- const dotR = 5;
81
- const dotX = SIZE - dotR - 1;
82
- const dotY = dotR + 1;
83
- ctx.beginPath();
84
- ctx.arc(dotX, dotY, dotR, 0, Math.PI * 2);
85
- ctx.fillStyle = NOTIFICATION_DOT_COLOR;
86
- ctx.fill();
87
- ctx.strokeStyle = "white";
88
- ctx.lineWidth = 1.5;
89
- ctx.stroke();
200
+ if (hasNotification) drawNotificationDot(ctx);
201
+ }
202
+
203
+ async function renderFavicon(state: FaviconState, hasNotification: boolean): Promise<string> {
204
+ const canvas = document.createElement("canvas");
205
+ canvas.width = SIZE;
206
+ canvas.height = SIZE;
207
+ const ctx = canvas.getContext("2d");
208
+ if (!ctx) return "";
209
+
210
+ if (!logoLoadFailed) {
211
+ try {
212
+ const logo = await loadLogo();
213
+ renderLogoFavicon(ctx, logo, state, hasNotification);
214
+ return canvas.toDataURL("image/png");
215
+ } catch {
216
+ // fall through — renderFallbackFavicon below handles it.
217
+ }
90
218
  }
91
219
 
220
+ renderFallbackFavicon(ctx, state, hasNotification);
92
221
  return canvas.toDataURL("image/png");
93
222
  }
94
223
 
@@ -106,10 +235,16 @@ function applyFavicon(dataUrl: string): void {
106
235
  }
107
236
 
108
237
  export function useDynamicFavicon(opts: { state: Ref<FaviconState> | ComputedRef<FaviconState>; hasNotification: Ref<boolean> | ComputedRef<boolean> }): void {
109
- function update(): void {
110
- const dataUrl = renderFavicon(opts.state.value, opts.hasNotification.value);
238
+ async function update(): Promise<void> {
239
+ const dataUrl = await renderFavicon(opts.state.value, opts.hasNotification.value);
111
240
  applyFavicon(dataUrl);
112
241
  }
113
242
 
114
- watch([opts.state, opts.hasNotification], update, { immediate: true });
243
+ watch(
244
+ [opts.state, opts.hasNotification],
245
+ () => {
246
+ update().catch((err) => console.warn("[favicon] render failed", err));
247
+ },
248
+ { immediate: true },
249
+ );
115
250
  }
@@ -1,13 +1,16 @@
1
1
  // Composable that wires the window-level event listeners used by
2
- // App.vue (click-outside handlers for 3 popups + global keydown for
3
- // navigation + view-mode shortcuts) and tears them down on unmount.
2
+ // App.vue (global keydown for navigation + view-mode shortcuts) and
3
+ // tears them down on unmount.
4
4
  //
5
5
  // Plugin → App.vue communication used to live here too via
6
6
  // `roles-updated` / `skill-run` CustomEvents on `window`. That now
7
7
  // flows through `useAppApi` (provide/inject) — see #227. Anything
8
8
  // remaining in this composable is genuinely a window-level concern
9
- // (keyboard / mouse events that don't have a single "owning"
10
- // component).
9
+ // (keyboard events that don't have a single "owning" component).
10
+ //
11
+ // The click-outside handler for the history popup was dropped when
12
+ // the popup became a real page at /history (see
13
+ // plans/feat-history-url-route.md).
11
14
  //
12
15
  // Each listener is supplied as an option so the composable stays
13
16
  // independent of App.vue's local state; the caller passes the
@@ -20,8 +23,6 @@ export interface EventListenerHandlers {
20
23
  onKeyNavigation: (e: KeyboardEvent) => void;
21
24
  /** Global keydown for Cmd/Ctrl+1/2/3 view-mode shortcut. */
22
25
  onViewModeShortcut: (e: KeyboardEvent) => void;
23
- /** mousedown click-outside handlers for each popup. */
24
- onClickOutsideHistory: (e: MouseEvent) => void;
25
26
  /** Called in onUnmounted after all window listeners are removed. */
26
27
  onTeardown?: () => void;
27
28
  }
@@ -30,13 +31,11 @@ export function useEventListeners(handlers: EventListenerHandlers): void {
30
31
  onMounted(() => {
31
32
  window.addEventListener("keydown", handlers.onKeyNavigation);
32
33
  window.addEventListener("keydown", handlers.onViewModeShortcut);
33
- window.addEventListener("mousedown", handlers.onClickOutsideHistory);
34
34
  });
35
35
 
36
36
  onUnmounted(() => {
37
37
  window.removeEventListener("keydown", handlers.onKeyNavigation);
38
38
  window.removeEventListener("keydown", handlers.onViewModeShortcut);
39
- window.removeEventListener("mousedown", handlers.onClickOutsideHistory);
40
39
  handlers.onTeardown?.();
41
40
  });
42
41
  }
@@ -10,8 +10,13 @@ export function useFaviconState(opts: {
10
10
  isRunning: ComputedRef<boolean>;
11
11
  currentSummary: ComputedRef<SessionSummary | undefined>;
12
12
  activeSession: ComputedRef<ActiveSession | undefined>;
13
+ // Number of sessions (across all tabs) with unread messages. We
14
+ // light the badge dot when any session is unread, even if it's not
15
+ // the currently-focused one, so background replies still surface in
16
+ // the tab bar.
17
+ sessionsUnreadCount: ComputedRef<number>;
13
18
  }) {
14
- const { isRunning, currentSummary, activeSession } = opts;
19
+ const { isRunning, currentSummary, activeSession, sessionsUnreadCount } = opts;
15
20
 
16
21
  const faviconState = computed<FaviconState>(() => {
17
22
  if (isRunning.value) return FAVICON_STATES.running;
@@ -21,7 +26,13 @@ export function useFaviconState(opts: {
21
26
  });
22
27
 
23
28
  const { unreadCount: notificationUnreadCount } = useNotifications();
24
- const hasNotificationBadge = computed(() => notificationUnreadCount.value > 0);
29
+ // Badge dot covers two independent signals:
30
+ // 1. Pub-sub notifications (scheduled tasks, etc.)
31
+ // 2. Any session with unread chat messages (including background
32
+ // tabs the user isn't currently viewing).
33
+ // Either one flips the dot on — the dot doesn't distinguish source,
34
+ // just tells the user "there's something to look at".
35
+ const hasNotificationBadge = computed(() => notificationUnreadCount.value > 0 || sessionsUnreadCount.value > 0);
25
36
 
26
37
  useDynamicFavicon({
27
38
  state: faviconState,
@@ -32,12 +32,27 @@ export function isValidFilePath(value: unknown): value is string {
32
32
  return !value.split("/").some((seg) => seg === "..");
33
33
  }
34
34
 
35
+ /**
36
+ * Extract the logical file path from a route's `pathMatch` param.
37
+ * Vue Router hands the repeatable catch-all back as an array, a
38
+ * single string, or `undefined` depending on what matched — normalise
39
+ * to a `string | null` so the rest of the composable doesn't care.
40
+ */
41
+ export function readPathMatch(raw: unknown): string | null {
42
+ if (Array.isArray(raw)) {
43
+ if (raw.length === 0) return null;
44
+ return raw.join("/");
45
+ }
46
+ if (typeof raw === "string" && raw.length > 0) return raw;
47
+ return null;
48
+ }
49
+
35
50
  export function useFileSelection() {
36
51
  const route = useRoute();
37
52
  const router = useRouter();
38
53
 
39
- const urlPath = route.query.path;
40
- const selectedPath = ref<string | null>(isValidFilePath(urlPath) ? urlPath : null);
54
+ const pathFromRoute = readPathMatch(route.params.pathMatch);
55
+ const selectedPath = ref<string | null>(isValidFilePath(pathFromRoute) ? pathFromRoute : null);
41
56
  const content = ref<FileContent | null>(null);
42
57
  const contentLoading = ref(false);
43
58
  const contentError = ref<string | null>(null);
@@ -71,8 +86,12 @@ export function useFileSelection() {
71
86
  function selectFile(filePath: string): void {
72
87
  selectedPath.value = filePath;
73
88
  loadContent(filePath);
74
- const { path: __path, ...restQuery } = route.query;
75
- router.push({ query: { ...restQuery, path: filePath } }).catch((err: unknown) => {
89
+ // Pass segments as an array so Vue Router encodes each segment
90
+ // independently (spaces / multi-byte / `?#%` get UTF-8 percent-
91
+ // encoding), while slashes stay as path separators. Passing the
92
+ // joined string would urlencode `/` → `%2F` and collapse the
93
+ // visible path shape.
94
+ router.push({ name: "files", params: { pathMatch: filePath.split("/") }, query: route.query }).catch((err: unknown) => {
76
95
  if (!isNavigationFailure(err)) {
77
96
  // Frontend composable — server logger not available.
78
97
  // console.error is the standard pattern in Vue composables.
@@ -88,8 +107,7 @@ export function useFileSelection() {
88
107
  content.value = null;
89
108
  contentLoading.value = false;
90
109
  contentError.value = null;
91
- const { path: __path, ...restQuery } = route.query;
92
- router.replace({ query: restQuery }).catch((err: unknown) => {
110
+ router.replace({ name: "files", params: { pathMatch: [] }, query: route.query }).catch((err: unknown) => {
93
111
  if (!isNavigationFailure(err)) {
94
112
  console.error("[deselectFile] navigation failed:", err);
95
113
  }
@@ -70,9 +70,9 @@ export function useFreshPluginData<T>(opts: UseFreshPluginDataOptions<T>): UseFr
70
70
 
71
71
  async function refresh(): Promise<boolean> {
72
72
  controller?.abort();
73
- const c = new AbortController();
74
- controller = c;
75
- return refreshOnce(opts, c.signal);
73
+ const ctrl = new AbortController();
74
+ controller = ctrl;
75
+ return refreshOnce(opts, ctrl.signal);
76
76
  }
77
77
 
78
78
  function abort(): void {
@@ -18,7 +18,7 @@ function isVerticalArrow(key: string): key is "ArrowUp" | "ArrowDown" {
18
18
 
19
19
  function resolveNextUuid(results: ToolResultComplete[], currentUuid: string | null, direction: "ArrowUp" | "ArrowDown"): string | null {
20
20
  if (results.length === 0) return null;
21
- const idx = results.findIndex((r) => r.uuid === currentUuid);
21
+ const idx = results.findIndex((result) => result.uuid === currentUuid);
22
22
  if (idx === -1) {
23
23
  return direction === "ArrowDown" ? results[0].uuid : results[results.length - 1].uuid;
24
24
  }
@@ -36,23 +36,23 @@ export function useKeyNavigation(opts: {
36
36
  }) {
37
37
  const { canvasRef, activePane, sidebarResults, selectedResultUuid } = opts;
38
38
 
39
- function handleCanvasKeydown(e: KeyboardEvent): void {
40
- if (!isVerticalArrow(e.key)) return;
41
- if (isEditableTarget(e.target)) return;
39
+ function handleCanvasKeydown(event: KeyboardEvent): void {
40
+ if (!isVerticalArrow(event.key)) return;
41
+ if (isEditableTarget(event.target)) return;
42
42
  if (!canvasRef.value) return;
43
43
  const scrollable = findScrollableChild(canvasRef.value);
44
44
  if (!scrollable) return;
45
- e.preventDefault();
46
- const delta = e.key === "ArrowDown" ? SCROLL_AMOUNT : -SCROLL_AMOUNT;
45
+ event.preventDefault();
46
+ const delta = event.key === "ArrowDown" ? SCROLL_AMOUNT : -SCROLL_AMOUNT;
47
47
  scrollable.scrollBy({ top: delta, behavior: "smooth" });
48
48
  }
49
49
 
50
- function handleKeyNavigation(e: KeyboardEvent): void {
50
+ function handleKeyNavigation(event: KeyboardEvent): void {
51
51
  if (activePane.value !== "sidebar") return;
52
- if (isEditableTarget(e.target)) return;
53
- if (!isVerticalArrow(e.key)) return;
54
- e.preventDefault();
55
- const nextUuid = resolveNextUuid(sidebarResults.value, selectedResultUuid.value, e.key);
52
+ if (isEditableTarget(event.target)) return;
53
+ if (!isVerticalArrow(event.key)) return;
54
+ event.preventDefault();
55
+ const nextUuid = resolveNextUuid(sidebarResults.value, selectedResultUuid.value, event.key);
56
56
  if (nextUuid) selectedResultUuid.value = nextUuid;
57
57
  }
58
58
 
@@ -0,0 +1,32 @@
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_MODES, 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
+ toggleLayoutMode: () => void;
17
+ } {
18
+ localStorage.removeItem(LEGACY_VIEW_MODE_STORAGE_KEY);
19
+
20
+ const layoutMode = ref<LayoutMode>(parseStoredLayoutMode(localStorage.getItem(LAYOUT_MODE_STORAGE_KEY)));
21
+
22
+ function setLayoutMode(mode: LayoutMode): void {
23
+ layoutMode.value = mode;
24
+ localStorage.setItem(LAYOUT_MODE_STORAGE_KEY, mode);
25
+ }
26
+
27
+ function toggleLayoutMode(): void {
28
+ setLayoutMode(layoutMode.value === LAYOUT_MODES.stack ? LAYOUT_MODES.single : LAYOUT_MODES.stack);
29
+ }
30
+
31
+ return { layoutMode, setLayoutMode, toggleLayoutMode };
32
+ }
@@ -56,8 +56,8 @@ export function useMcpTools(opts: UseMcpToolsOptions) {
56
56
  }
57
57
  mcpToolsError.value = null;
58
58
  const tools = result.data;
59
- disabledMcpTools.value = new Set(tools.filter((t) => !t.enabled).map((t) => t.name));
60
- mcpToolDescriptions.value = Object.fromEntries(tools.filter(hasPrompt).map((t) => [t.name, t.prompt]));
59
+ disabledMcpTools.value = new Set(tools.filter((tool) => !tool.enabled).map((tool) => tool.name));
60
+ mcpToolDescriptions.value = Object.fromEntries(tools.filter(hasPrompt).map((tool) => [tool.name, tool.prompt]));
61
61
  }
62
62
 
63
63
  return {
@@ -73,7 +73,7 @@ export function useNotifications(): {
73
73
 
74
74
  const unreadCount = computed(() => {
75
75
  if (!readAt.value) return notifications.value.length;
76
- return notifications.value.filter((n) => n.firedAt > readAt.value!).length;
76
+ return notifications.value.filter((notif) => notif.firedAt > readAt.value!).length;
77
77
  });
78
78
 
79
79
  function markAllRead(): void {
@@ -82,8 +82,8 @@ export function useNotifications(): {
82
82
  }
83
83
  }
84
84
 
85
- function dismiss(id: string): void {
86
- notifications.value = notifications.value.filter((n) => n.id !== id);
85
+ function dismiss(notifId: string): void {
86
+ notifications.value = notifications.value.filter((notif) => notif.id !== notifId);
87
87
  }
88
88
 
89
89
  return { notifications, latest, unreadCount, markAllRead, dismiss };
@@ -42,10 +42,10 @@ export function usePdfDownload(): UsePdfDownloadHandle {
42
42
  }
43
43
  const blob = await response.blob();
44
44
  url = URL.createObjectURL(blob);
45
- const a = document.createElement("a");
46
- a.href = url;
47
- a.download = filename;
48
- a.click();
45
+ const anchor = document.createElement("a");
46
+ anchor.href = url;
47
+ anchor.download = filename;
48
+ anchor.click();
49
49
  } catch (err) {
50
50
  pdfError.value = errorMessage(err);
51
51
  } finally {
@@ -59,7 +59,7 @@ 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((c) => __tickDep >= 0 && isCallStillPending(c, now));
62
+ return opts.toolCallHistory.value.filter((entry) => __tickDep >= 0 && isCallStillPending(entry, now));
63
63
  });
64
64
 
65
65
  function teardown(): void {
@@ -21,16 +21,16 @@ let socket: Socket | null = null;
21
21
 
22
22
  const listeners = new Map<string, Set<Callback>>();
23
23
 
24
- function resendSubscriptions(s: Socket): void {
24
+ function resendSubscriptions(sock: Socket): void {
25
25
  for (const channel of listeners.keys()) {
26
- s.emit("subscribe", channel);
26
+ sock.emit("subscribe", channel);
27
27
  }
28
28
  }
29
29
 
30
30
  function connect(): Socket {
31
31
  if (socket) return socket;
32
32
 
33
- const s = io({
33
+ const sock = io({
34
34
  path: "/ws/pubsub",
35
35
  // Match the server. Long-polling is fine as a fallback but
36
36
  // the server refuses it, so don't negotiate it here either —
@@ -38,17 +38,17 @@ function connect(): Socket {
38
38
  transports: ["websocket"],
39
39
  });
40
40
 
41
- s.on("connect", () => resendSubscriptions(s));
41
+ sock.on("connect", () => resendSubscriptions(sock));
42
42
 
43
- s.on("data", (msg: PubSubMessage) => {
43
+ sock.on("data", (msg: PubSubMessage) => {
44
44
  const cbs = listeners.get(msg.channel);
45
45
  if (cbs) {
46
- for (const cb of cbs) cb(msg.data);
46
+ for (const handler of cbs) handler(msg.data);
47
47
  }
48
48
  });
49
49
 
50
- socket = s;
51
- return s;
50
+ socket = sock;
51
+ return sock;
52
52
  }
53
53
 
54
54
  function maybeDisconnect(): void {
@@ -63,8 +63,8 @@ export function usePubSub() {
63
63
  if (!listeners.has(channel)) listeners.set(channel, new Set());
64
64
  listeners.get(channel)!.add(callback);
65
65
 
66
- const s = connect();
67
- if (s.connected) s.emit("subscribe", channel);
66
+ const sock = connect();
67
+ if (sock.connected) sock.emit("subscribe", channel);
68
68
  // If not yet connected, the "connect" handler replays every
69
69
  // listener's subscription, so newly-added channels are
70
70
  // covered without extra bookkeeping.
@@ -17,7 +17,7 @@ export function useRoles(): {
17
17
  } {
18
18
  const roles = ref<Role[]>(ROLES);
19
19
  const currentRoleId = ref(ROLES[0].id);
20
- const currentRole = computed(() => roles.value.find((r) => r.id === currentRoleId.value) ?? roles.value[0]);
20
+ const currentRole = computed(() => roles.value.find((role) => role.id === currentRoleId.value) ?? roles.value[0]);
21
21
 
22
22
  async function refreshRoles(): Promise<void> {
23
23
  const result = await apiGet<Role[]>(API_ROUTES.roles.list);
@@ -29,7 +29,7 @@ function isSandboxStatus(raw: RawResponse): raw is {
29
29
  } {
30
30
  if (typeof raw.sshAgent !== "boolean") return false;
31
31
  if (!Array.isArray(raw.mounts)) return false;
32
- return raw.mounts.every((m) => typeof m === "string");
32
+ return raw.mounts.every((mount) => typeof mount === "string");
33
33
  }
34
34
 
35
35
  export interface UseSandboxStatusHandle {
@@ -16,7 +16,7 @@ export function useSessionDerived(opts: { sessionMap: Map<string, ActiveSession>
16
16
 
17
17
  const sidebarResults = computed(() => deduplicateResults(toolResults.value));
18
18
 
19
- const currentSummary = computed(() => sessions.value.find((s) => s.id === currentSessionId.value));
19
+ const currentSummary = computed(() => sessions.value.find((summary) => summary.id === currentSessionId.value));
20
20
 
21
21
  // The server-side summary already merges pendingGenerations into
22
22
  // `isRunning` (see server/api/routes/sessions.ts), but pub/sub events
@@ -33,9 +33,9 @@ export function useSessionDerived(opts: { sessionMap: Map<string, ActiveSession>
33
33
 
34
34
  const toolCallHistory = computed<ToolCallHistoryItem[]>(() => activeSession.value?.toolCallHistory ?? []);
35
35
 
36
- const activeSessionCount = computed(() => sessions.value.filter((s) => s.isRunning).length);
36
+ const activeSessionCount = computed(() => sessions.value.filter((session) => session.isRunning).length);
37
37
 
38
- const unreadCount = computed(() => sessions.value.filter((s) => s.hasUnread).length);
38
+ const unreadCount = computed(() => sessions.value.filter((session) => session.hasUnread).length);
39
39
 
40
40
  return {
41
41
  activeSession,