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.
- package/bin/mulmoclaude.js +7 -24
- package/client/assets/html2canvas-Cx501zZr-Cv5snK9D.js +5 -0
- package/client/assets/index-CubzmCVK.css +2 -0
- package/client/assets/{index-eHWB79u5.js → index-DtcyExH9.js} +80 -61
- package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-DnizuhIY.js} +5 -5
- package/client/index.html +2 -4
- package/package.json +13 -13
- package/server/agent/attachmentConverter.ts +2 -2
- package/server/agent/index.ts +9 -3
- package/server/agent/mcp-tools/index.ts +6 -6
- package/server/agent/mcp-tools/x.ts +2 -1
- package/server/agent/prompt.ts +187 -26
- package/server/agent/resumeFailover.ts +5 -5
- package/server/agent/sandboxMounts.ts +3 -3
- package/server/api/auth/bearerAuth.ts +3 -3
- package/server/api/auth/token.ts +2 -2
- package/server/api/routes/agent.ts +21 -3
- package/server/api/routes/config.ts +1 -1
- package/server/api/routes/files.ts +13 -12
- package/server/api/routes/html.ts +2 -2
- package/server/api/routes/image.ts +7 -7
- package/server/api/routes/mulmo-script.ts +33 -31
- package/server/api/routes/pdf.ts +2 -2
- package/server/api/routes/plugins.ts +16 -6
- package/server/api/routes/roles.ts +2 -2
- package/server/api/routes/scheduler.ts +8 -6
- package/server/api/routes/schedulerTasks.ts +5 -3
- package/server/api/routes/sessions.ts +2 -2
- package/server/api/routes/sessionsCursor.ts +4 -4
- package/server/api/routes/skills.ts +5 -5
- package/server/api/routes/sources.ts +3 -3
- package/server/api/routes/todosHandlers.ts +1 -1
- package/server/api/routes/todosItemsHandlers.ts +14 -14
- package/server/api/routes/wiki.ts +22 -8
- package/server/api/sandboxStatus.ts +1 -1
- package/server/events/notifications.ts +6 -6
- package/server/events/pub-sub/index.ts +3 -3
- package/server/events/relay-client.ts +17 -16
- package/server/index.ts +40 -46
- package/server/system/config.ts +5 -5
- package/server/system/credentials.ts +7 -5
- package/server/system/env.ts +5 -5
- package/server/utils/files/atomic.ts +11 -11
- package/server/utils/files/image-store.ts +17 -6
- package/server/utils/files/journal-io.ts +2 -2
- package/server/utils/files/json.ts +5 -5
- package/server/utils/files/markdown-store.ts +4 -4
- package/server/utils/files/reference-dirs-io.ts +3 -3
- package/server/utils/files/roles-io.ts +4 -4
- package/server/utils/files/safe.ts +14 -14
- package/server/utils/files/scheduler-overrides-io.ts +2 -2
- package/server/utils/files/spreadsheet-store.ts +5 -5
- package/server/utils/files/workspace-io.ts +12 -12
- package/server/utils/gemini.ts +2 -2
- package/server/utils/gitignore.ts +9 -9
- package/server/utils/json.ts +5 -5
- package/server/utils/logBackgroundError.ts +12 -3
- package/server/utils/markdown.ts +5 -5
- package/server/utils/port.d.mts +6 -0
- package/server/utils/port.mjs +48 -0
- package/server/utils/request.ts +12 -6
- package/server/utils/spawn.ts +1 -1
- package/server/utils/types.ts +2 -2
- package/server/workspace/chat-index/summarizer.ts +4 -4
- package/server/workspace/custom-dirs.ts +5 -5
- package/server/workspace/journal/diff.ts +2 -2
- package/server/workspace/journal/index.ts +4 -4
- package/server/workspace/journal/optimizationPass.ts +2 -2
- package/server/workspace/journal/state.ts +6 -6
- package/server/workspace/paths.ts +3 -3
- package/server/workspace/reference-dirs.ts +3 -3
- package/server/workspace/skills/parser.ts +6 -6
- package/server/workspace/skills/scheduler.ts +3 -3
- package/server/workspace/skills/writer.ts +3 -3
- package/server/workspace/sources/arxivDiscovery.ts +2 -2
- package/server/workspace/sources/fetchers/rss.ts +5 -5
- package/server/workspace/sources/fetchers/rssParser.ts +4 -4
- package/server/workspace/sources/interests.ts +3 -3
- package/server/workspace/sources/paths.ts +6 -6
- package/server/workspace/sources/pipeline/fetch.ts +36 -13
- package/server/workspace/sources/pipeline/index.ts +2 -7
- package/server/workspace/sources/pipeline/notify.ts +3 -3
- package/server/workspace/sources/pipeline/plan.ts +11 -9
- package/server/workspace/sources/pipeline/write.ts +5 -5
- package/server/workspace/sources/rateLimiter.ts +1 -1
- package/server/workspace/sources/sourceState.ts +9 -4
- package/server/workspace/sources/types.ts +9 -0
- package/server/workspace/sources/urls.ts +1 -1
- package/server/workspace/tool-trace/classify.ts +4 -4
- package/server/workspace/workspace.ts +7 -7
- package/src/App.vue +286 -112
- package/src/components/CanvasViewToggle.vue +10 -7
- package/src/components/ChatInput.vue +60 -26
- package/src/components/FileContentHeader.vue +7 -4
- package/src/components/FileContentRenderer.vue +20 -6
- package/src/components/FileTree.vue +6 -3
- package/src/components/FileTreePane.vue +11 -8
- package/src/components/FilesView.vue +5 -3
- package/src/components/LockStatusPopup.vue +15 -12
- package/src/components/NotificationBell.vue +14 -5
- package/src/components/NotificationToast.vue +4 -1
- package/src/components/PluginLauncher.vue +19 -56
- package/src/components/RightSidebar.vue +13 -10
- package/src/components/SessionHistoryPanel.vue +33 -29
- package/src/components/SessionTabBar.vue +8 -10
- package/src/components/SettingsMcpTab.vue +43 -30
- package/src/components/SettingsModal.vue +21 -19
- package/src/components/SettingsReferenceDirsTab.vue +29 -24
- package/src/components/SettingsWorkspaceDirsTab.vue +32 -22
- package/src/components/SidebarHeader.vue +25 -4
- package/src/components/StackView.vue +4 -1
- package/src/components/SuggestionsPanel.vue +5 -2
- package/src/components/TodoExplorer.vue +26 -15
- package/src/components/ToolResultsPanel.vue +27 -13
- package/src/components/todo/TodoAddDialog.vue +17 -12
- package/src/components/todo/TodoEditDialog.vue +7 -2
- package/src/components/todo/TodoEditPanel.vue +15 -10
- package/src/components/todo/TodoKanbanView.vue +10 -5
- package/src/components/todo/TodoListView.vue +5 -2
- package/src/components/todo/TodoTableView.vue +5 -2
- package/src/composables/useAppApi.ts +9 -0
- package/src/composables/useDynamicFavicon.ts +172 -37
- package/src/composables/useEventListeners.ts +7 -8
- package/src/composables/useFaviconState.ts +13 -2
- package/src/composables/useFileSelection.ts +24 -6
- package/src/composables/useLayoutMode.ts +32 -0
- package/src/composables/useSessionHistory.ts +7 -17
- package/src/composables/useViewLayout.ts +20 -34
- package/src/lang/de.ts +536 -0
- package/src/lang/en.ts +558 -0
- package/src/lang/es.ts +543 -0
- package/src/lang/fr.ts +536 -0
- package/src/lang/ja.ts +536 -0
- package/src/lang/ko.ts +540 -0
- package/src/lang/pt-BR.ts +534 -0
- package/src/lang/zh.ts +537 -0
- package/src/lib/vue-i18n.ts +97 -0
- package/src/main.ts +2 -0
- package/src/plugins/canvas/View.vue +102 -186
- package/src/plugins/canvas/definition.ts +0 -8
- package/src/plugins/chart/Preview.vue +1 -1
- package/src/plugins/chart/View.vue +9 -4
- package/src/plugins/manageRoles/Preview.vue +4 -1
- package/src/plugins/manageRoles/View.vue +59 -43
- package/src/plugins/manageSkills/Preview.vue +8 -3
- package/src/plugins/manageSkills/View.vue +26 -22
- package/src/plugins/manageSource/Preview.vue +1 -1
- package/src/plugins/manageSource/View.vue +73 -52
- package/src/plugins/markdown/Preview.vue +1 -1
- package/src/plugins/markdown/View.vue +24 -34
- package/src/plugins/presentHtml/Preview.vue +1 -1
- package/src/plugins/presentHtml/View.vue +7 -4
- package/src/plugins/presentMulmoScript/Preview.vue +1 -1
- package/src/plugins/presentMulmoScript/View.vue +36 -26
- package/src/plugins/scheduler/Preview.vue +7 -4
- package/src/plugins/scheduler/TasksTab.vue +53 -24
- package/src/plugins/scheduler/View.vue +28 -19
- package/src/plugins/scheduler/formatSchedule.ts +93 -0
- package/src/plugins/spreadsheet/Preview.vue +8 -3
- package/src/plugins/spreadsheet/View.vue +21 -12
- package/src/plugins/textResponse/Preview.vue +15 -58
- package/src/plugins/textResponse/View.vue +27 -7
- package/src/plugins/todo/Preview.vue +11 -6
- package/src/plugins/todo/View.vue +27 -13
- package/src/plugins/ui-image/ImagePreview.vue +6 -3
- package/src/plugins/ui-image/ImageView.vue +7 -4
- package/src/plugins/wiki/Preview.vue +5 -2
- package/src/plugins/wiki/View.vue +202 -81
- package/src/plugins/wiki/route.ts +112 -0
- package/src/router/guards.ts +42 -24
- package/src/router/index.ts +41 -26
- package/src/types/vue-i18n.d.ts +20 -0
- package/src/utils/agent/request.ts +19 -0
- package/src/utils/canvas/layoutMode.ts +26 -0
- package/src/utils/image/cacheBust.ts +16 -0
- package/src/utils/image/resolve.ts +16 -0
- package/src/utils/path/workspaceLinkRouter.ts +81 -0
- package/src/vite-env.d.ts +9 -0
- package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
- package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
- package/client/assets/index-Bm70FDU2.css +0 -1
- package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
- package/src/composables/useCanvasViewMode.ts +0 -121
- package/src/utils/canvas/viewMode.ts +0 -46
- /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
|
-
//
|
|
4
|
-
//
|
|
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 = "#
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
73
|
-
ctx.lineWidth =
|
|
74
|
-
drawRoundedRect(ctx,
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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(
|
|
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 (
|
|
3
|
-
//
|
|
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
|
|
10
|
-
//
|
|
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
|
-
|
|
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
|
|
40
|
-
const selectedPath = ref<string | null>(isValidFilePath(
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
1
|
+
// Composable for the session-history view at `/history`.
|
|
2
2
|
//
|
|
3
|
-
// Owns the `sessions` list (what the server knows about)
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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 {
|
|
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
|
-
|
|
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(() => (
|
|
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
|
}
|