shadow-claw 1.23.3 → 1.23.4

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 (89) hide show
  1. package/bin/assert-version-bump.mjs +60 -38
  2. package/bin/assert-version-bump.test.mjs +74 -11
  3. package/dist/public/agent.worker.js +6 -6
  4. package/dist/public/{app-routes-B0ePT49v.js → app-routes-Bs-riIvr.js} +1 -1
  5. package/dist/public/components/settings/shadow-claw-storage/shadow-claw-storage.html +1 -1
  6. package/dist/public/components/shadow-claw-files/shadow-claw-files.css +1 -1
  7. package/dist/public/components/shadow-claw-files/shadow-claw-files.html +1 -1
  8. package/dist/public/config-BYAuxx3j.js +1 -0
  9. package/dist/public/{configurePeerJs-B7kNU8AQ.js → configurePeerJs-B4Ym9VMj.js} +1 -1
  10. package/dist/public/{connections-xd_66YVU.js → connections-BEn6JYdu.js} +1 -1
  11. package/dist/public/{custom-element-security-CZ92anB8.js → custom-element-security-DdYlUozq.js} +1 -1
  12. package/dist/public/docs/architecture/storage.md +5 -2
  13. package/dist/public/docs/decisions/indexeddb-and-opfs-storage.md +5 -2
  14. package/dist/public/docs/example/article/index.html +2 -2
  15. package/dist/public/docs/publishing/index.html +19 -76
  16. package/dist/public/docs/skill-creator/index.html +2 -2
  17. package/dist/public/{downloadGroupFile-BABv_1mJ.js → downloadGroupFile-Drdvg1Ev.js} +1 -1
  18. package/dist/public/{e2e-bridge-DkS5xFGX.js → e2e-bridge-BQ4KzUt5.js} +1 -1
  19. package/dist/public/{file-viewer-CCSABMsE.js → file-viewer-CcZYw-PS.js} +1 -1
  20. package/dist/public/files/main/~/docs/publishing-to-github-pages.md +17 -82
  21. package/dist/public/{git-BkjrsRXX.js → git-DEUvQTUA.js} +2 -2
  22. package/dist/public/{iframe-sanitizer-L6Jyu__Z.js → iframe-sanitizer-0NS4XLOd.js} +1 -1
  23. package/dist/public/index.html +2 -2
  24. package/dist/public/index.js +1 -1
  25. package/dist/public/{initChatSplitResize-BslPXAXS.js → initChatSplitResize-llGkSJXg.js} +1 -1
  26. package/dist/public/main/index.html +2 -2
  27. package/dist/public/main/memory/index.html +2 -2
  28. package/dist/public/{markdown-6MBrIo-S.js → markdown-CyYR4App.js} +1 -1
  29. package/dist/public/mcp-reconnect-CSx4aMwy.js +1 -0
  30. package/dist/public/{memoryStorage-CjGseKI1.js → memoryStorage-Dwg8NhAZ.js} +1 -1
  31. package/dist/public/{orchestrator-B9KmIj8Q.js → orchestrator-DRfEudXk.js} +18 -18
  32. package/dist/public/orchestrator-o9Fj67BN.js +15 -0
  33. package/dist/public/pages/main/~/docs/publishing-to-github-pages.md +17 -82
  34. package/dist/public/{peerjs-Dv8JNm7v.js → peerjs-CurBuKvk.js} +1 -1
  35. package/dist/public/{push-client-Dqq06wbB.js → push-client-Dii-vPEh.js} +1 -1
  36. package/dist/public/service-worker/fetch-proxy.js +1 -1
  37. package/dist/public/service-worker.js +1 -1
  38. package/dist/public/{shadow-claw-Bd1rfuVC.js → shadow-claw-5IHaDDMJ.js} +2 -2
  39. package/dist/public/{shadow-claw-accounts-DL2l3oM0.js → shadow-claw-accounts-CZvz4iQz.js} +3 -3
  40. package/dist/public/{shadow-claw-channels-BGXDsQ38.js → shadow-claw-channels-BKXFyRS3.js} +1 -1
  41. package/dist/public/{shadow-claw-chat-NXg6zz_l.js → shadow-claw-chat-CCLuhWdI.js} +3 -3
  42. package/dist/public/{shadow-claw-conversations-C8eqGpP9.js → shadow-claw-conversations-DV-87UUh.js} +1 -1
  43. package/dist/public/shadow-claw-file-viewer-Dk2dbAUd.js +577 -0
  44. package/dist/public/shadow-claw-files-BpfpJIuP.js +840 -0
  45. package/dist/public/{shadow-claw-git-C0ntz7aX.js → shadow-claw-git-DRA7_qck.js} +3 -3
  46. package/dist/public/{shadow-claw-integrations-DCXV2r69.js → shadow-claw-integrations-1Xu5snuV.js} +1 -1
  47. package/dist/public/{shadow-claw-llm-CudgFQsS.js → shadow-claw-llm-6Aci1BDP.js} +2 -2
  48. package/dist/public/{shadow-claw-mcp-remote-B8RkpX28.js → shadow-claw-mcp-remote-BUjAeVXC.js} +1 -1
  49. package/dist/public/{shadow-claw-networking-BLiRsrsK.js → shadow-claw-networking-FQe4nCp3.js} +1 -1
  50. package/dist/public/{shadow-claw-notifications-CQhQcjtL.js → shadow-claw-notifications-DV2wibL3.js} +1 -1
  51. package/dist/public/{shadow-claw-page-header-NeRD0tKU.js → shadow-claw-page-header-B63S4HTU.js} +1 -1
  52. package/dist/public/{shadow-claw-pages-DCHnFh6r.js → shadow-claw-pages-DE3jWj5K.js} +3 -3
  53. package/dist/public/{shadow-claw-settings-VAfjKFQg.js → shadow-claw-settings-S6DAKfYx.js} +3 -3
  54. package/dist/public/shadow-claw-storage-DaacA3_K.js +219 -0
  55. package/dist/public/{shadow-claw-task-server-Dk33N_1G.js → shadow-claw-task-server-IjKX042p.js} +1 -1
  56. package/dist/public/{shadow-claw-tasks-xA1VfmWN.js → shadow-claw-tasks-Cy0JYmGU.js} +4 -4
  57. package/dist/public/{shadow-claw-terminal-Du2r0ciq.js → shadow-claw-terminal-znAt7HyP.js} +1 -1
  58. package/dist/public/{shadow-claw-tools-CZj4rq7l.js → shadow-claw-tools-Cutv9Si5.js} +2 -2
  59. package/dist/public/{shadow-claw-webvm-JYUkWICG.js → shadow-claw-webvm-BH8mPDEu.js} +1 -1
  60. package/dist/public/static-main/~/docs/publishing-to-github-pages.md +17 -82
  61. package/dist/public/static-main-manifest.json +1 -1
  62. package/dist/public/{syncWebMcpRegistration-DfpvASnh.js → syncWebMcpRegistration-BqJxaMGR.js} +1 -1
  63. package/package.json +1 -1
  64. package/src/components/settings/shadow-claw-storage/shadow-claw-storage.html +17 -0
  65. package/src/components/settings/shadow-claw-storage/shadow-claw-storage.test.ts +44 -0
  66. package/src/components/settings/shadow-claw-storage/shadow-claw-storage.ts +56 -0
  67. package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.test.ts +105 -0
  68. package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.ts +11 -9
  69. package/src/components/shadow-claw-files/shadow-claw-files.css +41 -17
  70. package/src/components/shadow-claw-files/shadow-claw-files.html +35 -0
  71. package/src/components/shadow-claw-files/shadow-claw-files.test.ts +219 -0
  72. package/src/components/shadow-claw-files/shadow-claw-files.ts +170 -6
  73. package/src/components/shadow-claw-pages/utils/resolveWorkspaceLinkPath.test.ts +31 -0
  74. package/src/components/shadow-claw-pages/utils/resolveWorkspaceLinkPath.ts +2 -1
  75. package/src/components/shadow-claw-pages/utils/rewriteWorkspacePreviewHtml.test.ts +15 -0
  76. package/src/config/config.test.ts +9 -0
  77. package/src/config/config.ts +5 -0
  78. package/src/core/app-routes.test.ts +56 -0
  79. package/src/core/app-routes.ts +8 -1
  80. package/src/db/openDatabase.test.ts +10 -1
  81. package/src/db/openDatabase.ts +3 -1
  82. package/src/storage/migrateLegacyOpfs.test.ts +327 -0
  83. package/src/storage/migrateLegacyOpfs.ts +117 -0
  84. package/dist/public/config-AL77QAx4.js +0 -1
  85. package/dist/public/mcp-reconnect-DoicazQd.js +0 -1
  86. package/dist/public/orchestrator-4su7iyWj.js +0 -15
  87. package/dist/public/shadow-claw-file-viewer-9Qkwc30T.js +0 -577
  88. package/dist/public/shadow-claw-files-aDyq6Hv7.js +0 -781
  89. package/dist/public/shadow-claw-storage-Cf31np81.js +0 -202
