diff --git a/apps/app/src/components/dialogs/ProjectPathDialog.stories.tsx b/apps/app/src/components/dialogs/ProjectPathDialog.stories.tsx index ce7217e58..ba3b098d1 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() { /> - + + + + + + + + + + + ({ + 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", +}); +const offlineKunst = host({ ...kunst, 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", + ); + }); + + // 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 ad90dad7a..667022511 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,35 @@ 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; + // 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 : "", @@ -153,13 +186,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 +228,7 @@ export function ProjectPathDialogContent({ return; } - void onSubmit(target, normalizedPath); + void onSubmit(target, normalizedPath, selectedHostId); }; return ( @@ -203,21 +236,74 @@ 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} + : noMachineAvailable + ? "No machine is online. Start one to choose a project folder." + : copy.description}
- {hostId ? ( + {showMachinePicker ? ( +
+ {machineOptions?.map((host) => { + const connected = host.status === "connected"; + const selected = host.id === selectedHostId; + return ( + + ); + })} +
+ ) : null} + {selectedHostId ? ( + ) : noMachineAvailable ? ( +

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

) : ( ) : null} - diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx index 4db1b34a6..9e4d7df5a 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/components/ui/markdown-message-directives.test.ts b/apps/app/src/components/ui/markdown-message-directives.test.ts index 27dad6b4c..4b5e6a3cf 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 text directives", () => { + expect(reconstructDirectiveSource("30", {}, ":")).toBe(":30"); + 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 3fe9a6700..e321afa09 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,90 @@ 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("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.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", () => { 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 f598e17ca..0f902ee20 100644 --- a/apps/app/src/components/ui/markdown-message-directives.tsx +++ b/apps/app/src/components/ui/markdown-message-directives.tsx @@ -76,8 +76,19 @@ 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 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"; + +interface DirectiveNode { + type: DirectiveNodeType; name?: string; attributes?: Record | null; children?: unknown[]; @@ -87,6 +98,12 @@ interface LeafDirectiveNode { }; } +/** Source marker for each directive kind (used to reconstruct literal text). */ +const DIRECTIVE_MARKERS: Record = { + textDirective: ":", + leafDirective: "::", +}; + 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,76 @@ function messageDirectiveMountNode(index: number): RootContent { }; } -function literalDirectiveNode(source: string): RootContent { - return { - type: "paragraph", - children: [{ type: "text", value: source }], - }; +/** + * 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, +): number { + if (type !== "textDirective") { + parent.children.splice(index, 1, { + type: "paragraph", + children: [{ type: "text", value: source }], + }); + return index; + } + + // 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 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") { + 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`) 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. @@ -256,36 +326,43 @@ 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. 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") { + return spliceLiteralDirective(parent, index, directive.type, source); + } + if (name.length === 0) { - parent.children.splice(index, 1, literalDirectiveNode(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(source)); - return index; + return spliceLiteralDirective(parent, index, directive.type, source); } if (mounts.length >= MESSAGE_DIRECTIVE_MOUNT_LIMIT) { - parent.children.splice(index, 1, literalDirectiveNode(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 b84811dd6..a5ee745ad 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(); }); diff --git a/apps/app/src/hooks/useLocalPathPicker.test.tsx b/apps/app/src/hooks/useLocalPathPicker.test.tsx new file mode 100644 index 000000000..ab39a376e --- /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 0f1d88f49..6a1eb112e 100644 --- a/apps/app/src/hooks/useLocalPathPicker.tsx +++ b/apps/app/src/hooks/useLocalPathPicker.tsx @@ -76,12 +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) => { - if (isPending || !hostId) return; - submit({ path, hostId, target, closeDialog }); + ( + path: string, + target: ProjectPathDialogTarget, + targetHostId: string | null, + ) => { + if (isPending || !targetHostId) return; + submit({ path, hostId: targetHostId, target, closeDialog }); }, - [closeDialog, hostId, isPending, submit], + [closeDialog, isPending, submit], ); const openPicker = useCallback( @@ -100,7 +107,7 @@ export function useLocalPathPicker({ return; } if (!selectedPath) return; - submitPath(normalizeProjectPathInput(selectedPath), target); + submitPath(normalizeProjectPathInput(selectedPath), target, hostId); })(); return; } @@ -118,8 +125,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 000000000..044a57bae --- /dev/null +++ b/apps/app/src/hooks/useQuickCreateProject.test.tsx @@ -0,0 +1,129 @@ +// @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[] | undefined, + isLoadingHosts: false, + 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, isPending: mocks.isLoadingHosts }), +})); + +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, + status: Host["status"] = "connected", +): Host { + return { + id, + name, + type: "persistent", + status, + lastSeenAt: null, + lastRejectedProtocolVersion: null, + createdAt: 0, + updatedAt: 0, + }; +} + +beforeEach(() => { + mocks.hosts = [host("host_atum", "atum")]; + mocks.isLoadingHosts = false; +}); + +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(); + }); + + 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 ed3955bcc..f2a162624 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,23 @@ 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 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(); @@ -80,9 +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 (isLoadingHosts || connectedHostCount > 1) { + controller.projectPathDialog.onOpen({ kind: "create" }); + return; + } controller.openPicker({ kind: "create" }); - }, [controller]); + }, [connectedHostCount, controller, isLoadingHosts]); return useMemo( () => ({ @@ -92,10 +110,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/apps/server/src/services/skills/injected-skills.ts b/apps/server/src/services/skills/injected-skills.ts index cc793a823..49b54fd05 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), diff --git a/apps/server/test/skills/injected-skills.test.ts b/apps/server/test/skills/injected-skills.test.ts index 02f3b9578..d65928662 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", diff --git a/docs/multiple-devices.md b/docs/multiple-devices.md index 8fafbabf4..92c56a8a1 100644 --- a/docs/multiple-devices.md +++ b/docs/multiple-devices.md @@ -85,7 +85,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`. diff --git a/official-plugins/docs/README.md b/official-plugins/docs/README.md index 354906313..7f23ad8d5 100644 --- a/official-plugins/docs/README.md +++ b/official-plugins/docs/README.md @@ -27,6 +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:** 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 509d245c7..f838e0e43 100644 --- a/official-plugins/docs/app.test.tsx +++ b/official-plugins/docs/app.test.tsx @@ -429,6 +429,94 @@ 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}\r\n# 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, + }); + // 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\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 1bff776f8..492a30f0f 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,13 @@ function TiptapEditor({ useEffect(() => { 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; @@ -539,7 +547,7 @@ function TiptapEditor({ linkify: true, }), ], - content: displayMarkdown(initialValue, previewBaseUrl, notePath), + content: displayMarkdown(markdownDocument.body, previewBaseUrl, notePath), autofocus: "end", editorProps: { handlePaste(_view, event) { @@ -562,6 +570,8 @@ function TiptapEditor({ }, }); const getMarkdown = () => + markdownDocument.frontmatter + + bodyLeadingBreaks + storedMarkdown( editor.storage.markdown.getMarkdown(), previewBaseUrl, @@ -1941,7 +1951,12 @@ function NotesPanel({ subPath }: PluginNavPanelProps) { if (!data || !activeVaultId) return ( -
Loading vaults…
+
+ Loading vaults… +
); const selectedFolder = filePath ? dirname(filePath) : ""; diff --git a/official-plugins/docs/markdown-document.test.ts b/official-plugins/docs/markdown-document.test.ts new file mode 100644 index 000000000..5d6998d03 --- /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 new file mode 100644 index 000000000..e3842d0ee --- /dev/null +++ b/official-plugins/docs/markdown-document.ts @@ -0,0 +1,79 @@ +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 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 { + 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 { + 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); + const metadata = parseFrontmatterMetadata(frontmatterSource); + if (!metadata) break; + return { + frontmatter: content.slice(0, line.nextStart), + body: content.slice(line.nextStart), + title: frontmatterTitle(metadata), + }; + } + 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 6d5cf1a9d..4d1dfaeba 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.2", "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 601835f32..fb8b9064c 100644 --- a/official-plugins/docs/server.test.ts +++ b/official-plugins/docs/server.test.ts @@ -375,6 +375,122 @@ 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", + // 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", + }, + ]); + }); + + 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.", @@ -422,6 +538,42 @@ 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("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 dc6370412..36a65998d 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,60 @@ 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, + ); + // 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 >= 0 && cleanedLines[firstHeadingIndex] === title + ? firstHeadingIndex + : -1; + 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 +872,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 +1580,12 @@ export default async function plugin(bb: BbPluginApi) { const vaultId = input.vaultId; const currentPath = requireVaultPath(input.path, { extension: ".md" }); const file = await readFile(vaultId, currentPath); + // 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, "")); 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 efe0f3351..dcc384bf7 100644 --- a/official-plugins/docs/tsconfig.json +++ b/official-plugins/docs/tsconfig.json @@ -13,6 +13,8 @@ "include": [ "server.ts", "server.test.ts", + "markdown-document.ts", + "markdown-document.test.ts", "app.tsx", "app.test.tsx", "vitest.config.ts" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a4834e2b..c6c3d1317 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