mulmoclaude 0.3.0 → 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 (185) 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-eHWB79u5.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/index.ts +9 -3
  10. package/server/agent/mcp-tools/index.ts +6 -6
  11. package/server/agent/mcp-tools/x.ts +2 -1
  12. package/server/agent/prompt.ts +187 -26
  13. package/server/agent/resumeFailover.ts +5 -5
  14. package/server/agent/sandboxMounts.ts +3 -3
  15. package/server/api/auth/bearerAuth.ts +3 -3
  16. package/server/api/auth/token.ts +2 -2
  17. package/server/api/routes/agent.ts +21 -3
  18. package/server/api/routes/config.ts +1 -1
  19. package/server/api/routes/files.ts +13 -12
  20. package/server/api/routes/html.ts +2 -2
  21. package/server/api/routes/image.ts +7 -7
  22. package/server/api/routes/mulmo-script.ts +33 -31
  23. package/server/api/routes/pdf.ts +2 -2
  24. package/server/api/routes/plugins.ts +16 -6
  25. package/server/api/routes/roles.ts +2 -2
  26. package/server/api/routes/scheduler.ts +8 -6
  27. package/server/api/routes/schedulerTasks.ts +5 -3
  28. package/server/api/routes/sessions.ts +2 -2
  29. package/server/api/routes/sessionsCursor.ts +4 -4
  30. package/server/api/routes/skills.ts +5 -5
  31. package/server/api/routes/sources.ts +3 -3
  32. package/server/api/routes/todosHandlers.ts +1 -1
  33. package/server/api/routes/todosItemsHandlers.ts +14 -14
  34. package/server/api/routes/wiki.ts +22 -8
  35. package/server/api/sandboxStatus.ts +1 -1
  36. package/server/events/notifications.ts +6 -6
  37. package/server/events/pub-sub/index.ts +3 -3
  38. package/server/events/relay-client.ts +17 -16
  39. package/server/index.ts +40 -46
  40. package/server/system/config.ts +5 -5
  41. package/server/system/credentials.ts +7 -5
  42. package/server/system/env.ts +5 -5
  43. package/server/utils/files/atomic.ts +11 -11
  44. package/server/utils/files/image-store.ts +17 -6
  45. package/server/utils/files/journal-io.ts +2 -2
  46. package/server/utils/files/json.ts +5 -5
  47. package/server/utils/files/markdown-store.ts +4 -4
  48. package/server/utils/files/reference-dirs-io.ts +3 -3
  49. package/server/utils/files/roles-io.ts +4 -4
  50. package/server/utils/files/safe.ts +14 -14
  51. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  52. package/server/utils/files/spreadsheet-store.ts +5 -5
  53. package/server/utils/files/workspace-io.ts +12 -12
  54. package/server/utils/gemini.ts +2 -2
  55. package/server/utils/gitignore.ts +9 -9
  56. package/server/utils/json.ts +5 -5
  57. package/server/utils/logBackgroundError.ts +12 -3
  58. package/server/utils/markdown.ts +5 -5
  59. package/server/utils/port.d.mts +6 -0
  60. package/server/utils/port.mjs +48 -0
  61. package/server/utils/request.ts +12 -6
  62. package/server/utils/spawn.ts +1 -1
  63. package/server/utils/types.ts +2 -2
  64. package/server/workspace/chat-index/summarizer.ts +4 -4
  65. package/server/workspace/custom-dirs.ts +5 -5
  66. package/server/workspace/journal/diff.ts +2 -2
  67. package/server/workspace/journal/index.ts +4 -4
  68. package/server/workspace/journal/optimizationPass.ts +2 -2
  69. package/server/workspace/journal/state.ts +6 -6
  70. package/server/workspace/paths.ts +3 -3
  71. package/server/workspace/reference-dirs.ts +3 -3
  72. package/server/workspace/skills/parser.ts +6 -6
  73. package/server/workspace/skills/scheduler.ts +3 -3
  74. package/server/workspace/skills/writer.ts +3 -3
  75. package/server/workspace/sources/arxivDiscovery.ts +2 -2
  76. package/server/workspace/sources/fetchers/rss.ts +5 -5
  77. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  78. package/server/workspace/sources/interests.ts +3 -3
  79. package/server/workspace/sources/paths.ts +6 -6
  80. package/server/workspace/sources/pipeline/fetch.ts +36 -13
  81. package/server/workspace/sources/pipeline/index.ts +2 -7
  82. package/server/workspace/sources/pipeline/notify.ts +3 -3
  83. package/server/workspace/sources/pipeline/plan.ts +11 -9
  84. package/server/workspace/sources/pipeline/write.ts +5 -5
  85. package/server/workspace/sources/rateLimiter.ts +1 -1
  86. package/server/workspace/sources/sourceState.ts +9 -4
  87. package/server/workspace/sources/types.ts +9 -0
  88. package/server/workspace/sources/urls.ts +1 -1
  89. package/server/workspace/tool-trace/classify.ts +4 -4
  90. package/server/workspace/workspace.ts +7 -7
  91. package/src/App.vue +286 -112
  92. package/src/components/CanvasViewToggle.vue +10 -7
  93. package/src/components/ChatInput.vue +60 -26
  94. package/src/components/FileContentHeader.vue +7 -4
  95. package/src/components/FileContentRenderer.vue +20 -6
  96. package/src/components/FileTree.vue +6 -3
  97. package/src/components/FileTreePane.vue +11 -8
  98. package/src/components/FilesView.vue +5 -3
  99. package/src/components/LockStatusPopup.vue +15 -12
  100. package/src/components/NotificationBell.vue +14 -5
  101. package/src/components/NotificationToast.vue +4 -1
  102. package/src/components/PluginLauncher.vue +19 -56
  103. package/src/components/RightSidebar.vue +13 -10
  104. package/src/components/SessionHistoryPanel.vue +33 -29
  105. package/src/components/SessionTabBar.vue +8 -10
  106. package/src/components/SettingsMcpTab.vue +43 -30
  107. package/src/components/SettingsModal.vue +21 -19
  108. package/src/components/SettingsReferenceDirsTab.vue +29 -24
  109. package/src/components/SettingsWorkspaceDirsTab.vue +32 -22
  110. package/src/components/SidebarHeader.vue +25 -4
  111. package/src/components/StackView.vue +4 -1
  112. package/src/components/SuggestionsPanel.vue +5 -2
  113. package/src/components/TodoExplorer.vue +26 -15
  114. package/src/components/ToolResultsPanel.vue +27 -13
  115. package/src/components/todo/TodoAddDialog.vue +17 -12
  116. package/src/components/todo/TodoEditDialog.vue +7 -2
  117. package/src/components/todo/TodoEditPanel.vue +15 -10
  118. package/src/components/todo/TodoKanbanView.vue +10 -5
  119. package/src/components/todo/TodoListView.vue +5 -2
  120. package/src/components/todo/TodoTableView.vue +5 -2
  121. package/src/composables/useAppApi.ts +9 -0
  122. package/src/composables/useDynamicFavicon.ts +172 -37
  123. package/src/composables/useEventListeners.ts +7 -8
  124. package/src/composables/useFaviconState.ts +13 -2
  125. package/src/composables/useFileSelection.ts +24 -6
  126. package/src/composables/useLayoutMode.ts +32 -0
  127. package/src/composables/useSessionHistory.ts +7 -17
  128. package/src/composables/useViewLayout.ts +20 -34
  129. package/src/lang/de.ts +536 -0
  130. package/src/lang/en.ts +558 -0
  131. package/src/lang/es.ts +543 -0
  132. package/src/lang/fr.ts +536 -0
  133. package/src/lang/ja.ts +536 -0
  134. package/src/lang/ko.ts +540 -0
  135. package/src/lang/pt-BR.ts +534 -0
  136. package/src/lang/zh.ts +537 -0
  137. package/src/lib/vue-i18n.ts +97 -0
  138. package/src/main.ts +2 -0
  139. package/src/plugins/canvas/View.vue +102 -186
  140. package/src/plugins/canvas/definition.ts +0 -8
  141. package/src/plugins/chart/Preview.vue +1 -1
  142. package/src/plugins/chart/View.vue +9 -4
  143. package/src/plugins/manageRoles/Preview.vue +4 -1
  144. package/src/plugins/manageRoles/View.vue +59 -43
  145. package/src/plugins/manageSkills/Preview.vue +8 -3
  146. package/src/plugins/manageSkills/View.vue +26 -22
  147. package/src/plugins/manageSource/Preview.vue +1 -1
  148. package/src/plugins/manageSource/View.vue +73 -52
  149. package/src/plugins/markdown/Preview.vue +1 -1
  150. package/src/plugins/markdown/View.vue +24 -34
  151. package/src/plugins/presentHtml/Preview.vue +1 -1
  152. package/src/plugins/presentHtml/View.vue +7 -4
  153. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  154. package/src/plugins/presentMulmoScript/View.vue +36 -26
  155. package/src/plugins/scheduler/Preview.vue +7 -4
  156. package/src/plugins/scheduler/TasksTab.vue +53 -24
  157. package/src/plugins/scheduler/View.vue +28 -19
  158. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  159. package/src/plugins/spreadsheet/Preview.vue +8 -3
  160. package/src/plugins/spreadsheet/View.vue +21 -12
  161. package/src/plugins/textResponse/Preview.vue +15 -58
  162. package/src/plugins/textResponse/View.vue +27 -7
  163. package/src/plugins/todo/Preview.vue +11 -6
  164. package/src/plugins/todo/View.vue +27 -13
  165. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  166. package/src/plugins/ui-image/ImageView.vue +7 -4
  167. package/src/plugins/wiki/Preview.vue +5 -2
  168. package/src/plugins/wiki/View.vue +202 -81
  169. package/src/plugins/wiki/route.ts +112 -0
  170. package/src/router/guards.ts +42 -24
  171. package/src/router/index.ts +41 -26
  172. package/src/types/vue-i18n.d.ts +20 -0
  173. package/src/utils/agent/request.ts +19 -0
  174. package/src/utils/canvas/layoutMode.ts +26 -0
  175. package/src/utils/image/cacheBust.ts +16 -0
  176. package/src/utils/image/resolve.ts +16 -0
  177. package/src/utils/path/workspaceLinkRouter.ts +81 -0
  178. package/src/vite-env.d.ts +9 -0
  179. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  180. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  181. package/client/assets/index-Bm70FDU2.css +0 -1
  182. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  183. package/src/composables/useCanvasViewMode.ts +0 -121
  184. package/src/utils/canvas/viewMode.ts +0 -46
  185. /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
  }