@@ -1,5 +1,8 @@
1
+ import { CONFIG_KEYS } from "../../config/config.js";
1
2
  import { effect } from "../../core/effect.js";
2
3
  import { getDb } from "../../db/db.js";
4
+ import { getConfig } from "../../db/getConfig.js";
5
+ import { isTruthyConfigValue } from "../../common/utils/config-value.mjs";
3
6
 
4
7
  import { setSanitizedHtml } from "../../security/trusted-types.js";
5
8
  import { copyGroupEntry } from "../../storage/copyGroupEntry.js";
@@ -1575,34 +1578,195 @@ export class ShadowClawFiles extends ShadowClawElement {
1575
1578
  return false;
1576
1579
  }
1577
1580
 
1581
+ async promptUploadConflict(
1582
+ fileName: string,
1583
+ existingNames: Set<string>,
1584
+ ): Promise<
1585
+ { action: "overwrite" } | { action: "rename"; name: string } | null
1586
+ > {
1587
+ const root = this.shadowRoot;
1588
+ if (!root) {
1589
+ return null;
1590
+ }
1591
+
1592
+ const dialog = root.querySelector(".files__upload-conflict-dialog");
1593
+ const messageEl = root.querySelector(".files__upload-conflict-message");
1594
+ const renameInput = root.querySelector(
1595
+ ".files__upload-conflict-rename-input",
1596
+ );
1597
+ const form = root.querySelector(".files__upload-conflict-form");
1598
+ const cancelBtn = root.querySelector(".files__upload-conflict-cancel");
1599
+ const overwriteBtn = root.querySelector(
1600
+ ".files__upload-conflict-overwrite",
1601
+ );
1602
+
1603
+ if (
1604
+ !(dialog instanceof HTMLDialogElement) ||
1605
+ !(form instanceof HTMLFormElement) ||
1606
+ !(renameInput instanceof HTMLInputElement)
1607
+ ) {
1608
+ return null;
1609
+ }
1610
+
1611
+ const targetDirName =
1612
+ orchestratorStore.currentPath === "."
1613
+ ? "Root"
1614
+ : orchestratorStore.currentPath;
1615
+
1616
+ if (messageEl instanceof HTMLElement) {
1617
+ messageEl.textContent = `"${fileName}" already exists in "${targetDirName}". Please rename or choose to overwrite.`;
1618
+ }
1619
+
1620
+ renameInput.value = fileName;
1621
+
1622
+ return new Promise((resolve) => {
1623
+ let resolved = false;
1624
+
1625
+ const cleanup = () => {
1626
+ form.removeEventListener("submit", handleSubmit);
1627
+ cancelBtn?.removeEventListener("click", handleCancel);
1628
+ overwriteBtn?.removeEventListener("click", handleOverwrite);
1629
+ dialog.removeEventListener("cancel", handleCancel);
1630
+ dialog.removeEventListener("close", handleClose);
1631
+ };
1632
+
1633
+ const finish = (
1634
+ result:
1635
+ | { action: "overwrite" }
1636
+ | { action: "rename"; name: string }
1637
+ | null,
1638
+ ) => {
1639
+ if (resolved) {
1640
+ return;
1641
+ }
1642
+ resolved = true;
1643
+ cleanup();
1644
+ if (typeof dialog.close === "function") {
1645
+ dialog.close();
1646
+ } else {
1647
+ dialog.removeAttribute("open");
1648
+ }
1649
+ resolve(result);
1650
+ };
1651
+
1652
+ const handleSubmit = (event: Event) => {
1653
+ event.preventDefault();
1654
+ const nextName = renameInput.value.trim();
1655
+ if (!nextName) {
1656
+ showWarning("Please enter a name", 3000);
1657
+ return;
1658
+ }
1659
+
1660
+ if (nextName.includes("/") || nextName.includes("\\")) {
1661
+ showWarning("Use only a name, not a path", 3500);
1662
+ return;
1663
+ }
1664
+
1665
+ if (existingNames.has(nextName) || existingNames.has(`${nextName}/`)) {
1666
+ showWarning(
1667
+ `"${nextName}" already exists. Please choose a different name.`,
1668
+ 4000,
1669
+ );
1670
+ renameInput.select();
1671
+ return;
1672
+ }
1673
+
1674
+ finish({ action: "rename", name: nextName });
1675
+ };
1676
+
1677
+ const handleOverwrite = (event: Event) => {
1678
+ event.preventDefault();
1679
+ finish({ action: "overwrite" });
1680
+ };
1681
+
1682
+ const handleCancel = (event: Event) => {
1683
+ event.preventDefault();
1684
+ finish(null);
1685
+ };
1686
+
1687
+ const handleClose = () => {
1688
+ finish(null);
1689
+ };
1690
+
1691
+ form.addEventListener("submit", handleSubmit);
1692
+ cancelBtn?.addEventListener("click", handleCancel);
1693
+ overwriteBtn?.addEventListener("click", handleOverwrite);
1694
+ dialog.addEventListener("cancel", handleCancel);
1695
+ dialog.addEventListener("close", handleClose);
1696
+
1697
+ if (typeof dialog.showModal === "function") {
1698
+ dialog.showModal();
1699
+ } else {
1700
+ dialog.setAttribute("open", "");
1701
+ }
1702
+ renameInput.select();
1703
+ });
1704
+ }
1705
+
1578
1706
  async uploadFileList(db: ShadowClawDatabase, files: FileList | File[]) {
1579
1707
  const fileList: File[] = Array.from(files) as File[];
1580
1708
  if (fileList.length === 0) {
1581
1709
  return;
1582
1710
  }
1583
1711
 
1712
+ const appendUlid = await getConfig(db, CONFIG_KEYS.FILES_UPLOAD_APPEND_ULID)
1713
+ .then((val) => isTruthyConfigValue(val, false))
1714
+ .catch(() => false);
1715
+
1584
1716
  const groupId = orchestratorStore.activeGroupId;
1585
1717
  const currentPath = orchestratorStore.currentPath;
1586
1718
  const count = fileList.length;
1587
1719
 
1588
1720
  filesUiStore.startUpload(count);
1589
1721
 
1722
+ const existingNames = new Set(orchestratorStore.files);
1723
+ let uploadedCount = 0;
1724
+
1590
1725
  try {
1591
1726
  for (let i = 0; i < count; i++) {
1592
1727
  const file = fileList[i];
1593
- const filename =
1594
- currentPath === "."
1595
- ? `${ulid()}-${file.name}`
1596
- : `${currentPath}/${ulid()}-${file.name}`;
1728
+ let filename = appendUlid ? `${ulid()}-${file.name}` : file.name;
1729
+
1730
+ const hasConflict =
1731
+ existingNames.has(filename) || existingNames.has(`${filename}/`);
1732
+
1733
+ if (hasConflict) {
1734
+ const decision = await this.promptUploadConflict(
1735
+ filename,
1736
+ existingNames,
1737
+ );
1738
+ if (!decision) {
1739
+ filesUiStore.setUploadCompleted(i + 1);
1740
+ continue;
1741
+ }
1597
1742
 
1598
- await uploadGroupFile(db, groupId, filename, file);
1743
+ if (decision.action === "rename") {
1744
+ filename = decision.name;
1745
+ } else if (decision.action === "overwrite") {
1746
+ const targetPath =
1747
+ currentPath === "." ? filename : `${currentPath}/${filename}`;
1748
+ await this.removePageAssignmentIfNeeded(db, targetPath);
1749
+ }
1750
+ }
1751
+
1752
+ const fullPath =
1753
+ currentPath === "." ? filename : `${currentPath}/${filename}`;
1754
+
1755
+ await uploadGroupFile(db, groupId, fullPath, file);
1756
+ existingNames.add(filename);
1757
+ uploadedCount++;
1599
1758
  filesUiStore.setUploadCompleted(i + 1);
1600
1759
  }
1601
1760
 
1602
1761
  // Reload files
1603
1762
  await orchestratorStore.loadFiles(db);
1604
1763
 
1605
- showSuccess(`Uploaded ${count} file${count === 1 ? "" : "s"}`, 3000);
1764
+ if (uploadedCount > 0) {
1765
+ showSuccess(
1766
+ `Uploaded ${uploadedCount} file${uploadedCount === 1 ? "" : "s"}`,
1767
+ 3000,
1768
+ );
1769
+ }
1606
1770
  } catch (err) {
1607
1771
  const message = err instanceof Error ? err.message : String(err);
1608
1772
 
@@ -36,6 +36,37 @@ describe("resolveWorkspaceLinkPath", () => {
36
36
  expect(result).toBe("docs/guide.md");
37
37
  });
38
38
 
39
+ it("resolves relative image link in file directory", () => {
40
+ const result = resolveWorkspaceLinkPath(
41
+ "01M1A1R4Y708YJNPGV2NHQKYF7-IMG_0233.jpeg",
42
+ "index.md",
43
+ groupId,
44
+ origin,
45
+ );
46
+ expect(result).toBe("01M1A1R4Y708YJNPGV2NHQKYF7-IMG_0233.jpeg");
47
+ });
48
+
49
+ it("resolves relative links when subpath base tag is active", () => {
50
+ document.querySelectorAll("base").forEach((el) => el.remove());
51
+ const baseEl = document.createElement("base");
52
+ baseEl.setAttribute("href", "/shadow-claw/");
53
+ document.head.appendChild(baseEl);
54
+ (globalThis as any).__applyBasePathCacheReset?.();
55
+
56
+ try {
57
+ const result = resolveWorkspaceLinkPath(
58
+ "01M1A1R4Y708YJNPGV2NHQKYF7-IMG_0233.jpeg",
59
+ "index.md",
60
+ groupId,
61
+ origin,
62
+ );
63
+ expect(result).toBe("01M1A1R4Y708YJNPGV2NHQKYF7-IMG_0233.jpeg");
64
+ } finally {
65
+ baseEl.remove();
66
+ (globalThis as any).__applyBasePathCacheReset?.();
67
+ }
68
+ });
69
+
39
70
  it("returns null if link points to external origin", () => {
40
71
  const result = resolveWorkspaceLinkPath(
41
72
  "https://example.com/page",
@@ -1,4 +1,5 @@
1
1
  import {
2
+ applyBasePath,
2
3
  getFileRouteDirPath,
3
4
  getWorkspaceRouteRequestPath,
4
5
  resolveHrefAgainstRoute,
@@ -43,7 +44,7 @@ export function resolveWorkspaceLinkPath(
43
44
  }
44
45
  }
45
46
 
46
- const routeDir = getFileRouteDirPath(groupId, filePath);
47
+ const routeDir = applyBasePath(getFileRouteDirPath(groupId, filePath));
47
48
  const resolved = resolveHrefAgainstRoute(
48
49
  trimmed,
49
50
  routeDir,
@@ -42,4 +42,19 @@ describe("rewriteWorkspacePreviewHtml", () => {
42
42
  );
43
43
  expect(rewritten).toContain('href="https://example.com/docs"');
44
44
  });
45
+
46
+ it("rewrites relative markdown links to subpath-prefixed workspace routes", () => {
47
+ const html =
48
+ '<p><a href="01M1A1R4Y708YJNPGV2NHQKYF7-IMG_0233.jpeg">01M1A1R4Y708YJNPGV2NHQKYF7-IMG_0233.jpeg</a></p>';
49
+ const rewritten = rewriteWorkspacePreviewHtml(
50
+ html,
51
+ "index.md",
52
+ "/shadow-claw/files/br:main/",
53
+ groupId,
54
+ origin,
55
+ );
56
+ expect(rewritten).toContain(
57
+ 'href="/shadow-claw/files/br:main/01M1A1R4Y708YJNPGV2NHQKYF7-IMG_0233.jpeg"',
58
+ );
59
+ });
45
60
  });
@@ -12,6 +12,7 @@ import {
12
12
  DEFAULT_DEV_HOST,
13
13
  DEFAULT_DEV_IP,
14
14
  DEFAULT_DEV_PORT,
15
+ DEFAULT_FILES_UPLOAD_APPEND_ULID,
15
16
  DEFAULT_GROUP_ID,
16
17
  DEFAULT_MAX_TOKENS,
17
18
  DEFAULT_PROMPT_API_BACKEND,
@@ -149,6 +150,10 @@ describe("config.js", () => {
149
150
  expect(DEFAULT_TRANSFORMERS_JS_BACKEND).toBe("auto");
150
151
  expect(DEFAULT_TRANSFORMERS_JS_DTYPE_STRATEGY).toBe("auto");
151
152
  });
153
+
154
+ it("should have valid DEFAULT_FILES_UPLOAD_APPEND_ULID", () => {
155
+ expect(DEFAULT_FILES_UPLOAD_APPEND_ULID).toBe(false);
156
+ });
152
157
  });
153
158
 
154
159
  describe("buildTriggerPattern", () => {
@@ -425,6 +430,10 @@ describe("config.js", () => {
425
430
  );
426
431
  expect(CONFIG_KEYS.CHANNEL_ENABLED_PREFIX).toBe("channel_enabled:");
427
432
  expect(CONFIG_KEYS.CHAT_INPUT_AREA_HEIGHT).toBe("chat_input_area_height");
433
+ expect(CONFIG_KEYS.ENABLED_TOOLS).toBe("enabled_tools");
434
+ expect(CONFIG_KEYS.FILES_UPLOAD_APPEND_ULID).toBe(
435
+ "files_upload_append_ulid",
436
+ );
428
437
  expect(CONFIG_KEYS.GIT_AUTHOR_EMAIL).toBe("git_author_email");
429
438
  expect(CONFIG_KEYS.GIT_AUTHOR_NAME).toBe("git_author_name");
430
439
  expect(CONFIG_KEYS.GIT_CORS_PROXY).toBe("git_cors_proxy");
@@ -1042,6 +1042,7 @@ export const CONFIG_KEYS = {
1042
1042
  DEFAULT_PINNED_PAGE: "default_pinned_page",
1043
1043
  DIRECT_TOOL_COMMAND_POLICY: "direct_tool_command_policy",
1044
1044
  ENABLED_TOOLS: "enabled_tools",
1045
+ FILES_UPLOAD_APPEND_ULID: "files_upload_append_ulid",
1045
1046
  GIT_ACCOUNTS: "git_accounts",
1046
1047
  GIT_AUTHOR_EMAIL: "git_author_email",
1047
1048
  GIT_AUTHOR_NAME: "git_author_name",
@@ -1073,6 +1074,7 @@ export const CONFIG_KEYS = {
1073
1074
  MESH_LLM_HOST: "mesh_llm_host",
1074
1075
  MODEL: "model",
1075
1076
  OAUTH_PROVIDER_DEFINITIONS: "oauth_provider_definitions",
1077
+ OPFS_MIGRATED_FROM_LEGACY: "opfs_migrated_from_legacy",
1076
1078
  OVERRIDE_PRERENDER_SKELETON: "override_prerender_skeleton",
1077
1079
  PAGES_AUTO_REFRESH_INTERVAL: "pages_auto_refresh_interval",
1078
1080
  PAGES_LIST: "pages_list",
@@ -1180,3 +1182,6 @@ export const DEFAULT_TASK_SERVER_URL = "http://localhost:8888/schedule";
1180
1182
 
1181
1183
  /** Default Task Server Enabled */
1182
1184
  export const DEFAULT_TASK_SERVER_ENABLED = false;
1185
+
1186
+ /** Default Files Upload Append ULID */
1187
+ export const DEFAULT_FILES_UPLOAD_APPEND_ULID = false;
@@ -5,6 +5,7 @@ import {
5
5
  getDeploymentNamespace,
6
6
  getFileRouteDirPath,
7
7
  getWorkspaceRouteRequestPath,
8
+ isPossibleAppRoute,
8
9
  parseRouteFromUrl,
9
10
  resolveHrefAgainstRoute,
10
11
  } from "./app-routes.js";
@@ -148,11 +149,17 @@ describe("app-routes", () => {
148
149
  describe("applyBasePath", () => {
149
150
  // Reset cached base path between tests
150
151
  beforeEach(() => {
152
+ document.querySelectorAll("base").forEach((el) => el.remove());
151
153
  // Force-reset the module-level cache so each sub-test starts fresh.
152
154
  // We do this by temporarily reassigning window.location via jsdom.
153
155
  (globalThis as any).__applyBasePathCacheReset?.();
154
156
  });
155
157
 
158
+ afterEach(() => {
159
+ document.querySelectorAll("base").forEach((el) => el.remove());
160
+ (globalThis as any).__applyBasePathCacheReset?.();
161
+ });
162
+
156
163
  it("returns path unchanged when base is root", () => {
157
164
  // In jsdom the default base path resolves to "/"
158
165
  expect(applyBasePath("/chat")).toBe("/chat");
@@ -191,6 +198,55 @@ describe("app-routes", () => {
191
198
  anchor: undefined,
192
199
  });
193
200
  });
201
+
202
+ it("handles explicit subpath <base href> tag correctly for all workspace routes", () => {
203
+ document.querySelectorAll("base").forEach((el) => el.remove());
204
+ const baseEl = document.createElement("base");
205
+ baseEl.setAttribute("href", "/shadow-claw/");
206
+ document.head.appendChild(baseEl);
207
+ (globalThis as any).__applyBasePathCacheReset?.();
208
+
209
+ try {
210
+ expect(getAppBasePath()).toBe("/shadow-claw/");
211
+
212
+ // Workspace file route extraction with subpath prefix
213
+ expect(
214
+ getWorkspaceRouteRequestPath(
215
+ "/shadow-claw/files/main/01M1A1R4Y708YJNPGV2NHQKYF7-IMG_0233.jpeg",
216
+ ),
217
+ ).toEqual({
218
+ groupId: "br:main",
219
+ path: "01M1A1R4Y708YJNPGV2NHQKYF7-IMG_0233.jpeg",
220
+ });
221
+
222
+ // isPossibleAppRoute validation
223
+ expect(
224
+ isPossibleAppRoute(
225
+ "/shadow-claw/files/main/01M1A1R4Y708YJNPGV2NHQKYF7-IMG_0233.jpeg",
226
+ ),
227
+ ).toBe(true);
228
+
229
+ // Path without base prefix should NOT match app routes
230
+ expect(
231
+ isPossibleAppRoute(
232
+ "/files/main/01M1A1R4Y708YJNPGV2NHQKYF7-IMG_0233.jpeg",
233
+ ),
234
+ ).toBe(false);
235
+
236
+ // resolveHrefAgainstRoute with subpath routeDir
237
+ const resolved = resolveHrefAgainstRoute(
238
+ "01M1A1R4Y708YJNPGV2NHQKYF7-IMG_0233.jpeg",
239
+ applyBasePath(getFileRouteDirPath("br:main", "index.md")),
240
+ "http://localhost",
241
+ );
242
+ expect(resolved?.pathname).toBe(
243
+ "/shadow-claw/files/main/01M1A1R4Y708YJNPGV2NHQKYF7-IMG_0233.jpeg",
244
+ );
245
+ } finally {
246
+ baseEl.remove();
247
+ (globalThis as any).__applyBasePathCacheReset?.();
248
+ }
249
+ });
194
250
  });
195
251
 
196
252
  describe("pretty path routing", () => {
@@ -551,7 +551,14 @@ export function getFileRouteDirPath(groupId: string, filePath: string): string {
551
551
  export function getWorkspaceRouteRequestPath(
552
552
  pathname: string,
553
553
  ): { groupId: string; path: string } | null {
554
- const parts = pathname
554
+ let relativePath = pathname;
555
+ const basePath = getAppBasePath();
556
+
557
+ if (basePath !== "/" && relativePath.startsWith(basePath)) {
558
+ relativePath = "/" + relativePath.slice(basePath.length);
559
+ }
560
+
561
+ const parts = relativePath
555
562
  .split("/")
556
563
  .filter(Boolean)
557
564
  .map((part) => decodePathSegment(part));
@@ -2,8 +2,15 @@ import { jest } from "@jest/globals";
2
2
 
3
3
  const setDB = jest.fn();
4
4
 
5
+ const mockMigrateLegacyDatabase = jest.fn<any>().mockResolvedValue(undefined);
6
+ const mockMigrateLegacyOpfs = jest.fn<any>().mockResolvedValue(undefined);
7
+
5
8
  jest.unstable_mockModule("./migrateLegacyDatabase.js", () => ({
6
- migrateLegacyDatabase: jest.fn<any>().mockResolvedValue(undefined),
9
+ migrateLegacyDatabase: mockMigrateLegacyDatabase,
10
+ }));
11
+
12
+ jest.unstable_mockModule("../storage/migrateLegacyOpfs.js", () => ({
13
+ migrateLegacyOpfs: mockMigrateLegacyOpfs,
7
14
  }));
8
15
 
9
16
  jest.unstable_mockModule("./db.js", () => ({
@@ -110,6 +117,8 @@ describe("openDatabase", () => {
110
117
  );
111
118
 
112
119
  expect(setDB).toHaveBeenCalledWith(database);
120
+ expect(mockMigrateLegacyDatabase).toHaveBeenCalledWith(database);
121
+ expect(mockMigrateLegacyOpfs).toHaveBeenCalledWith(database);
113
122
  });
114
123
 
115
124
  it("skips creating stores that already exist", async () => {
@@ -1,6 +1,7 @@
1
1
  import { DB_VERSION, getDbName } from "../config/config.js";
2
2
  import { setDB } from "./db.js";
3
3
  import { migrateLegacyDatabase } from "./migrateLegacyDatabase.js";
4
+ import { migrateLegacyOpfs } from "../storage/migrateLegacyOpfs.js";
4
5
  import type { ShadowClawDatabase } from "./types.js";
5
6
 
6
7
  /**
@@ -59,8 +60,9 @@ export function openDatabase(): Promise<ShadowClawDatabase> {
59
60
  setDB(db);
60
61
  try {
61
62
  await migrateLegacyDatabase(db);
63
+ await migrateLegacyOpfs(db);
62
64
  } catch (e) {
63
- console.warn("Error running IndexedDB legacy migration:", e);
65
+ console.warn("Error running legacy migration:", e);
64
66
  }
65
67
  resolve(db);
66
68
  };