From fe60fca23b5d169b8ef99f9b1487974004e97d50 Mon Sep 17 00:00:00 2001 From: Ali Madad Date: Fri, 24 Jul 2026 15:07:02 +0000 Subject: [PATCH 1/9] feat: select a machine when creating projects --- .../dialogs/ProjectPathDialog.test.tsx | 119 ++++++++++++++++++ .../components/dialogs/ProjectPathDialog.tsx | 97 ++++++++++++-- apps/app/src/components/layout/AppLayout.tsx | 1 + apps/app/src/hooks/useLocalPathPicker.tsx | 14 ++- .../src/hooks/useQuickCreateProject.test.tsx | 99 +++++++++++++++ apps/app/src/hooks/useQuickCreateProject.tsx | 15 ++- docs/multiple-devices.md | 6 +- 7 files changed, 333 insertions(+), 18 deletions(-) create mode 100644 apps/app/src/components/dialogs/ProjectPathDialog.test.tsx create mode 100644 apps/app/src/hooks/useQuickCreateProject.test.tsx diff --git a/apps/app/src/components/dialogs/ProjectPathDialog.test.tsx b/apps/app/src/components/dialogs/ProjectPathDialog.test.tsx new file mode 100644 index 0000000000..caf6d55824 --- /dev/null +++ b/apps/app/src/components/dialogs/ProjectPathDialog.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { Host } from "@bb/domain"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ProjectPathDialog } from "./ProjectPathDialog"; + +vi.mock("@/components/dialogs/RemotePathBrowser", () => ({ + RemotePathBrowser: ({ + hostId, + onDirectoryChange, + }: { + hostId: string; + onDirectoryChange: (directory: string) => void; + }) => ( + + ), +})); + +function host(overrides: Partial & Pick): Host { + return { + type: "persistent", + status: "connected", + lastSeenAt: null, + lastRejectedProtocolVersion: null, + createdAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +const atum = host({ id: "host_atum", name: "atum" }); +const kunst = host({ id: "host_kunst", name: "Kunst" }); +const offline = host({ + id: "host_offline", + name: "Offline Mac", + status: "disconnected", +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("ProjectPathDialog machine selection", () => { + it("creates a project from a folder on the selected connected machine", () => { + const onSubmit = vi.fn(); + render( + , + ); + + expect( + (screen.getByRole("radio", { name: "atum" }) as HTMLInputElement).checked, + ).toBe(true); + expect( + screen + .getByRole("radio", { name: "Offline Mac" }) + .hasAttribute("disabled"), + ).toBe(true); + + fireEvent.click(screen.getByRole("radio", { name: "Kunst" })); + fireEvent.click( + screen.getByRole("button", { name: "Choose folder on host_kunst" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Add project" })); + + expect(onSubmit).toHaveBeenCalledWith( + { kind: "create" }, + "/Users/amadad/projects/reachy_mini", + "host_kunst", + ); + }); + + it("preserves the direct single-machine folder flow", () => { + const onSubmit = vi.fn(); + render( + , + ); + + expect(screen.queryByRole("radiogroup", { name: "Machine" })).toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Choose folder on host_atum" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Add project" })); + + expect(onSubmit).toHaveBeenCalledWith( + { kind: "create" }, + "/home/deploy/repos/givecare", + "host_atum", + ); + }); +}); diff --git a/apps/app/src/components/dialogs/ProjectPathDialog.tsx b/apps/app/src/components/dialogs/ProjectPathDialog.tsx index ad90dad7a9..68b8a1620f 100644 --- a/apps/app/src/components/dialogs/ProjectPathDialog.tsx +++ b/apps/app/src/components/dialogs/ProjectPathDialog.tsx @@ -3,6 +3,7 @@ import { deriveProjectNameFromPath, getProjectPathValidationMessage, normalizeProjectPathInput, + type Host, } from "@bb/domain"; import type { HostPlatform } from "@bb/host-daemon-contract"; import { Button } from "@bb/shared-ui/button"; @@ -15,7 +16,9 @@ import { DialogTitle, } from "@bb/shared-ui/dialog"; import { Input } from "@bb/shared-ui/input"; +import { cn } from "@bb/shared-ui/lib/utils"; import { RemotePathBrowser } from "@/components/dialogs/RemotePathBrowser"; +import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; export type ProjectPathDialogTarget = @@ -37,6 +40,7 @@ export type ProjectPathDialogTarget = export type ProjectPathDialogSubmitHandler = ( target: ProjectPathDialogTarget, path: string, + hostId: string | null, ) => Promise | void; interface ProjectPathDialogProps { @@ -45,6 +49,7 @@ interface ProjectPathDialogProps { platform: HostPlatform | null; hostId: string | null; hostName: string | null; + hosts?: readonly Host[]; onOpenChange: (open: boolean) => void; onSubmit: ProjectPathDialogSubmitHandler; } @@ -55,6 +60,7 @@ export function ProjectPathDialog({ platform, hostId, hostName, + hosts, onOpenChange, onSubmit, }: ProjectPathDialogProps) { @@ -69,6 +75,7 @@ export function ProjectPathDialog({ platform={platform} hostId={hostId} hostName={hostName} + hosts={hosts} onSubmit={onSubmit} /> ) : null} @@ -83,6 +90,7 @@ export interface ProjectPathDialogContentProps { platform: HostPlatform | null; hostId: string | null; hostName: string | null; + hosts?: readonly Host[]; onSubmit: ProjectPathDialogSubmitHandler; } @@ -139,10 +147,31 @@ export function ProjectPathDialogContent({ platform, hostId, hostName, + hosts, onSubmit, }: ProjectPathDialogContentProps) { const inputId = useId(); const isPointerCoarse = usePointerCoarse(); + const machineOptions = target.kind === "create" ? hosts : undefined; + const firstConnectedHostId = machineOptions?.find( + (host) => host.status === "connected", + )?.id; + const initialHostId = + hostId !== null && + (machineOptions === undefined || + machineOptions.some( + (host) => host.id === hostId && host.status === "connected", + )) + ? hostId + : (firstConnectedHostId ?? hostId); + const [selectedHostId, setSelectedHostId] = useState(initialHostId); + const selectedHost = machineOptions?.find( + (host) => host.id === selectedHostId, + ); + const selectedHostName = selectedHost?.name ?? hostName; + const selectedHostConnected = + selectedHost === undefined || selectedHost.status === "connected"; + const showMachinePicker = (machineOptions?.length ?? 0) > 1; // No-host fallback only: the browser owns the path when a host is present. const [manualPath, setManualPath] = useState( target.kind === "update" ? target.currentPath : "", @@ -153,13 +182,13 @@ export function ProjectPathDialogContent({ const [validationMessage, setValidationMessage] = useState( null, ); - const copy = getPlatformCopy(platform, hostName); + const copy = getPlatformCopy(platform, selectedHostName); const placeholder = target.kind === "update" ? target.currentPath || copy.placeholder : copy.placeholder; - const selectedPath = hostId + const selectedPath = selectedHostId ? browserDirectory : normalizeProjectPathInput(manualPath) || null; const derivedProjectName = selectedPath @@ -195,7 +224,7 @@ export function ProjectPathDialogContent({ return; } - void onSubmit(target, normalizedPath); + void onSubmit(target, normalizedPath, selectedHostId); }; return ( @@ -203,20 +232,67 @@ export function ProjectPathDialogContent({ {getDialogTitle(target.kind)} - {hostId + {selectedHostId ? `Browse to the project folder${ - hostName ? ` on ${hostName}` : "" + selectedHostName ? ` on ${selectedHostName}` : "" }, or edit the path directly.` : copy.description}
- {hostId ? ( + {showMachinePicker ? ( +
+ {machineOptions?.map((host) => { + const connected = host.status === "connected"; + const selected = host.id === selectedHostId; + return ( + + ); + })} +
+ ) : null} + {selectedHostId ? ( ) : ( ) : null} - diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx index 4db1b34a67..9e4d7df5a1 100644 --- a/apps/app/src/components/layout/AppLayout.tsx +++ b/apps/app/src/components/layout/AppLayout.tsx @@ -1111,6 +1111,7 @@ export function AppLayout({ children }: AppLayoutProps) { platform={quickCreateProject.platform} hostId={quickCreateProject.hostId} hostName={quickCreateProject.hostName} + hosts={quickCreateProject.hosts} onOpenChange={quickCreateProject.projectPathDialog.onOpenChange} onSubmit={quickCreateProject.submitProjectPath} /> diff --git a/apps/app/src/hooks/useLocalPathPicker.tsx b/apps/app/src/hooks/useLocalPathPicker.tsx index 0f1d88f492..d934da0959 100644 --- a/apps/app/src/hooks/useLocalPathPicker.tsx +++ b/apps/app/src/hooks/useLocalPathPicker.tsx @@ -77,9 +77,13 @@ export function useLocalPathPicker({ const closeDialog = projectPathDialog.onClose; const submitPath = useCallback( - (path: string, target: ProjectPathDialogTarget) => { - if (isPending || !hostId) return; - submit({ path, hostId, target, closeDialog }); + ( + path: string, + target: ProjectPathDialogTarget, + selectedHostId: string | null = hostId, + ) => { + if (isPending || !selectedHostId) return; + submit({ path, hostId: selectedHostId, target, closeDialog }); }, [closeDialog, hostId, isPending, submit], ); @@ -118,8 +122,8 @@ export function useLocalPathPicker({ ); const submitProjectPath = useCallback( - (target, path) => { - submitPath(path, target); + (target, path, selectedHostId) => { + submitPath(path, target, selectedHostId); }, [submitPath], ); diff --git a/apps/app/src/hooks/useQuickCreateProject.test.tsx b/apps/app/src/hooks/useQuickCreateProject.test.tsx new file mode 100644 index 0000000000..c1bce2d5ca --- /dev/null +++ b/apps/app/src/hooks/useQuickCreateProject.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook } from "@testing-library/react"; +import type { Host } from "@bb/domain"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useQuickCreateProject } from "./useQuickCreateProject"; + +const mocks = vi.hoisted(() => ({ + hosts: [] as Host[], + mutate: vi.fn(), + navigate: vi.fn(), + onClose: vi.fn(), + onOpen: vi.fn(), + onOpenChange: vi.fn(), + openPicker: vi.fn(), + setRootComposeProjectId: vi.fn(), +})); + +vi.mock("react-router-dom", () => ({ + useLocation: () => ({ pathname: "/" }), + useNavigate: () => mocks.navigate, +})); + +vi.mock("@/hooks/mutations/project-mutations", () => ({ + useCreateProject: () => ({ isPending: false, mutate: mocks.mutate }), +})); + +vi.mock("@/hooks/queries/host-queries", () => ({ + useHosts: () => ({ data: mocks.hosts }), +})); + +vi.mock("@/hooks/useLocalPathPicker", () => ({ + useLocalPathPicker: () => ({ + isAvailable: true, + hostId: "host_atum", + hostName: "atum", + openPicker: mocks.openPicker, + platform: "linux", + projectPathDialog: { + isOpen: false, + onClose: mocks.onClose, + onOpen: mocks.onOpen, + onOpenChange: mocks.onOpenChange, + target: null, + }, + submitProjectPath: vi.fn(), + }), +})); + +vi.mock("@/lib/root-compose-selection", () => ({ + useSetRootComposeProjectId: () => mocks.setRootComposeProjectId, +})); + +function host(id: string, name: string): Host { + return { + id, + name, + type: "persistent", + status: "connected", + lastSeenAt: null, + lastRejectedProtocolVersion: null, + createdAt: 0, + updatedAt: 0, + }; +} + +beforeEach(() => { + mocks.hosts = [host("host_atum", "atum")]; +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("useQuickCreateProject", () => { + it("opens the dialog instead of the native primary-host picker when several machines exist", () => { + mocks.hosts = [host("host_atum", "atum"), host("host_thoth", "Thoth")]; + const { result } = renderHook(() => useQuickCreateProject()); + + act(() => result.current.openCreateDialog()); + + expect(mocks.onOpen).toHaveBeenCalledWith({ kind: "create" }); + expect(mocks.openPicker).not.toHaveBeenCalled(); + expect(result.current.hosts.map((item) => item.id)).toEqual([ + "host_atum", + "host_thoth", + ]); + }); + + it("keeps the current picker behavior with one machine", () => { + const { result } = renderHook(() => useQuickCreateProject()); + + act(() => result.current.openCreateDialog()); + + expect(mocks.openPicker).toHaveBeenCalledWith({ kind: "create" }); + expect(mocks.onOpen).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/hooks/useQuickCreateProject.tsx b/apps/app/src/hooks/useQuickCreateProject.tsx index ed3955bccb..6a189bfbca 100644 --- a/apps/app/src/hooks/useQuickCreateProject.tsx +++ b/apps/app/src/hooks/useQuickCreateProject.tsx @@ -6,9 +6,10 @@ import { type ReactNode, } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import { deriveProjectNameFromPath } from "@bb/domain"; +import { deriveProjectNameFromPath, type Host } from "@bb/domain"; import type { HostPlatform } from "@bb/host-daemon-contract"; import { useCreateProject } from "@/hooks/mutations/project-mutations"; +import { useHosts } from "@/hooks/queries/host-queries"; import { useLocalPathPicker, type LocalPathSubmitParams, @@ -36,15 +37,18 @@ export interface QuickCreateProjectController { platform: HostPlatform | null; hostId: string | null; hostName: string | null; + hosts: readonly Host[]; projectPathDialog: QuickCreateProjectDialogState; submitProjectPath: ProjectPathDialogSubmitHandler; } const quickCreateProjectContext = createContext(null); +const EMPTY_HOSTS: readonly Host[] = []; export function useQuickCreateProject(): QuickCreateProjectController { const { mutate, isPending } = useCreateProject(); + const hosts = useHosts().data ?? EMPTY_HOSTS; const navigate = useNavigate(); const location = useLocation(); const setRootComposeProjectId = useSetRootComposeProjectId(); @@ -81,8 +85,12 @@ export function useQuickCreateProject(): QuickCreateProjectController { }); const openCreateDialog = useCallback(() => { + if (hosts.length > 1) { + controller.projectPathDialog.onOpen({ kind: "create" }); + return; + } controller.openPicker({ kind: "create" }); - }, [controller]); + }, [controller, hosts.length]); return useMemo( () => ({ @@ -92,10 +100,11 @@ export function useQuickCreateProject(): QuickCreateProjectController { platform: controller.platform, hostId: controller.hostId, hostName: controller.hostName, + hosts, projectPathDialog: controller.projectPathDialog, submitProjectPath: controller.submitProjectPath, }), - [controller, isPending, openCreateDialog], + [controller, hosts, isPending, openCreateDialog], ); } diff --git a/docs/multiple-devices.md b/docs/multiple-devices.md index 8fafbabf4d..eb0f79e8d6 100644 --- a/docs/multiple-devices.md +++ b/docs/multiple-devices.md @@ -1,3 +1,5 @@ + + # Using bb on multiple devices There are two separate ways to use more than one device with bb: @@ -85,7 +87,9 @@ service. After it connects: -1. Add that machine's project path or clone source in project settings. +1. To create a project from that machine, choose New project, select the + machine, and browse to its folder. To map an existing project there instead, + add its path or clone source in project settings. 2. Select the machine when creating a thread, or use `bb thread spawn --machine ...`. 3. Inspect enrolled machines with `bb machine list`. From 89fb3ab9665793044f9784b027f212476add0d33 Mon Sep 17 00:00:00 2001 From: Ali Madad Date: Sat, 25 Jul 2026 03:09:27 +0000 Subject: [PATCH 2/9] fix: parse Docs YAML frontmatter --- official-plugins/docs/README.md | 4 ++ official-plugins/docs/app.test.tsx | 53 ++++++++++++++ official-plugins/docs/app.tsx | 5 +- official-plugins/docs/markdown-document.ts | 66 +++++++++++++++++ official-plugins/docs/package.json | 3 +- official-plugins/docs/server.test.ts | 84 ++++++++++++++++++++++ official-plugins/docs/server.ts | 66 +++++++++++++---- official-plugins/docs/tsconfig.json | 1 + pnpm-lock.yaml | 3 + 9 files changed, 268 insertions(+), 17 deletions(-) create mode 100644 official-plugins/docs/markdown-document.ts diff --git a/official-plugins/docs/README.md b/official-plugins/docs/README.md index 3549063131..abb173174c 100644 --- a/official-plugins/docs/README.md +++ b/official-plugins/docs/README.md @@ -27,6 +27,10 @@ provider, and directive are all Docs. `.markdown` files, so it can be selected under Settings → File openers or chosen from a file link's Open with menu. Workspace and absolute host files retain compare-and-swap saves even when they are outside a Docs vault. +- **YAML frontmatter:** fenced frontmatter supplies the document title when it + has a string `title`, stays out of the rendered body and search preview, and + is preserved byte-for-byte when the rich editor saves body changes. Docs + also leaves frontmatter-managed filenames unchanged on save. - **Tables:** GitHub-flavored Markdown tables render as editable cells. Use Tab and Shift+Tab to move between cells (Tab from the final cell adds a row), and drag column boundaries to resize them. Saves remain portable Markdown. diff --git a/official-plugins/docs/app.test.tsx b/official-plugins/docs/app.test.tsx index 509d245c73..1776aa4766 100644 --- a/official-plugins/docs/app.test.tsx +++ b/official-plugins/docs/app.test.tsx @@ -429,6 +429,59 @@ describe("Docs nav panel", () => { }); }); + it("hides and preserves YAML frontmatter when editing a document", async () => { + const frontmatter = [ + "---\r\n", + "title: Wiki page\r\n", + "type: knowledge\r\n", + "---\r\n", + ].join(""); + const saveNote = vi.fn((_input: unknown) => ({ + outcome: "written", + sha256: "next-sha", + })); + const slot = renderSlot( + app.navPanels[0]!, + { subPath: "personal/wiki-page.md" }, + { + rpc: { + listNotes: () => + listNotesResult([ + { + path: "wiki-page.md", + title: "Wiki page", + preview: "Original body.", + modifiedAtMs: 1, + }, + ]), + readNote: () => ({ + content: `${frontmatter}# Wiki page\r\n\r\nOriginal body.`, + sha256: "sha", + }), + preparePreview: () => preview, + renameToTitle: () => ({ path: "wiki-page.md" }), + saveNote, + }, + }, + ); + + const body = await slot.findByText("Original body."); + const editor = slot.container.querySelector(".tiptap"); + expect(editor?.textContent).not.toContain("type: knowledge"); + expect(editor?.querySelector("hr")).toBeNull(); + + body.textContent = "Edited body."; + fireEvent.input(body); + await waitFor(() => expect(saveNote).toHaveBeenCalled(), { + timeout: 2_000, + }); + expect(saveNote.mock.calls.at(-1)?.[0]).toMatchObject({ + content: expect.stringMatching( + /^---\r\ntitle: Wiki page\r\ntype: knowledge\r\n---\r\n# Wiki page\n\nEdited body\./, + ), + }); + }); + it("renders nested folders, images, and sandboxed HTML directives", async () => { const slot = renderSlot( app.navPanels[0]!, diff --git a/official-plugins/docs/app.tsx b/official-plugins/docs/app.tsx index 1bff776f81..1a701131c0 100644 --- a/official-plugins/docs/app.tsx +++ b/official-plugins/docs/app.tsx @@ -20,6 +20,7 @@ import { type PluginThreadPanelProps, } from "@bb/plugin-sdk/app"; import type { docsRpcContract } from "./server.js"; +import { parseMarkdownDocument } from "./markdown-document.js"; import { Editor, Extension, @@ -503,6 +504,7 @@ function TiptapEditor({ useEffect(() => { ensureEditorStyles(); if (!rootRef.current) return; + const markdownDocument = parseMarkdownDocument(initialValue); let editor: Editor; const upload = async (file: File) => { if (!file.type.startsWith("image/")) return false; @@ -539,7 +541,7 @@ function TiptapEditor({ linkify: true, }), ], - content: displayMarkdown(initialValue, previewBaseUrl, notePath), + content: displayMarkdown(markdownDocument.body, previewBaseUrl, notePath), autofocus: "end", editorProps: { handlePaste(_view, event) { @@ -562,6 +564,7 @@ function TiptapEditor({ }, }); const getMarkdown = () => + markdownDocument.frontmatter + storedMarkdown( editor.storage.markdown.getMarkdown(), previewBaseUrl, diff --git a/official-plugins/docs/markdown-document.ts b/official-plugins/docs/markdown-document.ts new file mode 100644 index 0000000000..1e15e7d8b3 --- /dev/null +++ b/official-plugins/docs/markdown-document.ts @@ -0,0 +1,66 @@ +import { parse } from "yaml"; + +export interface MarkdownDocument { + frontmatter: string; + body: string; + title: string | null; +} + +interface MarkdownLine { + start: number; + nextStart: number; + text: string; +} + +function readLine(content: string, start: number): MarkdownLine { + const newline = content.indexOf("\n", start); + const end = newline === -1 ? content.length : newline; + const textEnd = end > start && content[end - 1] === "\r" ? end - 1 : end; + return { + start, + nextStart: newline === -1 ? content.length : newline + 1, + text: content.slice(start, textEnd), + }; +} + +function parseFrontmatterTitle(source: string): string | null { + try { + const metadata: unknown = parse(source, { maxAliasCount: 20 }); + if ( + typeof metadata !== "object" || + metadata === null || + Array.isArray(metadata) + ) { + return null; + } + const title = (metadata as Record).title; + return typeof title === "string" && title.trim() ? title.trim() : null; + } catch { + return null; + } +} + +export function parseMarkdownDocument(content: string): MarkdownDocument { + const firstLineStart = content.startsWith("\uFEFF") ? 1 : 0; + const firstLine = readLine(content, firstLineStart); + if (firstLine.text !== "---") { + return { frontmatter: "", body: content, title: null }; + } + + let lineStart = firstLine.nextStart; + while (lineStart < content.length) { + const line = readLine(content, lineStart); + if (line.text === "---" || line.text === "...") { + const frontmatterSource = content.slice(firstLine.nextStart, line.start); + return { + frontmatter: content.slice(0, line.nextStart), + body: content.slice(line.nextStart), + title: parseFrontmatterTitle(frontmatterSource), + }; + } + if (line.nextStart === lineStart) break; + lineStart = line.nextStart; + } + + return { frontmatter: "", body: content, title: null }; +} diff --git a/official-plugins/docs/package.json b/official-plugins/docs/package.json index 6d5cf1a9d5..0343c793a2 100644 --- a/official-plugins/docs/package.json +++ b/official-plugins/docs/package.json @@ -1,6 +1,6 @@ { "name": "bb-plugin-simple-notes", - "version": "0.2.0", + "version": "0.2.1", "description": "Create and edit Markdown documents across local and connected-host vaults.", "private": true, "license": "MIT", @@ -80,6 +80,7 @@ "@tiptap/pm": "^2.27.2", "@tiptap/starter-kit": "^2.27.2", "tiptap-markdown": "^0.8.10", + "yaml": "^2.8.2", "zod": "^4.3.6" } } diff --git a/official-plugins/docs/server.test.ts b/official-plugins/docs/server.test.ts index 601835f322..a0c32e009a 100644 --- a/official-plugins/docs/server.test.ts +++ b/official-plugins/docs/server.test.ts @@ -375,6 +375,70 @@ describe("Docs mention provider", () => { ]); }); + it("ignores YAML frontmatter when deriving note titles and previews", async () => { + const { harness } = await loadNotebook({ + "defensibility.md": [ + "---", + "type: knowledge", + "summary: Product strategy metadata", + "---", + "# AI application-layer defensibility", + "", + "Durable product strategy.", + ].join("\n"), + }); + const provider = harness.registrations.mentionProviders[0]!; + + await expect( + provider.search({ + trigger: "@", + query: "defensibility", + projectId: null, + threadId: null, + }), + ).resolves.toEqual([ + { + id: "personal:defensibility.md", + title: "AI application-layer defensibility", + subtitle: "Personal · Durable product strategy.", + icon: "FileText", + }, + ]); + }); + + it("uses a YAML frontmatter title when the body has no document heading", async () => { + const { harness } = await loadNotebook({ + "california-report.md": [ + "---", + 'title: "6th Annual Report: Evaluation of California\'s Caregiver Services"', + "type: knowledge", + "---", + "## Key findings used in wiki", + "", + "CareNav assessments identify unmet caregiver needs.", + ].join("\n"), + }); + const provider = harness.registrations.mentionProviders[0]!; + + await expect( + provider.search({ + trigger: "@", + query: "california", + projectId: null, + threadId: null, + }), + ).resolves.toEqual([ + { + id: "personal:california-report.md", + title: + "6th Annual Report: Evaluation of California's Caregiver Services", + subtitle: + "Personal · CareNav assessments identify unmet caregiver needs.", + icon: "FileText", + }, + ]); + }); + it("resolves the note's current content at send time", async () => { const { harness } = await loadNotebook({ "ideas.md": "# Fresh Ideas\n\nBuild the mention flow.", @@ -422,6 +486,26 @@ describe("Docs vault operations", () => { ).rejects.toThrow('unknown setting "directory"'); }); + it("keeps a frontmatter-managed document at its existing path", async () => { + const { harness } = await loadNotebook({ + "stable-wiki-slug.md": [ + "---", + "title: A different display title", + "type: knowledge", + "---", + "# A different display title", + ].join("\n"), + }); + + await expect( + harness.callRpc("renameToTitle", { + vaultId: "personal", + path: "stable-wiki-slug.md", + }), + ).resolves.toEqual({ path: "stable-wiki-slug.md" }); + expect(harness.sdk.callsTo("files.move")).toEqual([]); + }); + it("registers the agent-discoverable Docs CLI", async () => { const { harness } = await loadNotebook({ "plan.md": "# Plan" }); expect(harness.registrations.cli).toMatchObject({ diff --git a/official-plugins/docs/server.ts b/official-plugins/docs/server.ts index dc63704126..82c68a8b69 100644 --- a/official-plugins/docs/server.ts +++ b/official-plugins/docs/server.ts @@ -2,6 +2,7 @@ import { watch, type FSWatcher } from "node:fs"; import os from "node:os"; import path from "node:path"; +import { parseMarkdownDocument } from "./markdown-document.js"; import { defineRpcContract, type BbPluginApi, @@ -557,22 +558,54 @@ function cleanLine(line: string): string { .trim(); } -function deriveTitle(content: string, fallback: string): string { - for (const line of content.split("\n")) { - const stripped = cleanLine(line); - if (stripped && !stripped.startsWith("::html{")) - return stripped.slice(0, 120); - } - return fallback; +function markdownHeadingLevel(line: string): number | null { + const match = line.match(/^\s{0,3}(#{1,6})(?:[\t ]+|$)/); + return match ? match[1]!.length : null; } -function derivePreview(content: string, title: string): string { - return content - .split("\n") - .map(cleanLine) - .filter((line) => line && line !== title && !line.startsWith("::html{")) +function summarizeMarkdown( + content: string, + fallback: string, +): { title: string; preview: string } { + const document = parseMarkdownDocument(content); + const lines = document.body.split("\n"); + const cleanedLines = lines.map(cleanLine); + const firstContentIndex = cleanedLines.findIndex( + (line) => line && !line.startsWith("::html{"), + ); + const documentHeadingIndex = document.frontmatter + ? lines.findIndex((line) => markdownHeadingLevel(line) === 1) + : -1; + const titleLineIndex = document.title + ? -1 + : documentHeadingIndex >= 0 + ? documentHeadingIndex + : firstContentIndex; + const title = ( + document.title ?? + (titleLineIndex >= 0 ? cleanedLines[titleLineIndex] : null) ?? + fallback + ).slice(0, 120); + const firstHeadingIndex = lines.findIndex( + (line) => markdownHeadingLevel(line) !== null, + ); + const previewHeadingIndex = + titleLineIndex >= 0 ? titleLineIndex : firstHeadingIndex; + const preview = cleanedLines + .filter( + (line, index) => + index !== previewHeadingIndex && + line && + line !== title && + !line.startsWith("::html{"), + ) .join(" ") .slice(0, PREVIEW_LENGTH); + return { title, preview }; +} + +function deriveTitle(content: string, fallback: string): string { + return summarizeMarkdown(content, fallback).title; } function kebabCase(text: string): string { @@ -833,11 +866,11 @@ export default async function plugin(bb: BbPluginApi) { rootPath: vault.rootPath, }); const fallback = path.posix.basename(notePath).replace(/\.md$/i, ""); - const title = deriveTitle(file.content, fallback); + const summary = summarizeMarkdown(file.content, fallback); notes.push({ path: notePath, - title, - preview: derivePreview(file.content, title), + title: summary.title, + preview: summary.preview, modifiedAtMs: file.modifiedAtMs ?? 0, }); } catch { @@ -1541,6 +1574,9 @@ export default async function plugin(bb: BbPluginApi) { const vaultId = input.vaultId; const currentPath = requireVaultPath(input.path, { extension: ".md" }); const file = await readFile(vaultId, currentPath); + if (parseMarkdownDocument(file.content).frontmatter) { + return { path: currentPath }; + } const base = kebabCase(deriveTitle(file.content, "")); if (!base) return { path: currentPath }; const parent = path.posix.dirname(currentPath); diff --git a/official-plugins/docs/tsconfig.json b/official-plugins/docs/tsconfig.json index efe0f33512..a2f02dfd24 100644 --- a/official-plugins/docs/tsconfig.json +++ b/official-plugins/docs/tsconfig.json @@ -13,6 +13,7 @@ "include": [ "server.ts", "server.test.ts", + "markdown-document.ts", "app.tsx", "app.test.tsx", "vitest.config.ts" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a4834e2b5..c6c3d1317f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1094,6 +1094,9 @@ importers: tiptap-markdown: specifier: ^0.8.10 version: 0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) + yaml: + specifier: ^2.8.2 + version: 2.8.2 zod: specifier: 4.3.6 version: 4.3.6 From 8ee14a20d5949c9e66697a18ac6038d5e4082e3a Mon Sep 17 00:00:00 2001 From: Bhuvan Singla Date: Sat, 25 Jul 2026 15:53:42 +0530 Subject: [PATCH 3/9] fix(app): stop remark-directive from eating colons in assistant messages remark-directive (enabled for plugin directives like ::inline-vis{...}) also parses inline :name and block :::name directives, so any colon before a letter or digit (9:30, key:value, :D) became a directive node. remarkMessageDirectives only handled leaf (::name) directives; text and container directives fell through to mdast-util-to-hast, which renders an unknown directive as an empty
. Inside a paragraph that split the line and dropped the directive's text. Handle all three kinds: leaf directives mount as before; text and container directives are rewritten back to their literal source (text inline, container as a paragraph). Display-only change. --- .../ui/markdown-message-directives.test.ts | 5 + .../ui/markdown-message-directives.test.tsx | 50 +++++++ .../ui/markdown-message-directives.tsx | 122 ++++++++++++++---- 3 files changed, 150 insertions(+), 27 deletions(-) diff --git a/apps/app/src/components/ui/markdown-message-directives.test.ts b/apps/app/src/components/ui/markdown-message-directives.test.ts index 27dad6b4c5..358873b04e 100644 --- a/apps/app/src/components/ui/markdown-message-directives.test.ts +++ b/apps/app/src/components/ui/markdown-message-directives.test.ts @@ -81,4 +81,9 @@ describe("normalizeDirectiveAttributes / reconstructDirectiveSource", () => { reconstructDirectiveSource("inline-vis", { file: 'demo"x".html' }), ).toBe('::inline-vis{file="demo\\"x\\".html"}'); }); + + it("uses the supplied marker for non-leaf directive kinds", () => { + expect(reconstructDirectiveSource("30", {}, ":")).toBe(":30"); + expect(reconstructDirectiveSource("note", {}, ":::")).toBe(":::note"); + }); }); diff --git a/apps/app/src/components/ui/markdown-message-directives.test.tsx b/apps/app/src/components/ui/markdown-message-directives.test.tsx index 3fe9a6700a..9a2eda2f3f 100644 --- a/apps/app/src/components/ui/markdown-message-directives.test.tsx +++ b/apps/app/src/components/ui/markdown-message-directives.test.tsx @@ -215,6 +215,56 @@ describe("MarkdownPreview message directives", () => { expect(screen.getByText('::inline-vis{file="still-streaming')).toBeTruthy(); }); + it("renders incidental prose colons literally without dropping text", () => { + // Regression: `remark-directive` parses `13:30`, `a:b`, and `:D` as text + // directives. Before the fix these fell through to mdast-util-to-hast as + // empty block `
`s, which split the paragraph and deleted the colon + // text — e.g. "next run 13:30." rendered as "next run 13" + a stray line + // break + ". ...". + const registry = buildMessageDirectiveRegistry([ + slot({ id: "inline-vis", pluginId: "demo", component: InlineVis }), + ]); + const sentence = + "Watchdog runs every 30 min UTC, next run 13:30. It checks a:b and :D too."; + const { container } = render( + , + ); + + const paragraphs = container.querySelectorAll("p"); + expect(paragraphs).toHaveLength(1); + // Exact text preserved: no dropped `:30` / `:b` / `:D`. + expect(paragraphs[0]?.textContent).toBe(sentence); + // No block element was injected mid-paragraph to break the line. + expect(paragraphs[0]?.querySelector("div")).toBeNull(); + expect(screen.queryByTestId("inline-vis")).toBeNull(); + }); + + it("renders a container directive as literal source text", () => { + const registry = buildMessageDirectiveRegistry([ + slot({ id: "inline-vis", pluginId: "demo", component: InlineVis }), + ]); + render( + , + ); + + expect(screen.queryByTestId("inline-vis")).toBeNull(); + expect(screen.getByText(/:::note/)).toBeTruthy(); + }); + it("falls back to original directive text when the plugin component crashes", () => { const registry = buildMessageDirectiveRegistry([ slot({ id: "inline-vis", pluginId: "demo", component: CrashVis }), diff --git a/apps/app/src/components/ui/markdown-message-directives.tsx b/apps/app/src/components/ui/markdown-message-directives.tsx index f598e17cad..a65392f1a8 100644 --- a/apps/app/src/components/ui/markdown-message-directives.tsx +++ b/apps/app/src/components/ui/markdown-message-directives.tsx @@ -76,8 +76,18 @@ export interface ResolvedMessageDirectives { registry: MessageDirectiveRegistry; } -interface LeafDirectiveNode { - type: "leafDirective"; +/** + * `remark-directive` emits three node kinds from a single `:` grammar. Only the + * leaf form (`::name`) mounts a plugin component; the text and container forms + * are handled solely to rewrite them back to literal prose. + */ +type DirectiveNodeType = + | "textDirective" + | "leafDirective" + | "containerDirective"; + +interface DirectiveNode { + type: DirectiveNodeType; name?: string; attributes?: Record | null; children?: unknown[]; @@ -87,6 +97,13 @@ interface LeafDirectiveNode { }; } +/** Source marker for each directive kind (used to reconstruct literal text). */ +const DIRECTIVE_MARKERS: Record = { + textDirective: ":", + leafDirective: "::", + containerDirective: ":::", +}; + interface RemarkMessageDirectiveFile { value: unknown; } @@ -177,22 +194,24 @@ export function normalizeDirectiveAttributes( export function reconstructDirectiveSource( name: string, attributes: Readonly>, + marker = "::", ): string { const keys = Object.keys(attributes); if (keys.length === 0) { - return `::${name}`; + return `${marker}${name}`; } const body = keys .map((key) => `${key}=${JSON.stringify(attributes[key] ?? "")}`) .join(" "); - return `::${name}{${body}}`; + return `${marker}${name}{${body}}`; } function directiveSourceFromNode( - node: LeafDirectiveNode, + node: DirectiveNode, markdownSource: string, name: string, attributes: Readonly>, + marker: string, ): string { const start = node.position?.start?.offset; const end = node.position?.end?.offset; @@ -205,7 +224,7 @@ function directiveSourceFromNode( ) { return markdownSource.slice(start, end); } - return reconstructDirectiveSource(name, attributes); + return reconstructDirectiveSource(name, attributes, marker); } function messageDirectiveMountNode(index: number): RootContent { @@ -221,25 +240,46 @@ function messageDirectiveMountNode(index: number): RootContent { }; } -function literalDirectiveNode(source: string): RootContent { +function literalDirectiveNode( + type: DirectiveNodeType, + source: string, +): RootContent { + // A text directive lives inside phrasing content (e.g. mid-paragraph), so it + // must be rewritten to an inline text node — not a block paragraph, which + // would split the surrounding text onto its own line. Leaf and container + // directives are block-level, so a paragraph is the correct literal stand-in. + if (type === "textDirective") { + return { type: "text", value: source }; + } return { type: "paragraph", children: [{ type: "text", value: source }], }; } -function isLeafDirective(node: unknown): node is LeafDirectiveNode { - return ( - typeof node === "object" && - node !== null && - (node as { type?: unknown }).type === "leafDirective" - ); +function asDirectiveNode(node: unknown): DirectiveNode | null { + if (typeof node !== "object" || node === null) { + return null; + } + const type = (node as { type?: unknown }).type; + if ( + type === "textDirective" || + type === "leafDirective" || + type === "containerDirective" + ) { + return node as DirectiveNode; + } + return null; } /** - * Remark transformer: rewrite recognized leaf directives into indexed custom - * elements; leave unknown / collision / over-limit directives as literal - * source text. Must run after `remark-directive` has produced leaf nodes. + * Remark transformer: rewrite recognized leaf directives (`::name`) into indexed + * custom elements; leave unknown / collision / over-limit leaf directives as + * literal source text. Text directives (`:name`) and container directives + * (`:::name`) never mount and are always rewritten to their literal source, so + * incidental prose colons (`13:30`, `key:value`, `:D`) render verbatim instead + * of collapsing into `mdast-util-to-hast`'s empty-`
` fallback. Must run + * after `remark-directive` has produced the directive nodes. * * Mutates `mounts` in document order so indices stay stable when later text * streams in after an already-complete directive. @@ -256,35 +296,63 @@ export function remarkMessageDirectives(args: { // array reference does not accumulate duplicate indices. mounts.length = 0; visit(tree, (node, index, parent: Parent | undefined) => { - if ( - !isLeafDirective(node) || - parent === undefined || - index === undefined - ) { + const directive = asDirectiveNode(node); + if (directive === null || parent === undefined || index === undefined) { return; } - const name = typeof node.name === "string" ? node.name : ""; - const attributes = normalizeDirectiveAttributes(node.attributes); + const marker = DIRECTIVE_MARKERS[directive.type]; + const name = typeof directive.name === "string" ? directive.name : ""; + const attributes = normalizeDirectiveAttributes(directive.attributes); const source = directiveSourceFromNode( - node, + directive, markdownSource, name, attributes, + marker, ); + // Only leaf directives (`::name`) mount a plugin component. Text + // directives (`:name`) and container directives (`:::name`) are almost + // always incidental parses of ordinary prose — a time like `13:30`, a + // `key:value` pair, an emoticon like `:D`. Left in the tree they reach + // `mdast-util-to-hast`, which renders an unknown directive as an empty + // block `
`; nested inside a paragraph that both drops the directive's + // text and injects a stray line break. Rewrite them back to literal + // source so the prose renders verbatim. + if (directive.type !== "leafDirective") { + parent.children.splice( + index, + 1, + literalDirectiveNode(directive.type, source), + ); + return index; + } + if (name.length === 0) { - parent.children.splice(index, 1, literalDirectiveNode(source)); + parent.children.splice( + index, + 1, + literalDirectiveNode(directive.type, source), + ); return index; } const entry = registry.get(name); if (entry === undefined || entry.status === "collision") { - parent.children.splice(index, 1, literalDirectiveNode(source)); + parent.children.splice( + index, + 1, + literalDirectiveNode(directive.type, source), + ); return index; } if (mounts.length >= MESSAGE_DIRECTIVE_MOUNT_LIMIT) { - parent.children.splice(index, 1, literalDirectiveNode(source)); + parent.children.splice( + index, + 1, + literalDirectiveNode(directive.type, source), + ); return index; } From 4f7f058b530be985738190b72811313cee887e9a Mon Sep 17 00:00:00 2001 From: Ali Madad Date: Sat, 25 Jul 2026 03:49:54 +0000 Subject: [PATCH 4/9] fix: center Docs vault loading state --- official-plugins/docs/app.test.tsx | 18 ++++++++++++++++++ official-plugins/docs/app.tsx | 7 ++++++- official-plugins/docs/package.json | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/official-plugins/docs/app.test.tsx b/official-plugins/docs/app.test.tsx index 1776aa4766..476473e172 100644 --- a/official-plugins/docs/app.test.tsx +++ b/official-plugins/docs/app.test.tsx @@ -101,6 +101,24 @@ describe("Docs nav panel", () => { }); }); + it("keeps the vault loading state clear of top-left app chrome", () => { + const slot = renderSlot( + app.navPanels[0]!, + { subPath: "" }, + { rpc: { listNotes: () => new Promise(() => undefined) } }, + ); + + const loading = slot.getByText("Loading vaults…"); + expect(loading.className.split(/\s+/)).toEqual( + expect.arrayContaining([ + "flex", + "flex-1", + "items-center", + "justify-center", + ]), + ); + }); + it("moves the sidebar toggle into the shared panel header", async () => { const panel = app.navPanels[0]!; const slot = renderSlot( diff --git a/official-plugins/docs/app.tsx b/official-plugins/docs/app.tsx index 1a701131c0..4b40320d92 100644 --- a/official-plugins/docs/app.tsx +++ b/official-plugins/docs/app.tsx @@ -1944,7 +1944,12 @@ function NotesPanel({ subPath }: PluginNavPanelProps) { if (!data || !activeVaultId) return ( -
Loading vaults…
+
+ Loading vaults… +
); const selectedFolder = filePath ? dirname(filePath) : ""; diff --git a/official-plugins/docs/package.json b/official-plugins/docs/package.json index 0343c793a2..4d1dfaeba4 100644 --- a/official-plugins/docs/package.json +++ b/official-plugins/docs/package.json @@ -1,6 +1,6 @@ { "name": "bb-plugin-simple-notes", - "version": "0.2.1", + "version": "0.2.2", "description": "Create and edit Markdown documents across local and connected-host vaults.", "private": true, "license": "MIT", From dde1b195f5f3a9851ad4b288f8b3b582db254b08 Mon Sep 17 00:00:00 2001 From: Ali Madad Date: Sat, 25 Jul 2026 00:28:03 +0000 Subject: [PATCH 5/9] Demote skill-tier override dedup logs to debug Every catalog resolution (each thread turn and project-page load) logs one line per deduped skill; with a 71-skill inherited root that is ~30K journald lines/day of expected-precedence noise. --- apps/server/src/services/skills/injected-skills.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/services/skills/injected-skills.ts b/apps/server/src/services/skills/injected-skills.ts index cc793a8231..49b54fd05d 100644 --- a/apps/server/src/services/skills/injected-skills.ts +++ b/apps/server/src/services/skills/injected-skills.ts @@ -581,7 +581,7 @@ function excludeOverriddenBuiltins( if (!userClaimedNames.has(source.name)) { return true; } - logger.info( + logger.debug( { name: source.name, sourceRootPath: sourceRootPath(source), @@ -603,7 +603,7 @@ function excludeOverriddenLowerPriorityUserSources( if (!higherPriorityNames.has(source.name)) { return true; } - logger.info( + logger.debug( { name: source.name, sourceRootPath: sourceRootPath(source), From 10c6a5abec75bce9af39489400f621d3f29ec5ee Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Mon, 27 Jul 2026 17:18:56 +0000 Subject: [PATCH 6/9] Capture debug logs in the injected-skills test logger PR #870 demoted the skill-tier override dedup logs from info to debug, but the capturing logger in the injected-skills test stubbed debug out as a no-op, so the three precedence tests asserting on those messages saw an empty infos array and failed. Add a debugs capture alongside infos/warnings, wire debug through captureTo, and point the three override assertions at debugs. The assertions are preserved rather than dropped: they are the only coverage that precedence actually excludes the shadowed source. Co-authored-by: Ali Madad Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/test/skills/injected-skills.test.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/server/test/skills/injected-skills.test.ts b/apps/server/test/skills/injected-skills.test.ts index 02f3b95786..d659286625 100644 --- a/apps/server/test/skills/injected-skills.test.ts +++ b/apps/server/test/skills/injected-skills.test.ts @@ -28,6 +28,7 @@ interface CapturedLog { } interface CapturingLogger { + debugs: CapturedLog[]; infos: CapturedLog[]; logger: ServerLogger; warnings: CapturedLog[]; @@ -64,6 +65,7 @@ afterEach(async () => { }); function createCapturingLogger(): CapturingLogger { + const debugs: CapturedLog[] = []; const infos: CapturedLog[] = []; const warnings: CapturedLog[] = []; function captureTo(target: CapturedLog[]) { @@ -83,10 +85,11 @@ function createCapturingLogger(): CapturingLogger { }; } return { + debugs, infos, warnings, logger: { - debug: () => undefined, + debug: captureTo(debugs), error: () => undefined, info: captureTo(infos), warn: captureTo(warnings), @@ -360,7 +363,7 @@ describe("injected skill source discovery", () => { name: "bb-cli", description: "User override copy.", }); - const { logger, infos, warnings } = createCapturingLogger(); + const { logger, debugs, warnings } = createCapturingLogger(); const sources = await resolveInjectedSkillSources(logger, { builtinSkillsRootPath, @@ -376,7 +379,7 @@ describe("injected skill source discovery", () => { }), ]); expect(warnings).toEqual([]); - expect(infos).toEqual([ + expect(debugs).toEqual([ expect.objectContaining({ message: "Built-in injected skill overridden by user skill", }), @@ -397,7 +400,7 @@ describe("injected skill source discovery", () => { name: "stories", description: "Local stories skill.", }); - const { logger, infos, warnings } = createCapturingLogger(); + const { logger, debugs, warnings } = createCapturingLogger(); const sources = await resolveInjectedSkillSources(logger, { additionalSkillsRootPaths: [inheritedSkillsRootPath], @@ -414,7 +417,7 @@ describe("injected skill source discovery", () => { }), ]); expect(warnings).toEqual([]); - expect(infos).toEqual([ + expect(debugs).toEqual([ expect.objectContaining({ message: "Lower-priority injected skill overridden by higher-priority skill", @@ -437,7 +440,7 @@ describe("injected skill source discovery", () => { name: "stories", description: "Prod stories skill.", }); - const { logger, infos, warnings } = createCapturingLogger(); + const { logger, debugs, warnings } = createCapturingLogger(); const sources = await resolveInjectedSkillSources(logger, { additionalSkillsRootPaths: [parentSkillsRootPath, prodSkillsRootPath], @@ -454,7 +457,7 @@ describe("injected skill source discovery", () => { }), ]); expect(warnings).toEqual([]); - expect(infos).toEqual([ + expect(debugs).toEqual([ expect.objectContaining({ message: "Lower-priority injected skill overridden by higher-priority skill", From 4cec5e170cd633218662bc06a340b95d1656496d Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Mon, 27 Jul 2026 17:23:37 +0000 Subject: [PATCH 7/9] Harden the New project machine picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on the machine-selection change: - Gate the picker on connected machines, not every non-destroyed one. A single stale enrollment was enough to replace the native OS folder picker with the in-app remote browser, even though the offline machine could not be selected. - Treat a still-loading host list as "not single machine". The list query resolves independently of the local-daemon probe that enables the New project button, so a fast click could fall through to the native picker on the primary host and skip the machine choice entirely. - Stop the multi-machine/none-connected state from offering a submit that is silently dropped. The dialog now shows an offline empty state instead of the meaningless manual-path input and keeps submit disabled, and useLocalPathPicker takes the target host explicitly — the old default parameter only covered an omitted argument, never the explicit null the dialog sends when nothing is selected. - Drop the stray Diátaxis marker from docs/multiple-devices.md; it was the only occurrence in the repo and nothing reads it. - Add the machine picker and all-offline states to the dialog stories. Co-authored-by: Ali Madad Co-Authored-By: Claude Opus 5 (1M context) --- .../dialogs/ProjectPathDialog.stories.tsx | 48 +++++++- .../dialogs/ProjectPathDialog.test.tsx | 27 +++++ .../components/dialogs/ProjectPathDialog.tsx | 19 +++- .../app/src/hooks/useLocalPathPicker.test.tsx | 107 ++++++++++++++++++ apps/app/src/hooks/useLocalPathPicker.tsx | 13 ++- .../src/hooks/useQuickCreateProject.test.tsx | 38 ++++++- apps/app/src/hooks/useQuickCreateProject.tsx | 16 ++- docs/multiple-devices.md | 2 - 8 files changed, 253 insertions(+), 17 deletions(-) create mode 100644 apps/app/src/hooks/useLocalPathPicker.test.tsx diff --git a/apps/app/src/components/dialogs/ProjectPathDialog.stories.tsx b/apps/app/src/components/dialogs/ProjectPathDialog.stories.tsx index ce7217e58b..ba3b098d14 100644 --- a/apps/app/src/components/dialogs/ProjectPathDialog.stories.tsx +++ b/apps/app/src/components/dialogs/ProjectPathDialog.stories.tsx @@ -3,6 +3,9 @@ import { type ProjectPathDialogTarget, } from "./ProjectPathDialog"; import { + HOST_IDS, + HOST_NAMES, + makeHost, PROJECT_IDS, PROJECT_NAMES, } from "../../../.ladle/story-fixtures"; @@ -30,6 +33,14 @@ const addSourceTarget: ProjectPathDialogTarget = { projectName: PROJECT_NAMES.bb, }; +const connectedMachine = makeHost(); +const offlineMachine = makeHost({ + id: HOST_IDS.remote, + name: HOST_NAMES.remote, + status: "disconnected", +}); +const offlineLocalMachine = makeHost({ status: "disconnected" }); + export function Overview() { return ( @@ -108,7 +119,42 @@ export function Overview() { /> - + + + + + + + + + + + { cleanup(); @@ -116,4 +117,30 @@ describe("ProjectPathDialog machine selection", () => { "host_atum", ); }); + + // With machines listed but none selectable there is no host to resolve a + // path against, so the manual-path fallback must not invite a submit that + // the picker hook would drop without feedback. + it("blocks submission when every listed machine is offline", () => { + const onSubmit = vi.fn(); + render( + , + ); + + expect(screen.queryByLabelText("Project path")).toBeNull(); + expect( + screen + .getByRole("button", { name: "Add project" }) + .hasAttribute("disabled"), + ).toBe(true); + expect(onSubmit).not.toHaveBeenCalled(); + }); }); diff --git a/apps/app/src/components/dialogs/ProjectPathDialog.tsx b/apps/app/src/components/dialogs/ProjectPathDialog.tsx index 68b8a1620f..667022511e 100644 --- a/apps/app/src/components/dialogs/ProjectPathDialog.tsx +++ b/apps/app/src/components/dialogs/ProjectPathDialog.tsx @@ -172,6 +172,10 @@ export function ProjectPathDialogContent({ const selectedHostConnected = selectedHost === undefined || selectedHost.status === "connected"; const showMachinePicker = (machineOptions?.length ?? 0) > 1; + // Machines are listed but none can be browsed. The manual-path fallback is + // meaningless here — there is no host to resolve the path against, so a + // submit would be dropped without feedback. + const noMachineAvailable = showMachinePicker && selectedHostId === null; // No-host fallback only: the browser owns the path when a host is present. const [manualPath, setManualPath] = useState( target.kind === "update" ? target.currentPath : "", @@ -236,7 +240,9 @@ export function ProjectPathDialogContent({ ? `Browse to the project folder${ selectedHostName ? ` on ${selectedHostName}` : "" }, or edit the path directly.` - : copy.description} + : noMachineAvailable + ? "No machine is online. Start one to choose a project folder." + : copy.description} @@ -294,6 +300,10 @@ export function ProjectPathDialogContent({ onDirectoryChange={setBrowserDirectory} disabled={pending || !selectedHostConnected} /> + ) : noMachineAvailable ? ( +

+ Every machine is offline. Bring one online to browse its folders. +

) : ( diff --git a/apps/app/src/hooks/useLocalPathPicker.test.tsx b/apps/app/src/hooks/useLocalPathPicker.test.tsx new file mode 100644 index 0000000000..ab39a376eb --- /dev/null +++ b/apps/app/src/hooks/useLocalPathPicker.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { Host } from "@bb/domain"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useLocalPathPicker } from "./useLocalPathPicker"; + +const mocks = vi.hoisted(() => ({ + pickFolder: vi.fn(), + primaryHost: null as Host | null, + supportsNativeFolderPicker: true, +})); + +vi.mock("@/hooks/useHostDaemon", () => ({ + useHostDaemon: () => ({ + localDaemonHostId: "host_atum", + localHostId: "host_atum", + hasDaemon: true, + supportsNativeFolderPicker: mocks.supportsNativeFolderPicker, + platform: "linux", + isLocalDaemonHost: (hostId: string | null) => hostId === "host_atum", + }), +})); + +vi.mock("@/hooks/queries/host-queries", () => ({ + usePrimaryHost: () => mocks.primaryHost, +})); + +vi.mock("@/lib/sdk", () => ({ + sdk: { hosts: { pickFolder: mocks.pickFolder } }, +})); + +const atum: Host = { + id: "host_atum", + name: "atum", + type: "persistent", + status: "connected", + lastSeenAt: null, + lastRejectedProtocolVersion: null, + createdAt: 0, + updatedAt: 0, +}; + +beforeEach(() => { + mocks.primaryHost = atum; + mocks.supportsNativeFolderPicker = true; + mocks.pickFolder.mockResolvedValue({ path: "/home/me/repo" }); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("useLocalPathPicker", () => { + // The dialog reports the machine it actually resolved a path on. An explicit + // null means "no machine selected" and must not silently fall back to the + // primary host — the create would land on the wrong machine. + it("drops a submit that carries no machine", () => { + const submit = vi.fn(); + const { result } = renderHook(() => + useLocalPathPicker({ isPending: false, submit }), + ); + + act(() => { + result.current.submitProjectPath({ kind: "create" }, "/srv/thing", null); + }); + + expect(submit).not.toHaveBeenCalled(); + }); + + it("submits on the machine the dialog reports", () => { + const submit = vi.fn(); + const { result } = renderHook(() => + useLocalPathPicker({ isPending: false, submit }), + ); + + act(() => { + result.current.submitProjectPath( + { kind: "create" }, + "/srv/thing", + "host_kunst", + ); + }); + + expect(submit).toHaveBeenCalledWith( + expect.objectContaining({ hostId: "host_kunst", path: "/srv/thing" }), + ); + }); + + it("still submits on the primary host after the native folder picker", async () => { + const submit = vi.fn(); + const { result } = renderHook(() => + useLocalPathPicker({ isPending: false, submit }), + ); + + act(() => { + result.current.openPicker({ kind: "create" }); + }); + + await waitFor(() => { + expect(submit).toHaveBeenCalledWith( + expect.objectContaining({ hostId: "host_atum", path: "/home/me/repo" }), + ); + }); + }); +}); diff --git a/apps/app/src/hooks/useLocalPathPicker.tsx b/apps/app/src/hooks/useLocalPathPicker.tsx index d934da0959..6a1eb112e2 100644 --- a/apps/app/src/hooks/useLocalPathPicker.tsx +++ b/apps/app/src/hooks/useLocalPathPicker.tsx @@ -76,16 +76,19 @@ export function useLocalPathPicker({ const projectPathDialog = useDialogState(); const closeDialog = projectPathDialog.onClose; + // The target host is always passed explicitly: a default parameter would + // only cover an omitted argument, and the dialog passes an explicit null + // when no machine is selected — which would silently skip the fallback. const submitPath = useCallback( ( path: string, target: ProjectPathDialogTarget, - selectedHostId: string | null = hostId, + targetHostId: string | null, ) => { - if (isPending || !selectedHostId) return; - submit({ path, hostId: selectedHostId, target, closeDialog }); + if (isPending || !targetHostId) return; + submit({ path, hostId: targetHostId, target, closeDialog }); }, - [closeDialog, hostId, isPending, submit], + [closeDialog, isPending, submit], ); const openPicker = useCallback( @@ -104,7 +107,7 @@ export function useLocalPathPicker({ return; } if (!selectedPath) return; - submitPath(normalizeProjectPathInput(selectedPath), target); + submitPath(normalizeProjectPathInput(selectedPath), target, hostId); })(); return; } diff --git a/apps/app/src/hooks/useQuickCreateProject.test.tsx b/apps/app/src/hooks/useQuickCreateProject.test.tsx index c1bce2d5ca..044a57bae2 100644 --- a/apps/app/src/hooks/useQuickCreateProject.test.tsx +++ b/apps/app/src/hooks/useQuickCreateProject.test.tsx @@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useQuickCreateProject } from "./useQuickCreateProject"; const mocks = vi.hoisted(() => ({ - hosts: [] as Host[], + hosts: [] as Host[] | undefined, + isLoadingHosts: false, mutate: vi.fn(), navigate: vi.fn(), onClose: vi.fn(), @@ -26,7 +27,7 @@ vi.mock("@/hooks/mutations/project-mutations", () => ({ })); vi.mock("@/hooks/queries/host-queries", () => ({ - useHosts: () => ({ data: mocks.hosts }), + useHosts: () => ({ data: mocks.hosts, isPending: mocks.isLoadingHosts }), })); vi.mock("@/hooks/useLocalPathPicker", () => ({ @@ -51,12 +52,16 @@ vi.mock("@/lib/root-compose-selection", () => ({ useSetRootComposeProjectId: () => mocks.setRootComposeProjectId, })); -function host(id: string, name: string): Host { +function host( + id: string, + name: string, + status: Host["status"] = "connected", +): Host { return { id, name, type: "persistent", - status: "connected", + status, lastSeenAt: null, lastRejectedProtocolVersion: null, createdAt: 0, @@ -66,6 +71,7 @@ function host(id: string, name: string): Host { beforeEach(() => { mocks.hosts = [host("host_atum", "atum")]; + mocks.isLoadingHosts = false; }); afterEach(() => { @@ -96,4 +102,28 @@ describe("useQuickCreateProject", () => { expect(mocks.openPicker).toHaveBeenCalledWith({ kind: "create" }); expect(mocks.onOpen).not.toHaveBeenCalled(); }); + + it("keeps the native picker when the only other machine is offline", () => { + mocks.hosts = [ + host("host_atum", "atum"), + host("host_dead", "Old laptop", "disconnected"), + ]; + const { result } = renderHook(() => useQuickCreateProject()); + + act(() => result.current.openCreateDialog()); + + expect(mocks.openPicker).toHaveBeenCalledWith({ kind: "create" }); + expect(mocks.onOpen).not.toHaveBeenCalled(); + }); + + it("opens the dialog while the machine list is still loading", () => { + mocks.hosts = undefined; + mocks.isLoadingHosts = true; + const { result } = renderHook(() => useQuickCreateProject()); + + act(() => result.current.openCreateDialog()); + + expect(mocks.onOpen).toHaveBeenCalledWith({ kind: "create" }); + expect(mocks.openPicker).not.toHaveBeenCalled(); + }); }); diff --git a/apps/app/src/hooks/useQuickCreateProject.tsx b/apps/app/src/hooks/useQuickCreateProject.tsx index 6a189bfbca..f2a162624b 100644 --- a/apps/app/src/hooks/useQuickCreateProject.tsx +++ b/apps/app/src/hooks/useQuickCreateProject.tsx @@ -48,7 +48,12 @@ const EMPTY_HOSTS: readonly Host[] = []; export function useQuickCreateProject(): QuickCreateProjectController { const { mutate, isPending } = useCreateProject(); - const hosts = useHosts().data ?? EMPTY_HOSTS; + const hostsQuery = useHosts(); + const hosts = hostsQuery.data ?? EMPTY_HOSTS; + const isLoadingHosts = hostsQuery.isPending; + const connectedHostCount = hosts.filter( + (host) => host.status === "connected", + ).length; const navigate = useNavigate(); const location = useLocation(); const setRootComposeProjectId = useSetRootComposeProjectId(); @@ -84,13 +89,18 @@ export function useQuickCreateProject(): QuickCreateProjectController { submit, }); + // Only *connected* machines are choosable, so a lone stale enrollment must + // not cost desktop users the native folder picker. While the host list is + // still loading we can't yet tell single- from multi-machine: open the + // dialog, which grows the picker once the list arrives, rather than + // committing to the primary host behind the user's back. const openCreateDialog = useCallback(() => { - if (hosts.length > 1) { + if (isLoadingHosts || connectedHostCount > 1) { controller.projectPathDialog.onOpen({ kind: "create" }); return; } controller.openPicker({ kind: "create" }); - }, [controller, hosts.length]); + }, [connectedHostCount, controller, isLoadingHosts]); return useMemo( () => ({ diff --git a/docs/multiple-devices.md b/docs/multiple-devices.md index eb0f79e8d6..92c56a8a1b 100644 --- a/docs/multiple-devices.md +++ b/docs/multiple-devices.md @@ -1,5 +1,3 @@ - - # Using bb on multiple devices There are two separate ways to use more than one device with bb: From 92f5911e8c7f0e623e213daa57417e9b73905180 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Mon, 27 Jul 2026 17:25:46 +0000 Subject: [PATCH 8/9] Fix Docs frontmatter parsing, blank-line loss, rename, and preview Review follow-ups on the YAML frontmatter change: - parseMarkdownDocument treated any text between two `---` lines as frontmatter, so a document opening with a thematic break had its opening section hidden from the editor, nav titles, and previews. Require the fenced block to parse as a YAML mapping (or as nothing) first. - The rich editor dropped the blank line between frontmatter and body on the first save, giving every canonical `---\n\n# Heading` document a spurious diff line. Replay the body's leading line breaks on save, and give the round-trip fixture the blank line it was missing. - renameToTitle bailed on any frontmatter, so a document with only `tags:` silently stopped following its H1. Gate on the parsed title instead. - The search preview dropped the first heading of any level whenever frontmatter supplied a title. Only drop a heading that repeats the title. - Drop the loading-state test that asserted on Tailwind class names. Co-authored-by: Ali Madad Co-Authored-By: Claude Opus 5 (1M context) --- official-plugins/docs/README.md | 10 +-- official-plugins/docs/app.test.tsx | 57 +++++++++------ official-plugins/docs/app.tsx | 7 ++ .../docs/markdown-document.test.ts | 69 +++++++++++++++++++ official-plugins/docs/markdown-document.ts | 37 ++++++---- official-plugins/docs/server.test.ts | 68 ++++++++++++++++++ official-plugins/docs/server.ts | 13 +++- official-plugins/docs/tsconfig.json | 1 + 8 files changed, 224 insertions(+), 38 deletions(-) create mode 100644 official-plugins/docs/markdown-document.test.ts diff --git a/official-plugins/docs/README.md b/official-plugins/docs/README.md index abb173174c..7f23ad8d53 100644 --- a/official-plugins/docs/README.md +++ b/official-plugins/docs/README.md @@ -27,10 +27,12 @@ provider, and directive are all Docs. `.markdown` files, so it can be selected under Settings → File openers or chosen from a file link's Open with menu. Workspace and absolute host files retain compare-and-swap saves even when they are outside a Docs vault. -- **YAML frontmatter:** fenced frontmatter supplies the document title when it - has a string `title`, stays out of the rendered body and search preview, and - is preserved byte-for-byte when the rich editor saves body changes. Docs - also leaves frontmatter-managed filenames unchanged on save. +- **YAML frontmatter:** an opening fenced block that parses as a YAML mapping + supplies the document title when it has a string `title`, stays out of the + rendered body and search preview, and is preserved byte-for-byte when the + rich editor saves body changes. A document that opens with a thematic break + instead keeps that content in the editor. Docs leaves the filename unchanged + on save when frontmatter sets the title; otherwise the H1 still drives it. - **Tables:** GitHub-flavored Markdown tables render as editable cells. Use Tab and Shift+Tab to move between cells (Tab from the final cell adds a row), and drag column boundaries to resize them. Saves remain portable Markdown. diff --git a/official-plugins/docs/app.test.tsx b/official-plugins/docs/app.test.tsx index 476473e172..f838e0e432 100644 --- a/official-plugins/docs/app.test.tsx +++ b/official-plugins/docs/app.test.tsx @@ -101,24 +101,6 @@ describe("Docs nav panel", () => { }); }); - it("keeps the vault loading state clear of top-left app chrome", () => { - const slot = renderSlot( - app.navPanels[0]!, - { subPath: "" }, - { rpc: { listNotes: () => new Promise(() => undefined) } }, - ); - - const loading = slot.getByText("Loading vaults…"); - expect(loading.className.split(/\s+/)).toEqual( - expect.arrayContaining([ - "flex", - "flex-1", - "items-center", - "justify-center", - ]), - ); - }); - it("moves the sidebar toggle into the shared panel header", async () => { const panel = app.navPanels[0]!; const slot = renderSlot( @@ -473,7 +455,7 @@ describe("Docs nav panel", () => { }, ]), readNote: () => ({ - content: `${frontmatter}# Wiki page\r\n\r\nOriginal body.`, + content: `${frontmatter}\r\n# Wiki page\r\n\r\nOriginal body.`, sha256: "sha", }), preparePreview: () => preview, @@ -493,13 +475,48 @@ describe("Docs nav panel", () => { await waitFor(() => expect(saveNote).toHaveBeenCalled(), { timeout: 2_000, }); + // The blank line separating frontmatter from the body survives the first + // save: without it every real-world document picks up a spurious diff line. expect(saveNote.mock.calls.at(-1)?.[0]).toMatchObject({ content: expect.stringMatching( - /^---\r\ntitle: Wiki page\r\ntype: knowledge\r\n---\r\n# Wiki page\n\nEdited body\./, + /^---\r\ntitle: Wiki page\r\ntype: knowledge\r\n---\r\n\r\n# Wiki page\n\nEdited body\./, ), }); }); + it("keeps a leading thematic break visible in the editor", async () => { + const content = "---\n\nSome intro text.\n\n---\n\nMore text.\n"; + const slot = renderSlot( + app.navPanels[0]!, + { subPath: "personal/break.md" }, + { + rpc: { + listNotes: () => + listNotesResult([ + { + path: "break.md", + title: "Break doc", + preview: "Some intro text. More text.", + modifiedAtMs: 1, + }, + ]), + readNote: () => ({ content, sha256: "sha" }), + preparePreview: () => preview, + renameToTitle: () => ({ path: "break.md" }), + saveNote: () => ({ outcome: "written", sha256: "next-sha" }), + }, + }, + ); + + // The opening `---` is a thematic break, not frontmatter, so the section it + // introduces must stay editable rather than being hidden as metadata. + await waitFor(() => { + const editor = slot.container.querySelector(".tiptap"); + expect(editor?.textContent).toContain("Some intro text."); + expect(editor?.textContent).toContain("More text."); + }); + }); + it("renders nested folders, images, and sandboxed HTML directives", async () => { const slot = renderSlot( app.navPanels[0]!, diff --git a/official-plugins/docs/app.tsx b/official-plugins/docs/app.tsx index 4b40320d92..492a30f0f0 100644 --- a/official-plugins/docs/app.tsx +++ b/official-plugins/docs/app.tsx @@ -505,6 +505,12 @@ function TiptapEditor({ ensureEditorStyles(); if (!rootRef.current) return; const markdownDocument = parseMarkdownDocument(initialValue); + // `---\n\n# Heading` is the canonical frontmatter layout, and the editor + // never sees the leading blank lines, so replay them on save instead of + // handing every touched document a spurious diff line. + const bodyLeadingBreaks = markdownDocument.frontmatter + ? (/^(?:\r?\n)*/.exec(markdownDocument.body)?.[0] ?? "") + : ""; let editor: Editor; const upload = async (file: File) => { if (!file.type.startsWith("image/")) return false; @@ -565,6 +571,7 @@ function TiptapEditor({ }); const getMarkdown = () => markdownDocument.frontmatter + + bodyLeadingBreaks + storedMarkdown( editor.storage.markdown.getMarkdown(), previewBaseUrl, diff --git a/official-plugins/docs/markdown-document.test.ts b/official-plugins/docs/markdown-document.test.ts new file mode 100644 index 0000000000..5d6998d034 --- /dev/null +++ b/official-plugins/docs/markdown-document.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { parseMarkdownDocument } from "./markdown-document"; + +describe("parseMarkdownDocument", () => { + it("splits a YAML mapping block from the body", () => { + expect( + parseMarkdownDocument("---\ntitle: Wiki page\ntags: [a]\n---\n\n# Wiki"), + ).toEqual({ + frontmatter: "---\ntitle: Wiki page\ntags: [a]\n---\n", + body: "\n# Wiki", + title: "Wiki page", + }); + }); + + it("keeps a leading thematic break in the body", () => { + const content = "---\n\nSome intro text.\n\n---\n\nMore text.\n"; + expect(parseMarkdownDocument(content)).toEqual({ + frontmatter: "", + body: content, + title: null, + }); + }); + + it("keeps a leading YAML sequence in the body", () => { + const content = "---\n- a\n- b\n---\nbody\n"; + expect(parseMarkdownDocument(content)).toEqual({ + frontmatter: "", + body: content, + title: null, + }); + }); + + it("keeps an unparseable block in the body", () => { + const content = "---\nnot: [closed\n---\nbody\n"; + expect(parseMarkdownDocument(content)).toEqual({ + frontmatter: "", + body: content, + title: null, + }); + }); + + it("treats an empty block as frontmatter", () => { + expect(parseMarkdownDocument("---\n---\nbody\n")).toEqual({ + frontmatter: "---\n---\n", + body: "body\n", + title: null, + }); + }); + + it("reports no title when frontmatter omits one", () => { + expect(parseMarkdownDocument("---\ntags: [a]\n---\n# H1\n")).toMatchObject({ + frontmatter: "---\ntags: [a]\n---\n", + title: null, + }); + }); + + it("round-trips frontmatter and body byte-for-byte", () => { + for (const content of [ + "---\r\ntitle: X\r\n---\r\n\r\nBody\r\n", + "\uFEFF---\ntitle: X\n---\nBody\n", + "---\ntitle: X\n...\nBody\n", + "---\n\nA thematic break document.\n\n---\n\nMore.\n", + "No frontmatter at all.\n", + ]) { + const document = parseMarkdownDocument(content); + expect(document.frontmatter + document.body).toBe(content); + } + }); +}); diff --git a/official-plugins/docs/markdown-document.ts b/official-plugins/docs/markdown-document.ts index 1e15e7d8b3..e3842d0eef 100644 --- a/official-plugins/docs/markdown-document.ts +++ b/official-plugins/docs/markdown-document.ts @@ -23,21 +23,32 @@ function readLine(content: string, start: number): MarkdownLine { }; } -function parseFrontmatterTitle(source: string): string | null { +function isMapping(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Frontmatter is a YAML mapping. A document that merely opens with a thematic + * break (`---`) followed by prose, a list, or invalid YAML is not frontmatter, + * so the fenced block must parse to a mapping (or to nothing) before we hide it + * from the editor. Returns null when the block is not frontmatter. + */ +function parseFrontmatterMetadata( + source: string, +): Record | null { + let metadata: unknown; try { - const metadata: unknown = parse(source, { maxAliasCount: 20 }); - if ( - typeof metadata !== "object" || - metadata === null || - Array.isArray(metadata) - ) { - return null; - } - const title = (metadata as Record).title; - return typeof title === "string" && title.trim() ? title.trim() : null; + metadata = parse(source, { maxAliasCount: 20 }); } catch { return null; } + if (metadata === null || metadata === undefined) return {}; + return isMapping(metadata) ? metadata : null; +} + +function frontmatterTitle(metadata: Record): string | null { + const title = metadata.title; + return typeof title === "string" && title.trim() ? title.trim() : null; } export function parseMarkdownDocument(content: string): MarkdownDocument { @@ -52,10 +63,12 @@ export function parseMarkdownDocument(content: string): MarkdownDocument { const line = readLine(content, lineStart); if (line.text === "---" || line.text === "...") { const frontmatterSource = content.slice(firstLine.nextStart, line.start); + const metadata = parseFrontmatterMetadata(frontmatterSource); + if (!metadata) break; return { frontmatter: content.slice(0, line.nextStart), body: content.slice(line.nextStart), - title: parseFrontmatterTitle(frontmatterSource), + title: frontmatterTitle(metadata), }; } if (line.nextStart === lineStart) break; diff --git a/official-plugins/docs/server.test.ts b/official-plugins/docs/server.test.ts index a0c32e009a..fb8b9064c6 100644 --- a/official-plugins/docs/server.test.ts +++ b/official-plugins/docs/server.test.ts @@ -432,6 +432,38 @@ describe("Docs mention provider", () => { id: "personal:california-report.md", title: "6th Annual Report: Evaluation of California's Caregiver Services", + // The H2 is body content, not the title, so the preview keeps it. + subtitle: + "Personal · Key findings used in wiki CareNav assessments identify unmet caregiver needs.", + icon: "FileText", + }, + ]); + }); + + it("drops only a heading that repeats the frontmatter title", async () => { + const { harness } = await loadNotebook({ + "echoed-title.md": [ + "---", + "title: Caregiver services", + "---", + "# Caregiver services", + "", + "CareNav assessments identify unmet caregiver needs.", + ].join("\n"), + }); + const provider = harness.registrations.mentionProviders[0]!; + + await expect( + provider.search({ + trigger: "@", + query: "echoed", + projectId: null, + threadId: null, + }), + ).resolves.toEqual([ + { + id: "personal:echoed-title.md", + title: "Caregiver services", subtitle: "Personal · CareNav assessments identify unmet caregiver needs.", icon: "FileText", @@ -439,6 +471,26 @@ describe("Docs mention provider", () => { ]); }); + it("keeps a section opened by a thematic break in the preview", async () => { + const { harness } = await loadNotebook({ + "thematic-break.md": "---\n\nSome intro text.\n\n---\n\nMore text.\n", + }); + const provider = harness.registrations.mentionProviders[0]!; + + // The opening `---` is a thematic break, not frontmatter, so the section it + // introduces stays searchable instead of being swallowed as metadata. + await expect( + provider.search({ + trigger: "@", + query: "thematic", + projectId: null, + threadId: null, + }), + ).resolves.toMatchObject([ + { subtitle: "Personal · Some intro text. More text." }, + ]); + }); + it("resolves the note's current content at send time", async () => { const { harness } = await loadNotebook({ "ideas.md": "# Fresh Ideas\n\nBuild the mention flow.", @@ -506,6 +558,22 @@ describe("Docs vault operations", () => { expect(harness.sdk.callsTo("files.move")).toEqual([]); }); + it("still follows the H1 when frontmatter has no title", async () => { + const { harness } = await loadNotebook({ + "old-slug.md": ["---", "tags: [a]", "---", "# Brand new title"].join( + "\n", + ), + }); + + await expect( + harness.callRpc("renameToTitle", { + vaultId: "personal", + path: "old-slug.md", + }), + ).resolves.toEqual({ path: "brand-new-title.md" }); + expect(harness.sdk.callsTo("files.move")).toHaveLength(1); + }); + it("registers the agent-discoverable Docs CLI", async () => { const { harness } = await loadNotebook({ "plan.md": "# Plan" }); expect(harness.registrations.cli).toMatchObject({ diff --git a/official-plugins/docs/server.ts b/official-plugins/docs/server.ts index 82c68a8b69..36a65998d4 100644 --- a/official-plugins/docs/server.ts +++ b/official-plugins/docs/server.ts @@ -589,8 +589,14 @@ function summarizeMarkdown( const firstHeadingIndex = lines.findIndex( (line) => markdownHeadingLevel(line) !== null, ); + // A frontmatter title only supersedes a heading that repeats it. Any other + // opening heading is body content and belongs in the preview. const previewHeadingIndex = - titleLineIndex >= 0 ? titleLineIndex : firstHeadingIndex; + titleLineIndex >= 0 + ? titleLineIndex + : firstHeadingIndex >= 0 && cleanedLines[firstHeadingIndex] === title + ? firstHeadingIndex + : -1; const preview = cleanedLines .filter( (line, index) => @@ -1574,7 +1580,10 @@ export default async function plugin(bb: BbPluginApi) { const vaultId = input.vaultId; const currentPath = requireVaultPath(input.path, { extension: ".md" }); const file = await readFile(vaultId, currentPath); - if (parseMarkdownDocument(file.content).frontmatter) { + // Frontmatter that names the document owns the display title, so the + // filename is managed by hand. Frontmatter without a title still lets the + // H1 drive the filename. + if (parseMarkdownDocument(file.content).title) { return { path: currentPath }; } const base = kebabCase(deriveTitle(file.content, "")); diff --git a/official-plugins/docs/tsconfig.json b/official-plugins/docs/tsconfig.json index a2f02dfd24..dcc384bf73 100644 --- a/official-plugins/docs/tsconfig.json +++ b/official-plugins/docs/tsconfig.json @@ -14,6 +14,7 @@ "server.ts", "server.test.ts", "markdown-document.ts", + "markdown-document.test.ts", "app.tsx", "app.test.tsx", "vitest.config.ts" From b0682a530546bc6b9ec362b79c03aa61cca9fad4 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Mon, 27 Jul 2026 17:22:34 +0000 Subject: [PATCH 9/9] fix(app): narrow directive rewrite to text directives and merge literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the remark-directive colon fix. - Update two stale assertions in markdown-thread-mentions.test.tsx. The rewritten prose is now one text node, so `getByText("@thread")` — which joins only an element's direct text-node children — no longer matches. Assert the paragraph's textContent instead. - Leave container directives (`:::name`) on the default rendering path. `:::` at the start of a line is not incidental prose the way an inline `:` is, and rewriting the block to literal text both stopped a nested registered `::name` from mounting and collapsed the block onto one line. - Merge the rewritten text directive into an adjacent `text` sibling so a paragraph containing `9:30` is indistinguishable from one that never parsed as a directive. - Add a regression test for the originally reported symptom: "Meeting at 9:30 and 10:45 today." rendered as "Meeting at 9 and 10 today.". Co-authored-by: Bhuvan Singla Co-Authored-By: Claude Opus 5 (1M context) --- .../ui/markdown-message-directives.test.ts | 4 +- .../ui/markdown-message-directives.test.tsx | 42 +++++- .../ui/markdown-message-directives.tsx | 131 ++++++++++-------- .../ui/markdown-thread-mentions.test.tsx | 17 ++- 4 files changed, 122 insertions(+), 72 deletions(-) diff --git a/apps/app/src/components/ui/markdown-message-directives.test.ts b/apps/app/src/components/ui/markdown-message-directives.test.ts index 358873b04e..4b5e6a3cf0 100644 --- a/apps/app/src/components/ui/markdown-message-directives.test.ts +++ b/apps/app/src/components/ui/markdown-message-directives.test.ts @@ -82,8 +82,8 @@ describe("normalizeDirectiveAttributes / reconstructDirectiveSource", () => { ).toBe('::inline-vis{file="demo\\"x\\".html"}'); }); - it("uses the supplied marker for non-leaf directive kinds", () => { + it("uses the supplied marker for text directives", () => { expect(reconstructDirectiveSource("30", {}, ":")).toBe(":30"); - expect(reconstructDirectiveSource("note", {}, ":::")).toBe(":::note"); + expect(reconstructDirectiveSource("x", { a: "1" }, ":")).toBe(':x{a="1"}'); }); }); diff --git a/apps/app/src/components/ui/markdown-message-directives.test.tsx b/apps/app/src/components/ui/markdown-message-directives.test.tsx index 9a2eda2f3f..e321afa095 100644 --- a/apps/app/src/components/ui/markdown-message-directives.test.tsx +++ b/apps/app/src/components/ui/markdown-message-directives.test.tsx @@ -246,13 +246,45 @@ describe("MarkdownPreview message directives", () => { expect(screen.queryByTestId("inline-vis")).toBeNull(); }); - it("renders a container directive as literal source text", () => { + it("keeps clock times intact and leaves the prose as a single text node", () => { + // The originally reported symptom: "Meeting at 9:30 and 10:45 today." + // rendered as "Meeting at 9 and 10 today." — both `:30` and `:45` parsed as + // text directives and were dropped by the empty-`
` fallback. + const registry = buildMessageDirectiveRegistry([ + slot({ id: "inline-vis", pluginId: "demo", component: InlineVis }), + ]); + const sentence = "Meeting at 9:30 and 10:45 today."; + const { container } = render( + , + ); + + const paragraph = container.querySelector("p"); + expect(paragraph?.textContent).toBe(sentence); + // Rewritten directives merge into their neighbours, so the paragraph is + // indistinguishable from prose that never parsed as a directive. + expect(paragraph?.childNodes).toHaveLength(1); + expect(paragraph?.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE); + }); + + it("leaves container directives on the default rendering path", () => { + // `:::` at the start of a line is not incidental prose, so containers are + // not rewritten: their content still renders, and a registered leaf + // directive nested inside one still mounts. const registry = buildMessageDirectiveRegistry([ slot({ id: "inline-vis", pluginId: "demo", component: InlineVis }), ]); render( { />, ); - expect(screen.queryByTestId("inline-vis")).toBeNull(); - expect(screen.getByText(/:::note/)).toBeTruthy(); + expect(screen.getByText("world").tagName).toBe("STRONG"); + expect(screen.getByTestId("inline-vis").getAttribute("data-file")).toBe( + "demo.html", + ); }); it("falls back to original directive text when the plugin component crashes", () => { diff --git a/apps/app/src/components/ui/markdown-message-directives.tsx b/apps/app/src/components/ui/markdown-message-directives.tsx index a65392f1a8..0f902ee20d 100644 --- a/apps/app/src/components/ui/markdown-message-directives.tsx +++ b/apps/app/src/components/ui/markdown-message-directives.tsx @@ -78,13 +78,14 @@ export interface ResolvedMessageDirectives { /** * `remark-directive` emits three node kinds from a single `:` grammar. Only the - * leaf form (`::name`) mounts a plugin component; the text and container forms - * are handled solely to rewrite them back to literal prose. + * leaf form (`::name`) mounts a plugin component; the text form is handled + * solely to rewrite it back to literal prose. Container directives (`:::name`) + * are deliberately left alone — `:::` at the start of a line is not incidental + * prose the way an inline `:` is, and rewriting the block to literal text would + * both stop a nested `::name` from mounting and collapse the block's line + * structure. */ -type DirectiveNodeType = - | "textDirective" - | "leafDirective" - | "containerDirective"; +type DirectiveNodeType = "textDirective" | "leafDirective"; interface DirectiveNode { type: DirectiveNodeType; @@ -101,7 +102,6 @@ interface DirectiveNode { const DIRECTIVE_MARKERS: Record = { textDirective: ":", leafDirective: "::", - containerDirective: ":::", }; interface RemarkMessageDirectiveFile { @@ -240,21 +240,55 @@ function messageDirectiveMountNode(index: number): RootContent { }; } -function literalDirectiveNode( +/** + * Replace the directive at `index` with its literal source and return the index + * traversal should resume from. + * + * A text directive lives inside phrasing content (e.g. mid-paragraph), so it is + * rewritten inline — not as a block paragraph, which would split the + * surrounding text onto its own line — and merged into an adjacent `text` + * sibling so the rewritten prose stays a single text node, indistinguishable + * from text that was never parsed as a directive. A leaf directive is + * block-level, so a paragraph is the correct literal stand-in. + */ +function spliceLiteralDirective( + parent: Parent, + index: number, type: DirectiveNodeType, source: string, -): RootContent { - // A text directive lives inside phrasing content (e.g. mid-paragraph), so it - // must be rewritten to an inline text node — not a block paragraph, which - // would split the surrounding text onto its own line. Leaf and container - // directives are block-level, so a paragraph is the correct literal stand-in. - if (type === "textDirective") { - return { type: "text", value: source }; +): number { + if (type !== "textDirective") { + parent.children.splice(index, 1, { + type: "paragraph", + children: [{ type: "text", value: source }], + }); + return index; } - return { - type: "paragraph", - children: [{ type: "text", value: source }], - }; + + // A merged node no longer spans its recorded source range, and nothing + // downstream reads text positions, so drop the stale span rather than leave a + // wrong one — the same as the freshly built nodes below, which have none. + const previous = parent.children[index - 1]; + const next = parent.children[index + 1]; + if (previous?.type === "text") { + previous.value += source; + previous.position = undefined; + if (next?.type === "text") { + previous.value += next.value; + parent.children.splice(index, 2); + } else { + parent.children.splice(index, 1); + } + return index; + } + if (next?.type === "text") { + next.value = `${source}${next.value}`; + next.position = undefined; + parent.children.splice(index, 1); + return index; + } + parent.children.splice(index, 1, { type: "text", value: source }); + return index; } function asDirectiveNode(node: unknown): DirectiveNode | null { @@ -262,11 +296,7 @@ function asDirectiveNode(node: unknown): DirectiveNode | null { return null; } const type = (node as { type?: unknown }).type; - if ( - type === "textDirective" || - type === "leafDirective" || - type === "containerDirective" - ) { + if (type === "textDirective" || type === "leafDirective") { return node as DirectiveNode; } return null; @@ -275,11 +305,11 @@ function asDirectiveNode(node: unknown): DirectiveNode | null { /** * Remark transformer: rewrite recognized leaf directives (`::name`) into indexed * custom elements; leave unknown / collision / over-limit leaf directives as - * literal source text. Text directives (`:name`) and container directives - * (`:::name`) never mount and are always rewritten to their literal source, so - * incidental prose colons (`13:30`, `key:value`, `:D`) render verbatim instead - * of collapsing into `mdast-util-to-hast`'s empty-`
` fallback. Must run - * after `remark-directive` has produced the directive nodes. + * literal source text. Text directives (`:name`) never mount and are always + * rewritten to their literal source, so incidental prose colons (`13:30`, + * `key:value`, `:D`) render verbatim instead of collapsing into + * `mdast-util-to-hast`'s empty-`
` fallback. Container directives are not + * touched. Must run after `remark-directive` has produced the directive nodes. * * Mutates `mounts` in document order so indices stay stable when later text * streams in after an already-complete directive. @@ -311,49 +341,28 @@ export function remarkMessageDirectives(args: { marker, ); - // Only leaf directives (`::name`) mount a plugin component. Text - // directives (`:name`) and container directives (`:::name`) are almost - // always incidental parses of ordinary prose — a time like `13:30`, a - // `key:value` pair, an emoticon like `:D`. Left in the tree they reach - // `mdast-util-to-hast`, which renders an unknown directive as an empty - // block `
`; nested inside a paragraph that both drops the directive's - // text and injects a stray line break. Rewrite them back to literal - // source so the prose renders verbatim. + // Only leaf directives (`::name`) mount a plugin component. A text + // directive (`:name`) is almost always an incidental parse of ordinary + // prose — a time like `13:30`, a `key:value` pair, an emoticon like `:D`. + // Left in the tree it reaches `mdast-util-to-hast`, which renders an + // unknown directive as an empty block `
`; nested inside a paragraph + // that both drops the directive's text and injects a stray line break. + // Rewrite it back to literal source so the prose renders verbatim. if (directive.type !== "leafDirective") { - parent.children.splice( - index, - 1, - literalDirectiveNode(directive.type, source), - ); - return index; + return spliceLiteralDirective(parent, index, directive.type, source); } if (name.length === 0) { - parent.children.splice( - index, - 1, - literalDirectiveNode(directive.type, source), - ); - return index; + return spliceLiteralDirective(parent, index, directive.type, source); } const entry = registry.get(name); if (entry === undefined || entry.status === "collision") { - parent.children.splice( - index, - 1, - literalDirectiveNode(directive.type, source), - ); - return index; + return spliceLiteralDirective(parent, index, directive.type, source); } if (mounts.length >= MESSAGE_DIRECTIVE_MOUNT_LIMIT) { - parent.children.splice( - index, - 1, - literalDirectiveNode(directive.type, source), - ); - return index; + return spliceLiteralDirective(parent, index, directive.type, source); } const mountIndex = mounts.length; diff --git a/apps/app/src/components/ui/markdown-thread-mentions.test.tsx b/apps/app/src/components/ui/markdown-thread-mentions.test.tsx index b84811dd6f..a5ee745ad1 100644 --- a/apps/app/src/components/ui/markdown-thread-mentions.test.tsx +++ b/apps/app/src/components/ui/markdown-thread-mentions.test.tsx @@ -221,7 +221,7 @@ describe("MarkdownPreview thread mentions", () => { }); it("leaves a labeled text directive on the authored directive rendering path", () => { - renderMarkdown( + const { container } = renderMarkdown( { />, ); - expect(screen.getByText("@thread")).toBeTruthy(); - expect(screen.getByText("label")).toBeTruthy(); + // The token is not a mention here, so it stays verbatim prose — one text + // node, no pill and no directive mount. + const paragraph = container.querySelector("p"); + expect(paragraph?.textContent).toBe("@thread:thr_child[label]"); + expect(paragraph?.querySelector("a")).toBeNull(); expect(screen.queryByText("Rebuild comments")).toBeNull(); }); it("leaves an attributed text directive on the authored directive rendering path", () => { - renderMarkdown( + const { container } = renderMarkdown( { />, ); - expect(screen.getByText("@thread")).toBeTruthy(); + const paragraph = container.querySelector("p"); + expect(paragraph?.textContent).toBe( + "@thread:thr_child{#authored-directive}", + ); + expect(paragraph?.querySelector("a")).toBeNull(); expect(screen.queryByText("Rebuild comments")).toBeNull(); });