@@ -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
+ }
@@ -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/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
  }
@@ -1,44 +1,31 @@
1
- // View-layout state: tracks whether the app is in a "stack" layout
2
- // (full-width canvas) or "single" (sidebar + canvas), remembers the
3
- // last chat-oriented view mode, and derives displayedCurrentSessionId
4
- // (blank in plugin views so no tab appears "current").
5
-
6
- import { computed, ref, watch, type ComputedRef, type Ref } from "vue";
7
- import { CANVAS_VIEW, type CanvasViewMode } from "../utils/canvas/viewMode";
8
-
9
- const CHAT_VIEWS = [CANVAS_VIEW.single, CANVAS_VIEW.stack] as const;
10
- type ChatViewMode = (typeof CHAT_VIEWS)[number];
11
-
12
- function isChatView(mode: string): mode is ChatViewMode {
13
- return (CHAT_VIEWS as readonly string[]).includes(mode);
14
- }
1
+ // View-layout state. Derives two values that the template cares about:
2
+ //
3
+ // - isStackLayout: true whenever the canvas column should be full-width
4
+ // (no sidebar). This is the case for /chat in stack mode AND for
5
+ // every non-chat page (/files, /todos, /wiki, etc.). Only /chat in
6
+ // single mode shows the left sidebar.
7
+ //
8
+ // - displayedCurrentSessionId: blank on non-chat pages so no session
9
+ // tab appears "current" while the user is on Files, Todos, etc.
10
+ //
11
+ // Also flips activePane between "sidebar" and "main" so arrow-key
12
+ // navigation follows whichever side of the layout is visible.
13
+
14
+ import { computed, watch, type ComputedRef, type Ref } from "vue";
15
+ import { LAYOUT_MODES, type LayoutMode } from "../utils/canvas/layoutMode";
15
16
 
