Add CatPilot Canvas extension#2359
Conversation
A visual command center canvas for CatPilot: tasks (list + board), journal, milestones, memos, learning, growth, projects, Copilot-generated reports, an activity timeline, interactive settings/migration, a markdown editor on every field, and light/dark themes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cc565b3b-2964-445e-bb79-88466b6a5494
There was a problem hiding this comment.
Pull request overview
Adds CatPilot as a GitHub Copilot Canvas extension with a local storage engine and interactive SPA.
Changes:
- Adds dashboards, task management, reports, timelines, and settings.
- Integrates CatPilot storage and Copilot agent actions.
- Adds extension metadata, documentation, and visual assets.
Reviewed changes
Copilot reviewed 8 out of 12 changed files in this pull request and generated 18 comments.
Show a summary per file
| File | Description |
|---|---|
extensions/catpilot-canvas/extension.mjs |
Canvas server, APIs, and actions |
extensions/catpilot-canvas/catpilot-store.mjs |
CatPilot storage engine |
extensions/catpilot-canvas/ui/app.js |
Canvas application behavior |
extensions/catpilot-canvas/ui/index.html |
Application shell |
extensions/catpilot-canvas/ui/styles.css |
Layout and themes |
extensions/catpilot-canvas/ui/hero.svg |
Vector CatPilot artwork |
extensions/catpilot-canvas/ui/hero.png |
UI CatPilot artwork |
extensions/catpilot-canvas/assets/preview.png |
Marketplace preview |
extensions/catpilot-canvas/README.md |
Extension documentation |
extensions/catpilot-canvas/package.json |
Package metadata |
extensions/catpilot-canvas/package-lock.json |
Locked dependencies |
extensions/catpilot-canvas/.github/plugin/plugin.json |
Plugin manifest |
Files not reviewed (1)
- extensions/catpilot-canvas/package-lock.json: Generated file
| "markdown-editor" | ||
| ], | ||
| "logo": "assets/preview.png", | ||
| "extensions": "." |
| if (p.startsWith("/api/")) { | ||
| try { | ||
| const data = await handleApi(req, url); | ||
| if (data === null) return sendJson(res, 404, { error: "Not found" }); | ||
| return sendJson(res, 200, data); |
| const p = path.join(dir, filename); | ||
| if (!path.resolve(p).startsWith(path.resolve(dir))) throw new Error("Invalid memo path"); |
| const p = path.join(dir, filename); | ||
| if (!path.resolve(p).startsWith(path.resolve(dir))) throw new Error("Invalid note path"); |
| try { | ||
| const { report } = await api(`/api/reports/${encodeURIComponent(filename)}`); | ||
| let body; | ||
| if (report.format === "html") { body = el("iframe", { class: "report-iframe" }); body.srcdoc = report.content; } |
| @media (max-width: 720px) { | ||
| .app { grid-template-columns: 1fr; } | ||
| .sidebar { display: none; } | ||
| } |
| table.tbl tbody tr { transition: background var(--sq); } | ||
| table.tbl tbody tr:hover { background: var(--panel-hover); } | ||
| .row-actions { display: flex; gap: 4px; opacity: 0; transition: opacity var(--sq); justify-content: flex-end; } | ||
| table.tbl tr:hover .row-actions { opacity: 1; } |
| if (plan.needsMigration && (migration === "move" || migration === "copy")) { | ||
| for (const item of plan.items) { | ||
| if (!item.willMove) continue; | ||
| try { migrated += moveOrCopyPath(item.from, item.to, item.isDir, migration); } catch { /* best-effort per item */ } |
| // mutating the active config. | ||
| function resolveDomainPaths(baseDir, root, partitioning) { | ||
| const storageRoot = path.resolve(baseDir, root); | ||
| const partition = getPartitionFolder(partitioning); |
| } | ||
|
|
||
| function resolveFilePath(type, config) { | ||
| const partition = getPartitionFolder(config.storage.partitioning); |
Address the 18 inline review findings on PR github#2359: Security & correctness - Block cross-origin/non-loopback API requests (Host, Origin and Sec-Fetch-Site guards) in extension.mjs. - Harden memo/note reads against path traversal via basename + parent-dir checks; return sanitized filenames. - Escape markdown table cells (pipes/newlines) on write and decode on read so task/milestone fields round-trip safely. - Migration never overwrites: moveOrCopyPath skips existing destinations and reports skipped items; applyConfigChange validates the whole plan and fails before persisting on any error. - Config changes now persist to the resolved config path. Cross-partition aggregation - Dashboard "last 3 days", the activity timeline and generated-report journal now aggregate across every storage partition (day/week/month), skipping dotfolders (.trash/.git/.obsidian). Editable views and counts stay scoped to the current partition to avoid ID collisions. - Add last-7 / last-30 report periods (enum + range handling). Accessibility & UI - Nav items are real <button> elements with aria-current and aria-hidden icons; focus-visible outline and button reset in CSS. - Fix double-escaping of markdown list items. - Sandbox the report preview iframe and give it a title. - Config modal: require a preview before apply, surface skipped items and destination conflicts. - Responsive top nav (horizontal scroll) instead of hiding the sidebar. - Table row actions reveal on focus-within for keyboard users.
|
Thanks for the thorough automated review! I pushed a commit that addresses all of the inline findings. Summary: Security & correctness
Cross-partition aggregation
Accessibility & UI
I re-ran |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 13 changed files in this pull request and generated 11 comments.
Files not reviewed (1)
- extensions/catpilot-canvas/package-lock.json: Generated file
Comments suppressed due to low confidence (2)
extensions/catpilot-canvas/catpilot-store.mjs:84
- Onboarding always writes the global config even when
CATPILOT_CONFIGorCATPILOT_ROOTselects another canonical path. If that selected file does not yet exist,/api/statusremains unconfigured immediately after this write andstartApp()fails. Write to the path returned byresolveConfigPath()so setup creates the config that subsequent loads will actually use.
fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
fs.writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
config.__baseDir = configBaseDir(GLOBAL_CONFIG_PATH);
config.__configPath = GLOBAL_CONFIG_PATH;
extensions/catpilot-canvas/ui/app.js:1051
- Editing a field while the preview request is in flight calls
invalidate(), but the older response later restorespreviewedBodyand enables applying the superseded values. Re-check that the form still matches the submitted body before accepting the response.
try { const plan = await api("/api/config/plan", { method: "POST", body: b }); previewedBody = b; renderPlan(plan); }
| store.writeConfig(await readBody(req)); | ||
| return { ok: true, ...store.configStatus() }; |
There was a problem hiding this comment.
Fixed in cbc8f85. First-run onboarding has no previously-configured source to migrate from, so the Move/Copy choices were genuine no-ops. I removed the migration select from the onboarding form and writeConfig now always records migration mode 'adopt'. Move/Copy migration remains available from Settings, which uses the plan -> preview -> approve -> apply flow.
| function resolveDomainPaths(baseDir, root, partitioning) { | ||
| const storageRoot = path.resolve(baseDir, root); | ||
| const partition = getPartitionFolder(partitioning); | ||
| const out = {}; | ||
| for (const [type, fileName] of Object.entries(DEFAULT_FILES)) { | ||
| out[type] = { path: path.join(storageRoot, partition, fileName), isDir: !/\.[a-z]+$/i.test(fileName) }; |
There was a problem hiding this comment.
Fixed in cbc8f85. resolveDomainPaths now takes an optional files argument and merges DEFAULT_FILES with config.storage.files. All three callers (getConfig and both planConfigChange resolves) plus the root-migration loop now pass config.storage.files, so a customized filename map is honored everywhere.
| const filename = `${todayISO()}_${slugify(params.title)}.md`; | ||
| const p = path.join(dir, filename); | ||
| fs.writeFileSync(p, `# ${params.title}\n\n${params.content || "Add your memo content here."}`, "utf8"); |
There was a problem hiding this comment.
Fixed in cbc8f85. createMemo now routes the target path through a new uniquePath() helper that appends -2, -3... before the extension when a file already exists, and returns the actual basename. Two memos with the same title on the same day no longer overwrite each other.
| const filename = `${todayISO()}_${slugify(params.title)}.md`; | ||
| const p = path.join(dir, filename); | ||
| if (!path.resolve(p).startsWith(path.resolve(dir))) throw new Error("Invalid note path"); | ||
| const frontmatter = { catpilot: DOMAIN_TAG[domain], title: params.title, date: todayISO(), ...(params.frontmatter || {}) }; | ||
| const body = params.body ? `\n${params.body}\n` : "\n"; | ||
| fs.writeFileSync(p, `${toFrontmatter(frontmatter)}\n\n# ${params.title}\n${body}`, "utf8"); |
There was a problem hiding this comment.
Fixed in cbc8f85. addNote (learning/growth/projects) uses the same uniquePath() helper and returns the real basename, so a same-title note on the same day is preserved instead of overwritten.
| const filename = `report-${nowStamp()}${slug ? "-" + slug : ""}.${ext}`; | ||
| let content = body || ""; | ||
| if (ext === "md" && title && !/^#\s/m.test(content)) content = `# ${title}\n\n${content}`; | ||
| fs.writeFileSync(path.join(dir, filename), content, "utf8"); |
There was a problem hiding this comment.
Fixed in cbc8f85. saveReport (and therefore generateReport, which delegates to it) now allocates a unique filename via uniquePath() and returns the final basename + date, so two reports generated in the same minute no longer collide.
| try { | ||
| await VIEWS[view](content); | ||
| } catch (err) { | ||
| content.innerHTML = ""; | ||
| content.append(errorBox(err)); |
There was a problem hiding this comment.
Fixed in cbc8f85. go() now increments a navToken per navigation and builds the view into detached staging nodes (content + view-actions), committing to the DOM only when the token is still current. A slow view that resolves after the user has navigated away is discarded instead of painting into the newer view.
| }; | ||
|
|
||
| // ---------------------------------------------------------------- reports | ||
| const PERIODS = [["this-week", "This week"], ["last-week", "Last week"], ["this-month", "This month"], ["last-month", "Last month"], ["last-7", "Last 7 days"], ["last-30", "Last 30 days"]]; |
There was a problem hiding this comment.
Fixed in cbc8f85. Added an 'All time' entry to the PERIODS array. The backend periodRange and the report action's period enum already accepted 'all'; the UI now exposes it.
| const backdrop = el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target === backdrop) closeModal(); } }); | ||
| const modal = el("div", { class: "modal" }); | ||
| if (width) modal.style.width = `min(${width}px, 100%)`; | ||
| modal.append( | ||
| el("div", { class: "modal-head" }, | ||
| el("h3", { text: title }), | ||
| el("button", { class: "icon-btn", text: "✕", onclick: closeModal })), |
There was a problem hiding this comment.
Fixed in cbc8f85. openModal now sets role=dialog, aria-modal=true and aria-labelledby (associated with the title), traps Tab within the dialog, moves focus to the first focusable element on open, and restores focus to the previously-focused element on close.
| function taskRow(t) { | ||
| const done = t.status.toLowerCase() === "done"; | ||
| const tr = el("tr", { class: done ? "done-row" : "", dataset: { taskTitle: t.title } }); | ||
| const check = el("input", { type: "checkbox", style: "width:auto", ...(done ? { checked: "checked" } : {}) }); |
There was a problem hiding this comment.
Fixed in cbc8f85. The task row checkbox now gets an aria-label (Complete <title> / Reopen <title>) so it has an accessible name.
| async function ensureServer() { | ||
| if (sharedServer) return sharedServer; | ||
| const server = createServer((req, res) => { | ||
| requestListener(req, res).catch(() => { try { res.writeHead(500); res.end(); } catch { /* */ } }); | ||
| }); | ||
| await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); | ||
| const port = server.address().port; | ||
| sharedServer = { server, url: `http://127.0.0.1:${port}/` }; |
There was a problem hiding this comment.
Fixed in cbc8f85. ensureServer() now caches its in-flight init promise; concurrent open() calls await the same promise instead of each binding a new loopback listener. The cached promise is cleared on failure and on server close so a later reopen re-inits cleanly.
- catpilot-store: add uniquePath() so creating a memo, note, or report never silently overwrites an existing file with the same title/timestamp; return the actual (possibly suffixed) basename to callers - catpilot-store: bound timeline milestone events to <= today so far-future target dates don't leak into "recent activity" - catpilot-store: resolveDomainPaths now honors config.storage.files instead of always using DEFAULT_FILES (config, plan preview, and migration loop) - catpilot-store: writeConfig always adopts on first-run onboarding (no prior source to migrate); migration stays available via Settings plan/apply - extension.mjs: cache ensureServer() init promise so concurrent opens share one loopback server instead of racing to bind two - ui: onboarding form drops the no-op migration select - ui/app.js: navigation token guard + detached staging so a slow view can't paint into a newer view; add "All time" report period; modal gets role=dialog/aria-modal/labelledby + focus trap and restore; task checkbox gets an accessible name
|
Thanks for the thorough second pass. All 11 findings are addressed in cbc8f85: Data safety (no silent overwrites)
Concurrency
Onboarding / a11y
Each thread has a specific reply. Validated at runtime: duplicate memo/report creation yields suffixed filenames, |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 13 changed files in this pull request and generated 10 comments.
Files not reviewed (1)
- extensions/catpilot-canvas/package-lock.json: Generated file
Comments suppressed due to low confidence (3)
extensions/catpilot-canvas/ui/app.js:268
- The navigation token protects
stage, butactionsHostremains shared mutable state. If a slow view (for example Tasks) resolves after a newer navigation starts, its post-awaitactionsHost.append(...)writes into the newer view's action stage before the stale token is checked, leaving controls from the wrong view in the top bar. PassactionStageinto each view instead of assigning it globally.
actionsHost = actionStage;
extensions/catpilot-canvas/catpilot-store.mjs:876
- The selected report period is not used when loading tasks or milestones: both paths resolve only the current partition, and all current tasks feed the KPIs. Consequently “last month” and “last week” reports can show the same current totals and omit records from the requested partition, contradicting the per-period reporting behavior. Load/filter records for
since..until, or label these figures explicitly as a current snapshot.
const tasks = parseTasksTable(readFileOrCreate(resolveFilePath("tasks", config)));
const milestones = parseMilestones(readFileOrCreate(resolveFilePath("milestones", config)));
const journal = collectJournal(config);
const { since, until, label } = periodRange(period);
extensions/catpilot-canvas/catpilot-store.mjs:1123
- Migration failures are collected only after earlier items have already been renamed. If a later item fails, the config remains on the old root while successfully moved files exist only under the new root, making that data disappear from CatPilot until a successful retry. This contradicts the claimed non-destructive/consistent migration; preflight and roll back moved items, or use a staged transaction before changing either source or config.
const r = moveOrCopyPath(item.from, item.to, item.isDir, migration);
moved += r.moved;
skipped += r.skipped;
} catch (err) {
errors.push({ type: item.type, from: item.from, to: item.to, error: String(err && err.message || err) });
| const { prompt, timeout } = await readBody(req); | ||
| if (!prompt || !String(prompt).trim()) throw new Error("prompt is required"); | ||
| if (!sessionRef?.sendAndWait) return { ok: false, error: "No active session." }; | ||
| const ev = await sessionRef.sendAndWait(String(prompt), Number(timeout) || 120000); |
| fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true }); | ||
| fs.writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8"); | ||
| config.__baseDir = configBaseDir(GLOBAL_CONFIG_PATH); | ||
| config.__configPath = GLOBAL_CONFIG_PATH; |
| <!-- Modal host --> | ||
| <div id="modal-root"></div> | ||
| <!-- Toasts --> | ||
| <div id="toasts" class="toasts"></div> |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 13 changed files in this pull request and generated 8 comments.
Files not reviewed (1)
- extensions/catpilot-canvas/package-lock.json: Generated file
Comments suppressed due to low confidence (7)
extensions/catpilot-canvas/ui/app.js:542
- Dropping between Overdue and To do is always a no-op: both branches only set
status: "Open", while column membership is derived fromdueDate. The card therefore returns to its original column after reload even though both columns advertise a valid drop target. Either disable those drops or update the due date according to clearly defined rescheduling semantics.
if (col.key === "done") await api(`/api/tasks/${id}/complete`, { method: "POST" });
else await api(`/api/tasks/${id}`, { method: "PUT", body: { status: "Open" } });
extensions/catpilot-canvas/ui/index.html:71
- Async success and error messages are inserted here without a live-region role, so screen-reader users are not notified when saves, deletes, generation, or migration operations finish. Expose the toast host as a polite status region.
<div id="toasts" class="toasts"></div>
extensions/catpilot-canvas/catpilot-store.mjs:87
- Onboarding always writes the global config even when
CATPILOT_CONFIGorCATPILOT_ROOTselected a different missing config path. In that environment setup reports success, but the nextloadConfig()still resolves the missing environment path and the app fails to start. Write to the path returned byresolveConfigPath()so setup and subsequent reads agree.
fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
fs.writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
config.__baseDir = configBaseDir(GLOBAL_CONFIG_PATH);
config.__configPath = GLOBAL_CONFIG_PATH;
extensions/catpilot-canvas/catpilot-store.mjs:1131
- A move migration can partially relocate files before a later item fails; this then leaves the old config active while successfully moved data is no longer at the configured paths. Contrary to the comment above, config and data do drift until the user retries the exact migration. Stage/rollback moves or otherwise make the operation transactional before reporting failure.
if (errors.length) {
// Config is left untouched; already-moved files stay where they landed
// but the source→config mapping is unchanged, so a retry is safe.
throw Object.assign(new Error(`Migration failed for ${errors.length} item(s); configuration was not changed.`), {
extensions/catpilot-canvas/catpilot-store.mjs:1142
- All data moves complete before the config is persisted. If this write fails (permissions, disk full, or an I/O error), the function exits with the old config still active while the files have already moved to the new paths. Include config persistence in the migration transaction or roll the moves back on failure.
const configPath = persistConfig(config);
extensions/catpilot-canvas/catpilot-store.mjs:544
- This recursively treats every matching basename under the configured root as CatPilot data instead of restricting matches to valid partition paths. With the suggested Obsidian-vault root, an unrelated
journal.md,tasks.md, ormemos/elsewhere in the vault is included in timelines/reports and may be moved during a root migration. Enumerate only the supported day/week/month partition layouts before collecting or migrating files.
if (e.name === targetName && ((isDir && e.isDirectory()) || (!isDir && e.isFile()))) {
acc.push(full);
} else if (e.isDirectory() && !e.name.startsWith(".")) {
// Skip dotfolders (.trash, .git, .obsidian, …) — they are tooling
// state, not CatPilot partitions.
extensions/catpilot-canvas/catpilot-store.mjs:876
- The selected reporting period is not applied to tasks: this always loads the current partition and computes all task/completion KPIs from it. For example, a “Last month” report generated this month reports this month's tasks as though they were tracked last month. Load the relevant partition(s) and filter by task lifecycle dates, or stop presenting these task metrics as period-specific until the format records those dates.
const tasks = parseTasksTable(readFileOrCreate(resolveFilePath("tasks", config)));
const milestones = parseMilestones(readFileOrCreate(resolveFilePath("milestones", config)));
const journal = collectJournal(config);
const { since, until, label } = periodRange(period);
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 13 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- extensions/catpilot-canvas/package-lock.json: Generated file
Comments suppressed due to low confidence (11)
extensions/catpilot-canvas/catpilot-store.mjs:87
- Onboarding always writes the global config, but config resolution gives
CATPILOT_CONFIGandCATPILOT_ROOTprecedence. If either variable points to a not-yet-created config, setup reports success while subsequent requests still resolve the missing env-selected path and the app immediately fails as unconfigured. Write to the path returned byresolveConfigPath()so onboarding creates the configuration it will actually load.
fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
fs.writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
config.__baseDir = configBaseDir(GLOBAL_CONFIG_PATH);
config.__configPath = GLOBAL_CONFIG_PATH;
extensions/catpilot-canvas/ui/app.js:543
- Dropping between Overdue and To do cannot change columns because both branches only set
status: "Open", while column membership is determined by the unchanged due date. For example, an overdue task dropped on To do immediately renders back under Overdue. Disable those invalid drop targets or update/clear the due date according to an explicit user choice.
const id = e.dataTransfer.getData("text/plain");
try {
if (col.key === "done") await api(`/api/tasks/${id}/complete`, { method: "POST" });
else await api(`/api/tasks/${id}`, { method: "PUT", body: { status: "Open" } });
await refreshSummary(); go("tasks");
extensions/catpilot-canvas/catpilot-store.mjs:1075
- Move mode relies exclusively on
renameSync, which throwsEXDEVwhen the new storage root is on another drive or filesystem—a common reason to change storage roots. The repository already handles this case by falling back to copy-and-remove ineng/materialize-plugins.mjs:27-42; use equivalent cross-device handling here so migration to an external volume works.
function moveOrCopyPath(src, dest, isDir, mode) {
if (!fs.existsSync(src)) return { moved: 0, skipped: 0 };
if (isDir) {
ensureDir(dest);
extensions/catpilot-canvas/ui/index.html:71
- Toast messages are the only feedback for many save, error, and agent operations, but this dynamically updated container is not a live region, so screen-reader users receive no status announcement. Mark it as a polite status region (and consider assertive announcements for errors).
<div id="toasts" class="toasts"></div>
extensions/catpilot-canvas/ui/app.js:694
- Each memo card is the only control that opens its content, but it is a non-focusable
divwith a click handler. Keyboard users cannot reach or activate memo details. Render this as a semantic button/link, or add button semantics, focusability, and Enter/Space handling with a visible focus style.
memos.forEach((m) => grid.append(el("div", { class: "card note-card hoverable", onclick: () => memoDetail(m.filename) },
el("h4", { text: m.title }),
el("div", { class: "note-foot" }, el("span", { class: "tag", text: m.date || "" }), el("span", { class: "muted small", text: "📄 memo" })))));
extensions/catpilot-canvas/ui/app.js:742
- Learning, growth, and project cards are non-focusable
divelements whose details are available only throughonclick, leaving keyboard users unable to open the note body. Use a semantic interactive element or provide button role, tabindex, Enter/Space activation, and visible focus styling.
grid.append(el("div", { class: "card note-card hoverable", onclick: () => domainNoteDetail(domain, n.filename) },
el("h4", { text: fm.title || n.filename }),
domainChips(domain, fm),
el("div", { class: "note-foot" }, el("span", { class: "tag", text: fm.date || "" }))));
extensions/catpilot-canvas/ui/app.js:1003
- Report cards expose the report reader only through a click handler on a non-focusable
div, so keyboard users cannot open generated reports. Make each card a semantic button/link, or add complete keyboard button behavior and a visible focus indicator.
reports.forEach((r) => grid.append(el("div", { class: "card report-card hoverable", onclick: () => reportDetail(r.filename) },
el("div", { class: "report-ic", text: r.format === "html" ? "🌐" : "📄" }),
el("h4", { text: r.title }),
el("div", { class: "note-foot" }, el("span", { class: "tag", text: r.date || "" }), el("span", { class: "tag", text: r.format || "md" })))));
extensions/catpilot-canvas/catpilot-store.mjs:166
- This escaping is not compatible with the linked CatPilot storage reader:
tanure/cat-copilot/lib/cli-utils.jsparses task rows with plainline.split('|'), so a value written asfoo \| baris still split into two cells and shifts the due date, priority, and remaining columns when read by the CLI/MCP surfaces. Either reject/encode these characters in a representation understood by every CatPilot reader or update the shared parser before allowing them here.
function encodeCell(v) {
return String(v == null ? "" : v).replace(/\r?\n/g, "<br>").replace(/\|/g, "\\|");
extensions/catpilot-canvas/ui/app.js:268
- The navigation token does not isolate
actionsHost: everygo()overwrites this global before the awaited view fetch resolves. A stale Tasks/Timeline renderer can therefore append its switcher into a newer navigation'sactionStage, which then passes the token check and displays the wrong controls. Pass the localactionStageinto the view renderer instead of reading a shared mutable host.
actionsHost = actionStage;
extensions/catpilot-canvas/catpilot-store.mjs:1122
- Move mode mutates each source immediately, but failures are only checked after all items and the config is persisted later. If a later item or
persistConfigfails, already-renamed files remain solely under the new path while the active config still points to the old path, contradicting the non-destructive migration guarantee and making those records disappear from CatPilot until manual recovery. Stage/copy all data first and commit the config before deleting sources, or roll back every successful move on any failure.
try {
const r = moveOrCopyPath(item.from, item.to, item.isDir, migration);
moved += r.moved;
skipped += r.skipped;
} catch (err) {
extensions/catpilot-canvas/catpilot-store.mjs:824
listReports()extracts<title>/<h1>for HTML files, butreadReport()only searches for a Markdown heading. Opening an HTML report therefore replaces its displayed title with the filename even though the list showed the document title. Apply the same format-specific title extraction here.
let title = safe;
const h = content.match(/^#\s+(.+)$/m);
if (h) title = h[1].trim();
return { filename: safe, title, content, format: ext === "md" ? "markdown" : "html", date: reportDate(safe) };
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 13 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- extensions/catpilot-canvas/package-lock.json: Generated file
Comments suppressed due to low confidence (10)
extensions/catpilot-canvas/extension.mjs:132
sendAndWaitexpects the message options object as its first argument (the other extensions and SDK examples use{ prompt }). Passing the raw string means “Generate with Copilot” does not send the requested prompt correctly. Wrap the prompt and keep the timeout as the second argument.
const ev = await sessionRef.sendAndWait(String(prompt), Number(timeout) || 120000);
extensions/catpilot-canvas/ui/app.js:268
- The navigation token does not fully isolate view actions because
actionsHostremains shared mutable state. If Tasks starts loading, Timeline navigation replaces this variable, and Tasks resolves first, its switcher is appended to Timeline's staging node; the winning view then commits both controls. Pass this navigation'sactionStagedirectly to the renderer (or otherwise bind action writes totoken) instead of using the global host.
actionsHost = actionStage;
extensions/catpilot-canvas/catpilot-store.mjs:87
- Onboarding always writes the global config even when
CATPILOT_CONFIGorCATPILOT_ROOTselected a different missing config path. In that case/api/statuscontinues resolving the nonexistent environment-selected file, so setup reports success butstartApp()immediately fails withNOT_CONFIGURED. Write toresolveConfigPath().path; it already falls back to the global path when no override exists.
fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
fs.writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
config.__baseDir = configBaseDir(GLOBAL_CONFIG_PATH);
config.__configPath = GLOBAL_CONFIG_PATH;
extensions/catpilot-canvas/catpilot-store.mjs:1051
- When root and partitioning are changed together, this branch migrates only the current partition and then points the config at the new root. Historical journal/memo/note/report partitions remain under the old root, so timeline and cross-partition summaries can no longer discover them. Either reject combined changes or move every old-root partition while preserving its relative layout before applying the new partitioning mode.
} else {
// Partitioning change (re-bucketing history is ambiguous) or same root:
// operate on the current partition only.
for (const [type, meta] of Object.entries(from.paths)) {
items.push(mkItem(type, meta.isDir, meta.path, to.paths[type].path));
extensions/catpilot-canvas/catpilot-store.mjs:1082
renameSyncfails withEXDEVwhen the new storage root is on another filesystem or Windows drive, a normal use case for this settings wizard. Directory-backed domains therefore cannot be moved and may leave an already-partial migration behind. HandleEXDEVwith copy-then-delete (or use a cross-device move helper) while preserving the no-overwrite behavior.
else fs.renameSync(s, d);
extensions/catpilot-canvas/catpilot-store.mjs:1090
- This file-path branch also uses
renameSync, so moving tasks, journal, or milestones to a root on another filesystem deterministically fails withEXDEV. Use the same copy-then-delete fallback as the directory branch, without overwriting an existing destination.
else fs.renameSync(src, dest);
extensions/catpilot-canvas/ui/app.js:694
- Memo cards are clickable
divelements with no role, tab stop, or keyboard activation, and there is no other control for opening a memo. Keyboard users therefore cannot read memo contents. Render a button/link, or add button semantics plus Enter/Space handling.
memos.forEach((m) => grid.append(el("div", { class: "card note-card hoverable", onclick: () => memoDetail(m.filename) },
el("h4", { text: m.title }),
el("div", { class: "note-foot" }, el("span", { class: "tag", text: m.date || "" }), el("span", { class: "muted small", text: "📄 memo" })))));
extensions/catpilot-canvas/ui/app.js:742
- These learning/growth/project cards are the only way to open note details, but clickable
divelements are not keyboard focusable or activatable. Use a semantic button/link or providerole="button",tabindex="0", and Enter/Space handling.
grid.append(el("div", { class: "card note-card hoverable", onclick: () => domainNoteDetail(domain, n.filename) },
el("h4", { text: fm.title || n.filename }),
domainChips(domain, fm),
el("div", { class: "note-foot" }, el("span", { class: "tag", text: fm.date || "" }))));
extensions/catpilot-canvas/ui/app.js:1003
- Report cards can only be opened with a pointer because the clickable
divhas no keyboard semantics. Make each card a button/link or add a tab stop, button role, and Enter/Space activation so keyboard users can access saved reports.
reports.forEach((r) => grid.append(el("div", { class: "card report-card hoverable", onclick: () => reportDetail(r.filename) },
el("div", { class: "report-ic", text: r.format === "html" ? "🌐" : "📄" }),
el("h4", { text: r.title }),
el("div", { class: "note-foot" }, el("span", { class: "tag", text: r.date || "" }), el("span", { class: "tag", text: r.format || "md" })))));
extensions/catpilot-canvas/ui/index.html:71
- Toast messages are inserted dynamically but this host is not a live region, so screen readers do not announce success or failure feedback. Mark the container as a polite status/live region (errors can be made assertive separately if desired).
<div id="toasts" class="toasts"></div>
| const del = el("button", { class: "btn btn-danger", text: "Delete", onclick: async () => { | ||
| try { await api(`/api/reports/${encodeURIComponent(filename)}`, { method: "DELETE" }); toast("Report deleted", "", "ok"); closeModal(); if (state.view === "reports") go("reports"); } | ||
| catch (e) { toast("Error", e.message, "err"); } | ||
| } }); |
Sync the catpilot-canvas extension with the latest CatPilot 0.7.0 release: - Pomodoro timer with configurable focus / short-break / long-break, a global persistent mini-timer dock (pause / resume / stop, per-type colours), end-of- session notifications and long-break-after-4-focus break suggestions. - Tasks board gains Overdue / To do / Blocked / Done columns with a status selector, plus a new Month / Week calendar view with per-day priority chips, a field chooser, today highlight and the shared detail popup. - Dashboard Today / Week / Month filter and a focus-analytics section. Refresh the extension description/keywords (pomodoro, timer, calendar) and regenerate marketplace.json accordingly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 13 changed files in this pull request and generated 8 comments.
Files not reviewed (1)
- extensions/catpilot-canvas/package-lock.json: Generated file
Comments suppressed due to low confidence (4)
extensions/catpilot-canvas/catpilot-store.mjs:1504
- Move migration is not transactional: each source is renamed immediately, errors are collected, and no completed moves are rolled back. If a later item fails, the config remains pointed at the old root while earlier files now exist only at the new root, despite the stated guarantee that config and data never drift. Use copy-and-verify followed by config commit and source cleanup, or track and roll back completed moves; also handle cross-device
renamefailures.
const r = moveOrCopyPath(item.from, item.to, item.isDir, migration);
moved += r.moved;
skipped += r.skipped;
} catch (err) {
errors.push({ type: item.type, from: item.from, to: item.to, error: String(err && err.message || err) });
extensions/catpilot-canvas/catpilot-store.mjs:89
- Onboarding always writes the global file, but
resolveConfigPath()always prioritizesCATPILOT_CONFIGorCATPILOT_ROOTeven when that target does not exist. In that environment setup reports success, then every subsequent API still resolves the missing env path and remains unconfigured. Write the configuration to the path selected by the resolver.
fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
fs.writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
config.__baseDir = configBaseDir(GLOBAL_CONFIG_PATH);
config.__configPath = GLOBAL_CONFIG_PATH;
extensions/catpilot-canvas/catpilot-store.mjs:168
- This escaping is not compatible with the linked CatPilot v0.7.0 parser:
lib/cli-utils.jsstill usesline.split('|')and does not decode\\|or<br>. A task or Pomodoro value containing a pipe/newline written by Canvas is therefore read differently or shifted into the wrong columns by the CLI/MCP, despite the exact-format parity claim. Align the canonical parser/formatter across all clients or reject unsupported cell characters.
function encodeCell(v) {
return String(v == null ? "" : v).replace(/\r?\n/g, "<br>").replace(/\|/g, "\\|");
extensions/catpilot-canvas/catpilot-store.mjs:1184
- HTML report titles are extracted in
listReports(), but this detail reader only looks for a Markdown#heading. Clicking an HTML report therefore changes the modal/iframe accessible title from the displayed report title to its filename. Apply the existing<title>/<h1>extraction for HTML here too.
let title = safe;
const h = content.match(/^#\s+(.+)$/m);
if (h) title = h[1].trim();
return { filename: safe, title, content, format: ext === "md" ? "markdown" : "html", date: reportDate(safe) };
| const chip = el("div", { class: `cal-chip pr-${priorityRank(t.priority)}${done ? " done" : ""}`, | ||
| dataset: { taskTitle: t.title }, title: t.title, onclick: () => taskDetail(t) }); |
| <div id="pomo-dock" class="pomo-dock hidden" aria-live="polite"></div> | ||
| <!-- Toasts --> | ||
| <div id="toasts" class="toasts"></div> |
| btn(`🍅 Start focus (${min("focus")}m)`, "btn-primary", () => choose("focus")), | ||
| btn("Skip", "", () => choose(null))); | ||
| } | ||
| openModal({ |
| const started = Date.parse(session.startedAt); | ||
| const endedAt = new Date(); | ||
| const actualMin = Math.max(0, Math.round(((endedAt.getTime() - started) / 1000) / 60)); |
| if (!active.pausedAt) { | ||
| active.pausedAt = new Date().toISOString(); | ||
| fs.writeFileSync(pomodoroActivePath(config), JSON.stringify(active, null, 2), "utf8"); |
| if (rootChanged && !partChanged) { | ||
| // Root-only change: migrate EVERY partition, preserving the relative | ||
| // layout, so historical data is not left behind in the old root. | ||
| for (const [type, fileName] of Object.entries({ ...DEFAULT_FILES, ...(config.storage.files || {}) })) { |
| properties: { | ||
| title: { type: "string" }, | ||
| due: { type: "string", description: "YYYY-MM-DD" }, | ||
| priority: { type: "string", description: "P0/P1/P2/P3 or High/Med/Low" }, | ||
| tags: { type: "string" }, | ||
| context: { type: "string" }, | ||
| }, | ||
| }, |
| description: "Visual command center for CatPilot: dashboard, tasks, journal, milestones, memos, learning, growth, projects, timeline, Copilot reports, settings/migration and a help guide — with charts and agent actions.", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { view: { type: "string", description: "Optional initial view: dashboard|timeline|tasks|journal|milestones|memos|learning|growth|projects|reports|settings|help" } }, |
Sync the Tasks board redesign into the extension runtime files: colour-coded scrollable columns with per-column quick-add, and rebuilt cards with priority accent bars and clear header/title/footer separation. Mirrors tanure/cat-copilot v0.8.0.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 13 changed files in this pull request and generated 3 comments.
Files not reviewed (1)
- extensions/catpilot-canvas/package-lock.json: Generated file
Comments suppressed due to low confidence (5)
extensions/catpilot-canvas/catpilot-store.mjs:89
- Onboarding always writes the global config even when
CATPILOT_CONFIGorCATPILOT_ROOTmakesresolveConfigPath()point elsewhere. In that environment/api/setupreports success, but subsequentconfigStatus()/loadConfig()still look at the missing environment-selected path, so startup remains unconfigured. Write to the resolved target path instead.
fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
fs.writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
config.__baseDir = configBaseDir(GLOBAL_CONFIG_PATH);
config.__configPath = GLOBAL_CONFIG_PATH;
extensions/catpilot-canvas/catpilot-store.mjs:168
- This escaping format is not understood by CatPilot v0.8.0's canonical
lib/cli-utils.js, which parses rows withline.split('|'). For example, a canvas task title containing|is written as\|, but the CLI still splits it into extra columns and shifts due date/priority/tags. This breaks the PR's same-storage interoperability promise; either update all CatPilot surfaces to the same codec or reject/normalize unsupported table delimiters here.
function encodeCell(v) {
return String(v == null ? "" : v).replace(/\r?\n/g, "<br>").replace(/\|/g, "\\|");
extensions/catpilot-canvas/catpilot-store.mjs:477
pausedAtis canvas-only state: CatPilot v0.8.0's canonicallib/pomodoro.jscomputes elapsed time solely fromstartedAtand does not inspectpausedAt. While the canvas shows a paused timer, the CLI/MCP timer continues counting down, contradicting the claimed cross-surface synchronization. Add the same pause semantics to the canonical engine before persisting this state, or avoid exposing pause here.
if (!active.pausedAt) {
active.pausedAt = new Date().toISOString();
fs.writeFileSync(pomodoroActivePath(config), JSON.stringify(active, null, 2), "utf8");
extensions/catpilot-canvas/catpilot-store.mjs:1512
- A failed
moveis not atomic as claimed: earlier items have already been renamed out of the configured source before this error is thrown, while the config remains pointed at that source. Those domains immediately disappear from CatPilot, and abandoning the retry leaves data stranded. Stage copies and switch only after all succeed, or track successful renames and roll them back on failure.
if (errors.length) {
// Config is left untouched; already-moved files stay where they landed
// but the source→config mapping is unchanged, so a retry is safe.
throw Object.assign(new Error(`Migration failed for ${errors.length} item(s); configuration was not changed.`), {
extensions/catpilot-canvas/catpilot-store.mjs:1432
- Changing both root and partitioning takes this
elsepath and migrates only the current partition. After the config switches to the new root, every historical partition left under the old root becomes invisible, and the preview does not list it. Either reject combined changes or enumerate every old-root partition and map it safely into the destination.
} else {
// Partitioning change (re-bucketing history is ambiguous) or same root:
// operate on the current partition only.
for (const [type, meta] of Object.entries(from.paths)) {
items.push(mkItem(type, meta.isDir, meta.path, to.paths[type].path));
| // Log the finished session, then start the chosen next one (if any). | ||
| async function choose(nextType, carryTask) { | ||
| try { | ||
| await api("/api/pomodoro/complete", { method: "POST", body: {} }); |
| function renderTaskBoard(holder, tasks) { | ||
| const cols = [ | ||
| { key: "overdue", title: "⏰ Overdue", status: "Open", filter: (t) => t.status.toLowerCase() === "open" && t.dueDate && t.dueDate < todayISO() }, | ||
| { key: "open", title: "◻ To do", status: "Open", filter: (t) => t.status.toLowerCase() === "open" && !(t.dueDate && t.dueDate < todayISO()) }, | ||
| { key: "blocked", title: "⛔ Blocked", status: "Blocked", filter: (t) => t.status.toLowerCase() === "blocked" }, |
| pomoSyncAccum += 1; | ||
| if (pomo.active && !pomo.active.paused) { | ||
| pomo.remaining = Math.max(0, pomo.remaining - 1); |
Sync the CatPilot canvas UI to cat-copilot v0.8.1: - Board cards now render the due date as a bold, high-contrast chip (icon + date + relative label: Today / Tomorrow / in 3d / 2d overdue). - Backlog column for open tasks with no due date, with drag-to-clear / drag-to-schedule handling. - Consistent emoji empty-state for every board column. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f970696-ac1b-4e45-b901-9ea6f67f5689
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 13 changed files in this pull request and generated 3 comments.
Files not reviewed (1)
- extensions/catpilot-canvas/package-lock.json: Generated file
Comments suppressed due to low confidence (6)
extensions/catpilot-canvas/catpilot-store.mjs:89
- When
CATPILOT_CONFIGorCATPILOT_ROOTpoints to a config that does not exist yet,resolveConfigPath()keeps selecting that location, but onboarding writes only~/.catpilot/config.json. The returned status therefore remains unconfigured and the newly started UI immediately fails. Write the initial config to the path selected byresolveConfigPath()so environment-based setups can be bootstrapped.
fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
fs.writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
config.__baseDir = configBaseDir(GLOBAL_CONFIG_PATH);
config.__configPath = GLOBAL_CONFIG_PATH;
extensions/catpilot-canvas/catpilot-store.mjs:1471
- Move migrations fail when the old and new storage roots are on different filesystems or Windows drives because
renameSyncthrowsEXDEV. Storage roots are arbitrary user-selected paths, so this is a normal migration scenario. HandleEXDEVwith copy-then-remove (and apply the same handling to the directory-entry rename above) before reporting the move as failed.
if (mode === "copy") fs.copyFileSync(src, dest);
else fs.renameSync(src, dest);
extensions/catpilot-canvas/catpilot-store.mjs:405
- Completing or cancelling while paused counts the entire paused interval as active time because
endedAtis used even though the timer is frozen atpausedAt. This inflatesactualMinand all focus analytics. UsepausedAtas the effective elapsed-time endpoint while retaining the real completion timestamp inended.
const endedAt = new Date();
const actualMin = Math.max(0, Math.round(((endedAt.getTime() - started) / 1000) / 60));
extensions/catpilot-canvas/ui/index.html:71
- The dock is rebuilt every second as the countdown changes, so placing
aria-live="polite"on the whole container can make a screen reader announce timer updates continuously, including repeated controls and labels. Keep the ticking time outside a live region and announce only meaningful state changes or expiry.
<div id="pomo-dock" class="pomo-dock hidden" aria-live="polite"></div>
extensions/catpilot-canvas/ui/index.html:73
- Validation failures and asynchronous operation results are shown only through this toast container, but it is not a live region, so screen-reader users receive no feedback. Mark it as an atomic polite status region (or use assertive announcements for errors).
<div id="toasts" class="toasts"></div>
extensions/catpilot-canvas/catpilot-store.mjs:915
- This recursive basename search treats every matching file or directory anywhere in the storage root as a CatPilot partition. Because onboarding explicitly allows an Obsidian vault and names such as
projects,reports,memos, andjournal.mdare common there, timeline aggregation can ingest unrelated content and a root migration can move unrelated user files. Enumerate only paths matching the configured day/week/month partition layout (while honoring custom relative mappings) instead of scanning the whole vault by basename.
return walkFind(storageRoot, fileName, isDir, 0, []);
| <button class="icon-btn agent-icon" id="agent-btn" title="Ask Copilot">✨</button> | ||
| <button class="icon-btn" id="refresh-btn" title="Refresh">⟳</button> | ||
| <button class="icon-btn" id="theme-btn" title="Toggle theme">🌙</button> |
| The canvas reads and writes the **same** config-driven storage as the CatPilot CLI, agent, skills, and MCP server, so every surface stays in sync. | ||
|
|
||
| - **Dashboard** — hero greeting, summary cards, an open-tasks-by-priority donut, a last-3-days activity chart, a focus list and a recent-activity feed, plus a **Today / Week / Month** filter and a **🍅 Focus** analytics section (focus sessions, focus time, completed, abandoned). | ||
| - **Tasks** — switch between **list (table)**, **board (kanban)** and **📅 calendar** views. The board has **Overdue · To do · Blocked · Done** columns with drag-and-drop and a **status selector** on create/edit; the calendar offers **Month / Week** layouts with per-day priority-coloured task chips, a field chooser, today highlight and the shared detail popup. Complete inline, edit/save locally, and open a detail popup from any view. |
| root.innerHTML = ""; | ||
| const cards = [ | ||
| { icon: "🐱", title: "What is this canvas?", body: "A visual command center for **CatPilot**. Everything you see reads and writes the *same files* CatPilot's CLI, agent and MCP server use — so edits here show up everywhere. Nothing is duplicated." }, | ||
| { icon: "🧭", title: "Navigation", body: "The sidebar covers every CatPilot domain: **Tasks, Journal, Milestones, Memos, Learning, Growth, Projects**. Each has add buttons, inline edit and detail popups. **Tasks** offers a table and a kanban board with **Overdue · To do · Blocked · Done** columns, a status selector on create/edit, and **All / Today / 7 days** due-date filters." }, |
- Fix clipped Kanban board card footers: cards are flex: 0 0 auto so the To do column no longer shrinks cards below content height and clips the due-date pill + tag chips when many cards are present. - Add a muted "No due date" chip for undated open board cards. - Harden per-request cache-busting for canvas assets.
|
Synced CatPilot canvas extension to v0.8.2: fixes clipped Kanban board card footers (cards are now |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 13 changed files in this pull request and generated 3 comments.
Files not reviewed (1)
- extensions/catpilot-canvas/package-lock.json: Generated file
Comments suppressed due to low confidence (7)
extensions/catpilot-canvas/catpilot-store.mjs:398
- This history loader reads only the current partition. Consequently, with the default monthly partitioning, “Last month” and “All time” productivity reports omit previous files, and rolling week/month dashboard stats are incomplete at partition boundaries. Aggregate pomodoro.md files across partitions for read/report operations while keeping appends targeted at the current partition.
function loadPomodoroHistory(config) {
const filePath = resolveFilePath("pomodoro", config);
const content = readFileOrCreate(filePath, POMODORO_HISTORY_HEADER + formatPomodoroTable([]));
return { filePath, sessions: parsePomodoroTable(content) };
extensions/catpilot-canvas/catpilot-store.mjs:477
- Pausing is not synchronized with the advertised CatPilot v0.8.1 surfaces: upstream
lib/pomodoro.jsignorespausedAtand derives remaining time solely fromstartedAt, so the CLI/MCP timer continues counting down while this canvas shows it paused. Use a pause representation understood by every CatPilot surface (or add support upstream) before exposing pause/resume here.
if (!active.pausedAt) {
active.pausedAt = new Date().toISOString();
fs.writeFileSync(pomodoroActivePath(config), JSON.stringify(active, null, 2), "utf8");
extensions/catpilot-canvas/catpilot-store.mjs:405
- Completing or stopping a session while it is paused counts the entire paused interval as focus time because
actualMinalways uses the current time. Freeze the elapsed calculation atpausedAtwhile retaining the real completion timestamp.
const started = Date.parse(session.startedAt);
const endedAt = new Date();
const actualMin = Math.max(0, Math.round(((endedAt.getTime() - started) / 1000) / 60));
extensions/catpilot-canvas/catpilot-store.mjs:915
- This recursively treats every matching name anywhere under the storage root as a CatPilot partition. Since onboarding explicitly supports an Obsidian vault, unrelated
journal.mdfiles or genericprojects/reportsfolders can be included in analytics and even moved by root migration. Restrict discovery to the configured day/week/month partition layouts instead of scanning arbitrary descendants.
function allPartitionPaths(config, type) {
const base = config.__baseDir || process.cwd();
const storageRoot = path.resolve(base, config.storage.root);
const fileName = (config.storage.files && config.storage.files[type]) || DEFAULT_FILES[type];
if (!fileName) return [];
const isDir = !/\.[a-z]+$/i.test(fileName);
return walkFind(storageRoot, fileName, isDir, 0, []);
extensions/catpilot-canvas/catpilot-store.mjs:1418
- Root migration enumerates only partitioned
DEFAULT_FILES, but the active timer lives at<storageRoot>/pomodoro-active.json. Changing roots during a running session updates the config without moving that file, so the timer immediately disappears from every surface and is stranded in the old root. Include this root-level state in the plan/apply flow or block root changes while a timer is active.
for (const [type, fileName] of Object.entries({ ...DEFAULT_FILES, ...(config.storage.files || {}) })) {
const isDir = !/\.[a-z]+$/i.test(fileName);
const srcs = allPartitionPaths(config, type);
extensions/catpilot-canvas/catpilot-store.mjs:1472
- Move migrations fail whenever the new storage root is on another filesystem or drive because
renameSyncthrowsEXDEV; changing roots is precisely where that is common. Fall back to copy-then-delete forEXDEV(including the directory-entry branch above) so cross-volume migrations work.
if (fs.existsSync(dest)) return { moved: 0, skipped: 1 };
ensureDir(path.dirname(dest));
if (mode === "copy") fs.copyFileSync(src, dest);
else fs.renameSync(src, dest);
return { moved: 1, skipped: 0 };
extensions/catpilot-canvas/catpilot-store.mjs:168
- The backslash pipe encoding is not compatible with CatPilot v0.8.1’s shared parser, which uses
line.split("|")and does not decode escapes. A title/context containing|therefore round-trips in this canvas but is split into extra columns by the CLI/MCP, contradicting the same-storage guarantee. Use the shared format’s accepted sanitization or update every CatPilot parser together.
// Markdown-table cells are pipe/newline delimited, so any user value that
// contains `|` or a line break must be escaped on write and restored on read,
// otherwise it spills into extra cells/rows and corrupts the table.
function encodeCell(v) {
return String(v == null ? "" : v).replace(/\r?\n/g, "<br>").replace(/\|/g, "\\|");
| if (t.dueDate) { | ||
| const state = done ? "" : overdue ? " overdue" : (t.dueDate === todayISO() ? " today" : ""); | ||
| foot.append(el("span", { class: `bc-due${state}`, title: `Due ${t.dueDate}`, text: `📅 ${t.dueDate}` })); |
| function renderTaskList(holder, tasks) { | ||
| const open = tasks.filter((t) => t.status.toLowerCase() === "open"); | ||
| const blocked = tasks.filter((t) => t.status.toLowerCase() === "blocked"); | ||
| const done = tasks.filter((t) => t.status.toLowerCase() === "done"); | ||
| const ordered = [...open, ...blocked, ...done]; |
| } else { | ||
| // Partitioning change (re-bucketing history is ambiguous) or same root: | ||
| // operate on the current partition only. | ||
| for (const [type, meta] of Object.entries(from.paths)) { | ||
| items.push(mkItem(type, meta.isDir, meta.path, to.paths[type].path)); | ||
| } |
Pull Request Checklist
npm startand verified thatREADME.mdis up to date.mainbranch for this pull request.Description
Adds the CatPilot Canvas extension at
extensions/catpilot-canvas/— a visual command center for CatPilot that lets you manage everything CatPilot tracks without leaving the Copilot app. It reads and writes the same config-driven storage as the CatPilot CLI, agent, skills, and MCP server, so every surface stays in sync.Type of Contribution
Additional Notes
Add CatPilot Canvas extension
This adds a canvas extension at
extensions/catpilot-canvas/.What you get
Checklist
extensions/catpilot-canvas/.github/plugin/plugin.jsonwithlogo: "assets/preview.png"andextensions: ".",namematches the folder, nox-awesome-copilot, nocanvas.json.assets/preview.pngprimary screenshot.npm run plugin:validatepasses (✅ extension catpilot-canvas is valid).By submitting this pull request, I confirm that my contribution abides by the Code of Conduct and will be licensed under the MIT License.
Update — v0.8.1 (2026-07-23)
Synced the extension to CatPilot v0.8.1:
Revalidated with
pm run plugin:validate (✅ all 70 plugins / 20 extensions valid).