16
17
  export function useViewLayout(opts: {
17
- canvasViewMode: Ref<CanvasViewMode> | ComputedRef<CanvasViewMode>;
18
- setCanvasViewMode: (mode: CanvasViewMode) => void;
18
+ layoutMode: Ref<LayoutMode> | ComputedRef<LayoutMode>;
19
+ isChatPage: Ref<boolean> | ComputedRef<boolean>;
19
20
  currentSessionId: Ref<string>;
20
21
  activePane: Ref<"sidebar" | "main">;
21
22
  }) {
22
- const { canvasViewMode, setCanvasViewMode, currentSessionId, activePane } = opts;
23
-
24
- const isStackLayout = computed(() => canvasViewMode.value !== CANVAS_VIEW.single);
25
-
26
- const lastChatViewMode = ref<ChatViewMode>(isChatView(canvasViewMode.value) ? canvasViewMode.value : CANVAS_VIEW.stack);
27
-
28
- watch(canvasViewMode, (mode) => {
29
- if (isChatView(mode)) lastChatViewMode.value = mode;
30
- });
23
+ const { layoutMode, isChatPage, currentSessionId, activePane } = opts;
31
24
 
32
- function restoreChatViewForSession(): void {
33
- if (!isChatView(canvasViewMode.value)) {
34
- setCanvasViewMode(lastChatViewMode.value);
35
- }
36
- }
25
+ const isStackLayout = computed(() => !(isChatPage.value && layoutMode.value === LAYOUT_MODES.single));
37
26
 
38
- const displayedCurrentSessionId = computed(() => (isChatView(canvasViewMode.value) ? currentSessionId.value : ""));
27
+ const displayedCurrentSessionId = computed(() => (isChatPage.value ? currentSessionId.value : ""));
39
28
 
40
- // Keep arrow-key navigation tied to the canvas when the sidebar
41
- // list doesn't exist (Stack layout has no ToolResultsPanel).
42
29
  watch(
43
30
  isStackLayout,
44
31
  (stack) => {
@@ -49,7 +36,6 @@ export function useViewLayout(opts: {
49
36
 
50
37
  return {
51
38
  isStackLayout,
52
- restoreChatViewForSession,
53
39
  displayedCurrentSessionId,
54
40
  };
55
41
  }