diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts
index 265ba0ff..797cc6e9 100644
--- a/apps/hook/server/index.ts
+++ b/apps/hook/server/index.ts
@@ -431,9 +431,7 @@ if (args[0] === "sessions") {
server.stop();
// Output feedback (captured by slash command)
- if (result.exit) {
- console.log("Review session closed without feedback.");
- } else if (result.approved) {
+ if (result.approved) {
console.log("Code review completed — no changes requested.");
} else {
console.log(result.feedback);
@@ -555,11 +553,7 @@ if (args[0] === "sessions") {
server.stop();
// Output feedback (captured by slash command)
- if (result.exit) {
- console.log("Annotation session closed without feedback.");
- } else {
- console.log(result.feedback || "No feedback provided.");
- }
+ console.log(result.feedback || "No feedback provided.");
process.exit(0);
} else if (args[0] === "annotate-last" || args[0] === "last") {
@@ -673,11 +667,7 @@ if (args[0] === "sessions") {
server.stop();
- if (result.exit) {
- console.log("Annotation session closed without feedback.");
- } else {
- console.log(result.feedback || "No feedback provided.");
- }
+ console.log(result.feedback || "No feedback provided.");
process.exit(0);
} else if (args[0] === "archive") {
@@ -862,11 +852,7 @@ if (args[0] === "sessions") {
await Bun.sleep(1500);
server.stop();
- if (result.exit) {
- console.log("Annotation session closed without feedback.");
- } else {
- console.log(result.feedback || "No feedback provided.");
- }
+ console.log(result.feedback || "No feedback provided.");
process.exit(0);
} else if (args[0] === "improve-context") {
diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts
index 2218c271..c2829f3c 100644
--- a/apps/opencode-plugin/commands.ts
+++ b/apps/opencode-plugin/commands.ts
@@ -103,10 +103,6 @@ export async function handleReviewCommand(
await Bun.sleep(1500);
server.stop();
- if (result.exit) {
- return;
- }
-
if (result.feedback) {
// @ts-ignore - Event properties contain sessionID
const sessionId = event.properties?.sessionID;
@@ -185,10 +181,6 @@ export async function handleAnnotateCommand(
await Bun.sleep(1500);
server.stop();
- if (result.exit) {
- return;
- }
-
if (result.feedback) {
// @ts-ignore - Event properties contain sessionID
const sessionId = event.properties?.sessionID;
@@ -274,10 +266,6 @@ export async function handleAnnotateLastCommand(
await Bun.sleep(1500);
server.stop();
- if (result.exit) {
- return null;
- }
-
return result.feedback || null;
}
diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts
index 38ed6cfe..a3c62040 100644
--- a/apps/pi-extension/index.ts
+++ b/apps/pi-extension/index.ts
@@ -347,9 +347,7 @@ export default function plannotator(pi: ExtensionAPI): void {
const prUrl = args?.trim() || undefined;
const isPRReview = prUrl?.startsWith("http://") || prUrl?.startsWith("https://");
const result = await openCodeReview(ctx, { prUrl });
- if (result.exit) {
- ctx.ui.notify("Code review session closed.", "info");
- } else if (result.feedback) {
+ if (result.feedback) {
if (result.approved) {
pi.sendUserMessage(
`# Code Review\n\nCode review completed — no changes requested.`,
@@ -426,9 +424,7 @@ export default function plannotator(pi: ExtensionAPI): void {
try {
const result = await openMarkdownAnnotation(ctx, absolutePath, markdown, mode ?? "annotate", folderPath);
- if (result.exit) {
- ctx.ui.notify("Annotation session closed.", "info");
- } else if (result.feedback) {
+ if (result.feedback) {
const header = isFolder
? `# Markdown Annotations\n\nFolder: ${absolutePath}\n\n`
: `# Markdown Annotations\n\nFile: ${absolutePath}\n\n`;
@@ -468,9 +464,7 @@ export default function plannotator(pi: ExtensionAPI): void {
try {
const result = await openLastMessageAnnotation(ctx, lastText);
- if (result.exit) {
- ctx.ui.notify("Annotation session closed.", "info");
- } else if (result.feedback) {
+ if (result.feedback) {
pi.sendUserMessage(
`# Message Annotations\n\n${result.feedback}\n\nPlease address the annotation feedback above.`,
);
diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts
index 6c6548fb..5b69fd18 100644
--- a/apps/pi-extension/plannotator-browser.ts
+++ b/apps/pi-extension/plannotator-browser.ts
@@ -165,7 +165,7 @@ export async function openPlanReviewBrowser(
export async function openCodeReview(
ctx: ExtensionContext,
options: { cwd?: string; defaultBranch?: string; diffType?: DiffType; prUrl?: string } = {},
-): Promise<{ approved: boolean; feedback?: string; annotations?: unknown[]; agentSwitch?: string; exit?: boolean }> {
+): Promise<{ approved: boolean; feedback?: string; annotations?: unknown[]; agentSwitch?: string }> {
if (!ctx.hasUI || !reviewHtmlContent) {
throw new Error("Plannotator code review browser is unavailable in this session.");
}
@@ -367,7 +367,7 @@ export async function openMarkdownAnnotation(
markdown: string,
mode: AnnotateMode,
folderPath?: string,
-): Promise<{ feedback: string; exit?: boolean }> {
+): Promise<{ feedback: string }> {
if (!ctx.hasUI || !planHtmlContent) {
throw new Error("Plannotator annotation browser is unavailable in this session.");
}
@@ -402,7 +402,7 @@ export async function openMarkdownAnnotation(
export async function openLastMessageAnnotation(
ctx: ExtensionContext,
lastText: string,
-): Promise<{ feedback: string; exit?: boolean }> {
+): Promise<{ feedback: string }> {
return openMarkdownAnnotation(ctx, "last-message", lastText, "annotate-last");
}
diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts
index 007ac539..763b3fb6 100644
--- a/apps/pi-extension/server.test.ts
+++ b/apps/pi-extension/server.test.ts
@@ -214,43 +214,6 @@ describe("pi review server", () => {
}
});
- test("exit endpoint resolves decision with exit flag", async () => {
- const homeDir = makeTempDir("plannotator-pi-home-");
- const repoDir = initRepo();
- process.env.HOME = homeDir;
- process.chdir(repoDir);
- process.env.PLANNOTATOR_PORT = String(await reservePort());
-
- const gitContext = await getGitContext();
- const diff = await runGitDiff("uncommitted", gitContext.defaultBranch);
-
- const server = await startReviewServer({
- rawPatch: diff.patch,
- gitRef: diff.label,
- error: diff.error,
- diffType: "uncommitted",
- gitContext,
- origin: "pi",
- htmlContent: "
review",
- });
-
- try {
- const exitResponse = await fetch(`${server.url}/api/exit`, { method: "POST" });
- expect(exitResponse.status).toBe(200);
- expect(await exitResponse.json()).toEqual({ ok: true });
-
- await expect(server.waitForDecision()).resolves.toEqual({
- exit: true,
- approved: false,
- feedback: "",
- annotations: [],
- agentSwitch: undefined,
- });
- } finally {
- server.stop();
- }
- });
-
test("git-add endpoint stages and unstages files in review mode", async () => {
const homeDir = makeTempDir("plannotator-pi-home-");
const repoDir = initRepo();
diff --git a/apps/pi-extension/server/integrations.ts b/apps/pi-extension/server/integrations.ts
index 19fda7d6..d7af15d5 100644
--- a/apps/pi-extension/server/integrations.ts
+++ b/apps/pi-extension/server/integrations.ts
@@ -12,28 +12,60 @@ import {
type ObsidianConfig,
type BearConfig,
type OctarineConfig,
+ type RoamConfig,
+ type RoamSuggestionPage,
+ type RoamSuggestionsResult,
type IntegrationResult,
+ ROAM_API_VERSION,
extractTitle,
generateFrontmatter,
generateFilename,
generateOctarineFrontmatter,
+ generatePageTitle,
+ formatRoamDailyNotePage,
+ majorMinorMatches,
+ normalizeRoamDailyNoteParent,
+ normalizeRoamSuggestionsToPages,
+ frontmatterToAttributeBlocks,
+ stripFrontmatter,
+ stripRoamMetadataTags,
stripH1,
buildHashtags,
buildBearContent,
detectObsidianVaults,
+ DEFAULT_ROAM_PARENT_BLOCK,
} from "../generated/integrations-common.js";
import { sanitizeTag } from "../generated/project.js";
+import { callRoamLocalApi } from "./roam-client.js";
-export type { ObsidianConfig, BearConfig, OctarineConfig, IntegrationResult };
+export type {
+ ObsidianConfig,
+ BearConfig,
+ OctarineConfig,
+ RoamConfig,
+ RoamSuggestionPage,
+ RoamSuggestionsResult,
+ IntegrationResult,
+};
export {
+ ROAM_API_VERSION,
extractTitle,
generateFrontmatter,
generateFilename,
generateOctarineFrontmatter,
+ generatePageTitle,
+ formatRoamDailyNotePage,
+ majorMinorMatches,
+ normalizeRoamDailyNoteParent,
+ normalizeRoamSuggestionsToPages,
+ frontmatterToAttributeBlocks,
+ stripFrontmatter,
+ stripRoamMetadataTags,
stripH1,
buildHashtags,
buildBearContent,
detectObsidianVaults,
+ DEFAULT_ROAM_PARENT_BLOCK,
};
/** Detect project name from git or cwd (sync). Used by extractTags for note integrations. */
@@ -148,6 +180,110 @@ export async function saveToObsidian(
}
}
+export async function saveToRoam(
+ config: RoamConfig,
+): Promise {
+ try {
+ const { frontmatter, body } = stripFrontmatter(config.plan);
+ const title = generatePageTitle(
+ body,
+ config.titleFormat,
+ config.titleSeparator,
+ );
+ const contentMarkdown = [
+ frontmatterToAttributeBlocks(frontmatter),
+ stripH1(body).trimStart(),
+ ]
+ .filter((section) => section.trim().length > 0)
+ .join("\n\n");
+
+ if (config.saveLocation === "daily-note") {
+ const createPlanBlockResult = await callRoamLocalApi<{ uids?: string[] }>(
+ config,
+ "data.block.fromMarkdown",
+ [
+ {
+ location: {
+ order: "last",
+ "page-title": {
+ "daily-note-page": formatRoamDailyNotePage(new Date()),
+ },
+ "nest-under-str": normalizeRoamDailyNoteParent(
+ config.dailyNoteParent,
+ ),
+ },
+ "markdown-string": title,
+ },
+ ],
+ );
+
+ const planBlockUid = createPlanBlockResult.uids?.[0];
+ if (!planBlockUid) {
+ return {
+ success: false,
+ error: "Roam did not return a block UID for the saved plan",
+ };
+ }
+
+ if (contentMarkdown.trim().length > 0) {
+ await callRoamLocalApi<{ uids?: string[] }>(
+ config,
+ "data.block.fromMarkdown",
+ [
+ {
+ location: {
+ order: "last",
+ "parent-uid": planBlockUid,
+ },
+ "markdown-string": contentMarkdown,
+ },
+ ],
+ );
+ }
+
+ return {
+ success: true,
+ path: `roam:${config.graphType}:${config.graphName}/${planBlockUid}`,
+ };
+ }
+
+ const markdown = [
+ frontmatterToAttributeBlocks(frontmatter),
+ DEFAULT_ROAM_PARENT_BLOCK,
+ stripH1(body).trimStart(),
+ ]
+ .filter((section) => section.trim().length > 0)
+ .join("\n\n");
+
+ const result = await callRoamLocalApi<{
+ uid?: string;
+ title?: string;
+ page?: { uid?: string; title?: string };
+ }>(
+ config,
+ "data.page.fromMarkdown",
+ [
+ {
+ page: { title },
+ "markdown-string": markdown,
+ },
+ ],
+ );
+
+ const pathId =
+ result.page?.uid ?? result.uid ?? result.page?.title ?? result.title ?? title;
+ return {
+ success: true,
+ path: `roam:${config.graphType}:${config.graphName}/${pathId}`,
+ };
+ } catch (err) {
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : "Unknown error",
+ };
+ }
+}
+
export async function saveToBear(
config: BearConfig,
): Promise {
diff --git a/apps/pi-extension/server/reference.ts b/apps/pi-extension/server/reference.ts
index 89a965a7..15f6bd02 100644
--- a/apps/pi-extension/server/reference.ts
+++ b/apps/pi-extension/server/reference.ts
@@ -11,20 +11,36 @@ import {
statSync,
type Dirent,
} from "node:fs";
-import type { ServerResponse } from "node:http";
+import type { IncomingMessage, ServerResponse } from "node:http";
import { join, resolve as resolvePath } from "node:path";
import { json } from "./helpers";
+import {
+ ROAM_API_VERSION,
+ detectObsidianVaults,
+ normalizeRoamSuggestionsToPages,
+ stripRoamMetadataTags,
+ type RoamSuggestionsResult,
+} from "./integrations.js";
+import {
+ callRoamLocalApi,
+ callRoamLocalApiEnvelope,
+ RoamClientError,
+ RoamAuthError,
+ RoamConnectionError,
+ RoamTimeoutError,
+ RoamVersionMismatchError,
+} from "./roam-client.js";
import {
type VaultNode,
buildFileTree,
FILE_BROWSER_EXCLUDED,
} from "../generated/reference-common.js";
-import { detectObsidianVaults } from "../generated/integrations-common.js";
import { resolveMarkdownFile } from "../generated/resolve-file.js";
type Res = ServerResponse;
+type Req = IncomingMessage;
/** Recursively walk a directory collecting markdown files, skipping ignored dirs. */
function walkMarkdownFiles(dir: string, root: string, results: string[]): void {
@@ -204,3 +220,130 @@ export function handleFileBrowserRequest(res: Res, url: URL): void {
json(res, { error: "Failed to list directory files" }, 500);
}
}
+
+export async function handleRoamTest(req: Req, res: Res, url: URL): Promise {
+ try {
+ const config = parseRoamRequest(req, url);
+ const response = await callRoamLocalApiEnvelope>(
+ config,
+ "ui.mainWindow.getOpenView",
+ [],
+ );
+ json(res, {
+ ok: true,
+ apiVersion: response.apiVersion ?? ROAM_API_VERSION,
+ graphName: config.graphName,
+ });
+ } catch (error) {
+ roamErrorResponse(res, error);
+ }
+}
+
+export async function handleRoamPages(req: Req, res: Res, url: URL): Promise {
+ try {
+ const config = parseRoamRequest(req, url);
+ const result = await callRoamLocalApi<
+ { suggestions?: RoamSuggestionsResult } | RoamSuggestionsResult
+ >(config, "data.ai.search", [
+ { query: "", scope: "pages", limit: 100, includePath: false },
+ ]);
+ const suggestions =
+ typeof result === "object" && result !== null && "suggestions" in result
+ ? result.suggestions ?? {}
+ : result;
+ const tree = normalizeRoamSuggestionsToPages(suggestions ?? {}).map((page) => ({
+ name: page.title,
+ path: page.uid,
+ type: "file" as const,
+ }));
+ json(res, { tree });
+ } catch (error) {
+ roamErrorResponse(res, error);
+ }
+}
+
+export async function handleRoamDoc(req: Req, res: Res, url: URL): Promise {
+ try {
+ const config = parseRoamRequest(req, url);
+ const uid = url.searchParams.get("uid");
+ if (!uid) {
+ json(res, { error: "Missing uid parameter" }, 400);
+ return;
+ }
+
+ const result = await callRoamLocalApi<{ markdown?: string } | string>(
+ config,
+ "data.ai.getPage",
+ [{ uid }],
+ );
+ const rawMarkdown =
+ typeof result === "string" ? result : result.markdown ?? "";
+ json(res, {
+ markdown: stripRoamMetadataTags(rawMarkdown),
+ filepath: `roam:${config.graphType}:${config.graphName}/${uid}`,
+ });
+ } catch (error) {
+ roamErrorResponse(res, error);
+ }
+}
+
+function parseRoamRequest(
+ req: Req,
+ url: URL,
+): {
+ graphName: string;
+ graphType: "hosted" | "offline";
+ token: string;
+ port: number;
+} {
+ const graphName = url.searchParams.get("graphName");
+ const graphType = url.searchParams.get("graphType");
+ const portParam = url.searchParams.get("port");
+ const token =
+ req.headers.authorization?.replace(/^Bearer\s+/i, "") ??
+ url.searchParams.get("token") ??
+ "";
+
+ if (!graphName || !token || !portParam) {
+ throw new Error("Missing graphName, token, or port parameter");
+ }
+
+ const port = Number.parseInt(portParam, 10);
+ if (!Number.isFinite(port) || port <= 0) {
+ throw new Error("Invalid port parameter");
+ }
+
+ if (graphType !== "hosted" && graphType !== "offline") {
+ throw new Error("Invalid graphType parameter");
+ }
+
+ return { graphName, graphType, token, port };
+}
+
+function roamErrorResponse(res: Res, error: unknown): void {
+ if (error instanceof RoamAuthError) {
+ json(res, { error: error.message }, 401);
+ return;
+ }
+ if (error instanceof RoamVersionMismatchError) {
+ json(res, { error: error.message }, 409);
+ return;
+ }
+ if (error instanceof RoamTimeoutError) {
+ json(res, { error: error.message }, 504);
+ return;
+ }
+ if (error instanceof RoamConnectionError) {
+ json(res, { error: error.message }, 502);
+ return;
+ }
+ if (error instanceof RoamClientError) {
+ json(res, { error: error.message }, 502);
+ return;
+ }
+ if (error instanceof Error) {
+ json(res, { error: error.message }, 400);
+ return;
+ }
+ json(res, { error: "Unknown Roam error" }, 500);
+}
diff --git a/apps/pi-extension/server/roam-client.test.ts b/apps/pi-extension/server/roam-client.test.ts
new file mode 100644
index 00000000..a7a6e8da
--- /dev/null
+++ b/apps/pi-extension/server/roam-client.test.ts
@@ -0,0 +1,69 @@
+import { afterEach, describe, expect, test } from "bun:test";
+
+import { ROAM_API_VERSION } from "../../../packages/shared/integrations-common.ts";
+import { callRoamLocalApi } from "./roam-client.ts";
+
+let activeServer: ReturnType | null = null;
+
+afterEach(() => {
+ activeServer?.stop(true);
+ activeServer = null;
+});
+
+function startRoamServer(
+ fetch: (req: Request) => Response | Promise,
+): number {
+ activeServer = Bun.serve({
+ port: 0,
+ fetch,
+ });
+ return activeServer.port;
+}
+
+describe("callRoamLocalApi", () => {
+ test("posts to the local Roam API with bearer auth, offline graph query, and expectedApiVersion", async () => {
+ let seen: {
+ pathname: string;
+ search: string;
+ auth: string | null;
+ body: unknown;
+ } | null = null;
+
+ const port = startRoamServer(async (req) => {
+ const url = new URL(req.url);
+ seen = {
+ pathname: url.pathname,
+ search: url.search,
+ auth: req.headers.get("authorization"),
+ body: await req.json(),
+ };
+ return Response.json({
+ apiVersion: ROAM_API_VERSION,
+ result: { ok: true },
+ });
+ });
+
+ const result = await callRoamLocalApi(
+ {
+ graphName: "my-graph",
+ graphType: "offline",
+ token: "secret-token",
+ port,
+ },
+ "data.ai.search",
+ [{ query: "" }],
+ );
+
+ expect(result).toEqual({ ok: true });
+ expect(seen).toEqual({
+ pathname: "/api/my-graph",
+ search: "?type=offline",
+ auth: "Bearer secret-token",
+ body: {
+ action: "data.ai.search",
+ args: [{ query: "" }],
+ expectedApiVersion: ROAM_API_VERSION,
+ },
+ });
+ });
+});
diff --git a/apps/pi-extension/server/roam-client.ts b/apps/pi-extension/server/roam-client.ts
new file mode 100644
index 00000000..ea53a561
--- /dev/null
+++ b/apps/pi-extension/server/roam-client.ts
@@ -0,0 +1,219 @@
+const DEFAULT_TIMEOUT_MS = 10_000;
+const ROAM_API_VERSION = "1.1.2";
+
+interface RoamConfigShape {
+ graphName: string;
+ graphType: "hosted" | "offline";
+ token: string;
+ port: number;
+}
+
+export interface RoamRequestConfig {
+ graphName: string;
+ graphType: "hosted" | "offline";
+ token: string;
+ port: number;
+}
+
+export class RoamClientError extends Error {
+ override cause?: unknown;
+
+ constructor(message: string, options?: { cause?: unknown }) {
+ super(message);
+ this.name = new.target.name;
+ this.cause = options?.cause;
+ }
+}
+
+export class RoamConnectionError extends RoamClientError {}
+export class RoamAuthError extends RoamClientError {}
+export class RoamTimeoutError extends RoamClientError {}
+
+export class RoamVersionMismatchError extends RoamClientError {
+ actualVersion?: string;
+
+ constructor(message: string, options?: { actualVersion?: string; cause?: unknown }) {
+ super(message, options);
+ this.actualVersion = options?.actualVersion;
+ }
+}
+
+interface RoamEnvelope {
+ success?: boolean;
+ result?: T;
+ error?: string;
+ message?: string;
+ apiVersion?: string;
+ actualApiVersion?: string;
+ version?: string;
+}
+
+export async function callRoamLocalApi(
+ config:
+ | RoamRequestConfig
+ | Pick,
+ action: string,
+ args: unknown[],
+ options: { timeoutMs?: number } = {},
+): Promise {
+ const payload = await callRoamLocalApiEnvelope(config, action, args, options);
+ return payload.result;
+}
+
+export async function callRoamLocalApiEnvelope(
+ config:
+ | RoamRequestConfig
+ | Pick,
+ action: string,
+ args: unknown[],
+ options: { timeoutMs?: number } = {},
+): Promise<{ result: T; apiVersion?: string }> {
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
+ const controller = new AbortController();
+ let didTimeout = false;
+ const timeout = setTimeout(() => {
+ didTimeout = true;
+ controller.abort("timeout");
+ }, timeoutMs);
+
+ try {
+ const response = await fetch(buildRoamUrl(config), {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${config.token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ action,
+ args,
+ expectedApiVersion: ROAM_API_VERSION,
+ }),
+ signal: controller.signal,
+ });
+
+ const payload = await parseRoamResponse(response);
+ const actualVersion = extractApiVersion(payload);
+ if (actualVersion && !majorMinorMatches(actualVersion, ROAM_API_VERSION)) {
+ throw new RoamVersionMismatchError(
+ `Roam API version mismatch: expected ${ROAM_API_VERSION}, got ${actualVersion}`,
+ { actualVersion },
+ );
+ }
+
+ if (response.status === 401 || response.status === 403) {
+ throw new RoamAuthError(
+ extractErrorMessage(payload, "Roam authentication failed"),
+ );
+ }
+
+ if (!response.ok) {
+ throw new RoamClientError(
+ extractErrorMessage(
+ payload,
+ `Roam request failed with status ${response.status}`,
+ ),
+ );
+ }
+
+ if (payload.success === false) {
+ throw new RoamClientError(
+ extractErrorMessage(payload, "Roam request failed"),
+ );
+ }
+
+ return {
+ result: (payload?.result ?? payload) as T,
+ apiVersion: actualVersion,
+ };
+ } catch (error) {
+ if (error instanceof RoamClientError) {
+ throw error;
+ }
+ if (didTimeout || isAbortError(error)) {
+ throw new RoamTimeoutError(
+ `Roam request timed out after ${timeoutMs}ms`,
+ { cause: error },
+ );
+ }
+ throw new RoamConnectionError("Unable to reach the Roam local API", {
+ cause: error,
+ });
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+function buildRoamUrl(config: RoamRequestConfig): string {
+ const url = new URL(
+ `http://127.0.0.1:${config.port}/api/${encodeURIComponent(config.graphName)}`,
+ );
+ if (config.graphType === "offline") {
+ url.searchParams.set("type", "offline");
+ }
+ return url.toString();
+}
+
+async function parseRoamResponse(response: Response): Promise> {
+ const text = await response.text();
+ if (!text) {
+ return {};
+ }
+
+ try {
+ return JSON.parse(text) as RoamEnvelope;
+ } catch {
+ return { error: text };
+ }
+}
+
+function extractApiVersion(payload: RoamEnvelope | undefined): string | undefined {
+ if (!payload || typeof payload !== "object") {
+ return undefined;
+ }
+ if (typeof payload.actualApiVersion === "string") return payload.actualApiVersion;
+ if (typeof payload.apiVersion === "string") return payload.apiVersion;
+ if (typeof payload.version === "string") return payload.version;
+ return undefined;
+}
+
+function extractErrorMessage(
+ payload: RoamEnvelope | undefined,
+ fallback: string,
+): string {
+ if (!payload || typeof payload !== "object") {
+ return fallback;
+ }
+ if (typeof payload.error === "string" && payload.error.trim()) return payload.error;
+ if (typeof payload.message === "string" && payload.message.trim()) {
+ return payload.message;
+ }
+ return fallback;
+}
+
+function isAbortError(error: unknown): boolean {
+ return error instanceof DOMException
+ ? error.name === "AbortError"
+ : error instanceof Error && error.name === "AbortError";
+}
+
+function majorMinorMatches(a: string, b: string): boolean {
+ const parsedA = parseMajorMinorVersion(a);
+ const parsedB = parseMajorMinorVersion(b);
+ if (!parsedA || !parsedB) {
+ return false;
+ }
+
+ return parsedA.major === parsedB.major && parsedA.minor === parsedB.minor;
+}
+
+function parseMajorMinorVersion(version: string): { major: number; minor: number } | null {
+ const match = version.trim().match(/^(\d+)\.(\d+)(?:\.\d+)?(?:[-+].*)?$/);
+ if (!match) {
+ return null;
+ }
+
+ return {
+ major: Number(match[1]),
+ minor: Number(match[2]),
+ };
+}
diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts
index 7699aa12..5777ec02 100644
--- a/apps/pi-extension/server/serverAnnotate.ts
+++ b/apps/pi-extension/server/serverAnnotate.ts
@@ -21,6 +21,9 @@ import {
handleObsidianVaultsRequest,
handleObsidianFilesRequest,
handleObsidianDocRequest,
+ handleRoamDoc,
+ handleRoamPages,
+ handleRoamTest,
} from "./reference.js";
import { createExternalAnnotationHandler } from "./external-annotations.js";
@@ -28,7 +31,7 @@ export interface AnnotateServerResult {
port: number;
portSource: "env" | "remote-default" | "random";
url: string;
- waitForDecision: () => Promise<{ feedback: string; annotations: unknown[]; exit?: boolean }>;
+ waitForDecision: () => Promise<{ feedback: string; annotations: unknown[] }>;
stop: () => void;
}
@@ -54,12 +57,10 @@ export async function startAnnotateServer(options: {
let resolveDecision!: (result: {
feedback: string;
annotations: unknown[];
- exit?: boolean;
}) => void;
const decisionPromise = new Promise<{
feedback: string;
annotations: unknown[];
- exit?: boolean;
}>((r) => {
resolveDecision = r;
});
@@ -120,14 +121,16 @@ export async function startAnnotateServer(options: {
handleObsidianFilesRequest(res, url);
} else if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") {
handleObsidianDocRequest(res, url);
+ } else if (url.pathname === "/api/roam/test" && req.method === "GET") {
+ await handleRoamTest(req, res, url);
+ } else if (url.pathname === "/api/reference/roam/pages" && req.method === "GET") {
+ await handleRoamPages(req, res, url);
+ } else if (url.pathname === "/api/reference/roam/doc" && req.method === "GET") {
+ await handleRoamDoc(req, res, url);
} else if (url.pathname === "/api/reference/files" && req.method === "GET") {
handleFileBrowserRequest(res, url);
} else if (url.pathname === "/favicon.svg") {
handleFavicon(res);
- } else if (url.pathname === "/api/exit" && req.method === "POST") {
- deleteDraft(draftKey);
- resolveDecision({ feedback: "", annotations: [], exit: true });
- json(res, { ok: true });
} else if (url.pathname === "/api/feedback" && req.method === "POST") {
try {
const body = await parseBody(req);
diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts
index 78448877..6aa33a07 100644
--- a/apps/pi-extension/server/serverPlan.ts
+++ b/apps/pi-extension/server/serverPlan.ts
@@ -30,9 +30,11 @@ import {
type IntegrationResult,
type ObsidianConfig,
type OctarineConfig,
+ type RoamConfig,
saveToBear,
saveToObsidian,
saveToOctarine,
+ saveToRoam,
} from "./integrations.js";
import { listenOnPort } from "./network.js";
@@ -44,6 +46,9 @@ import {
handleObsidianDocRequest,
handleObsidianFilesRequest,
handleObsidianVaultsRequest,
+ handleRoamDoc,
+ handleRoamPages,
+ handleRoamTest,
} from "./reference.js";
export interface PlanReviewDecision {
@@ -253,6 +258,12 @@ export async function startPlanReviewServer(options: {
handleObsidianFilesRequest(res, url);
} else if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") {
handleObsidianDocRequest(res, url);
+ } else if (url.pathname === "/api/roam/test" && req.method === "GET") {
+ await handleRoamTest(req, res, url);
+ } else if (url.pathname === "/api/reference/roam/pages" && req.method === "GET") {
+ await handleRoamPages(req, res, url);
+ } else if (url.pathname === "/api/reference/roam/doc" && req.method === "GET") {
+ await handleRoamDoc(req, res, url);
} else if (url.pathname === "/api/reference/files" && req.method === "GET") {
handleFileBrowserRequest(res, url);
} else if (
@@ -298,6 +309,7 @@ export async function startPlanReviewServer(options: {
obsidian?: IntegrationResult;
bear?: IntegrationResult;
octarine?: IntegrationResult;
+ roam?: IntegrationResult;
} = {};
try {
const body = await parseBody(req);
@@ -305,6 +317,7 @@ export async function startPlanReviewServer(options: {
const obsConfig = body.obsidian as ObsidianConfig | undefined;
const bearConfig = body.bear as BearConfig | undefined;
const octConfig = body.octarine as OctarineConfig | undefined;
+ const roamConfig = body.roam as RoamConfig | undefined;
if (obsConfig?.vaultPath && obsConfig?.plan) {
promises.push(
saveToObsidian(obsConfig).then((r) => {
@@ -326,6 +339,18 @@ export async function startPlanReviewServer(options: {
}),
);
}
+ if (
+ roamConfig?.graphName &&
+ roamConfig?.token &&
+ roamConfig?.port &&
+ roamConfig?.plan
+ ) {
+ promises.push(
+ saveToRoam(roamConfig).then((r) => {
+ results.roam = r;
+ }),
+ );
+ }
await Promise.allSettled(promises);
for (const [name, result] of Object.entries(results)) {
if (!result?.success && result)
@@ -364,6 +389,7 @@ export async function startPlanReviewServer(options: {
const obsConfig = body.obsidian as ObsidianConfig | undefined;
const bearConfig = body.bear as BearConfig | undefined;
const octConfig = body.octarine as OctarineConfig | undefined;
+ const roamConfig = body.roam as RoamConfig | undefined;
if (obsConfig?.vaultPath && obsConfig?.plan) {
integrationPromises.push(
saveToObsidian(obsConfig).then((r) => {
@@ -385,6 +411,18 @@ export async function startPlanReviewServer(options: {
}),
);
}
+ if (
+ roamConfig?.graphName &&
+ roamConfig?.token &&
+ roamConfig?.port &&
+ roamConfig?.plan
+ ) {
+ integrationPromises.push(
+ saveToRoam(roamConfig).then((r) => {
+ integrationResults.roam = r;
+ }),
+ );
+ }
await Promise.allSettled(integrationPromises);
for (const [name, result] of Object.entries(integrationResults)) {
if (!result?.success && result)
diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts
index c69180aa..6d2c6939 100644
--- a/apps/pi-extension/server/serverReview.ts
+++ b/apps/pi-extension/server/serverReview.ts
@@ -95,7 +95,6 @@ export interface ReviewServerResult {
feedback: string;
annotations: unknown[];
agentSwitch?: string;
- exit?: boolean;
}>;
stop: () => void;
}
@@ -286,14 +285,12 @@ export async function startReviewServer(options: {
feedback: string;
annotations: unknown[];
agentSwitch?: string;
- exit?: boolean;
}) => void;
const decisionPromise = new Promise<{
approved: boolean;
feedback: string;
annotations: unknown[];
agentSwitch?: string;
- exit?: boolean;
}>((r) => {
resolveDecision = r;
});
@@ -683,10 +680,6 @@ export async function startReviewServer(options: {
return;
}
json(res, { error: "Not found" }, 404);
- } else if (url.pathname === "/api/exit" && req.method === "POST") {
- deleteDraft(draftKey);
- resolveDecision({ approved: false, feedback: '', annotations: [], exit: true });
- json(res, { ok: true });
} else if (url.pathname === "/api/feedback" && req.method === "POST") {
try {
const body = await parseBody(req);
diff --git a/bun.lock b/bun.lock
index 786f3433..c9d35859 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
- "configVersion": 0,
"workspaces": {
"": {
"name": "plannotator",
@@ -63,7 +62,7 @@
},
"apps/opencode-plugin": {
"name": "@plannotator/opencode",
- "version": "0.17.7",
+ "version": "0.17.3",
"dependencies": {
"@opencode-ai/plugin": "^1.1.10",
},
@@ -85,7 +84,7 @@
},
"apps/pi-extension": {
"name": "@plannotator/pi-extension",
- "version": "0.17.7",
+ "version": "0.17.3",
"peerDependencies": {
"@mariozechner/pi-coding-agent": ">=0.53.0",
},
@@ -171,7 +170,7 @@
},
"packages/server": {
"name": "@plannotator/server",
- "version": "0.17.7",
+ "version": "0.17.3",
"dependencies": {
"@plannotator/ai": "workspace:*",
"@plannotator/shared": "workspace:*",
diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx
index ca75dd06..bc84258d 100644
--- a/packages/editor/App.tsx
+++ b/packages/editor/App.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
+import React, { useState, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { type Origin, getAgentName } from '@plannotator/shared/agents';
import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter } from '@plannotator/ui/utils/parser';
import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer';
@@ -13,7 +13,7 @@ import { StickyHeaderLane } from '@plannotator/ui/components/StickyHeaderLane';
import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunning';
import { TaterSpritePullup } from '@plannotator/ui/components/TaterSpritePullup';
import { Settings } from '@plannotator/ui/components/Settings';
-import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons';
+import { FeedbackButton, ApproveButton } from '@plannotator/ui/components/ToolbarButtons';
import { useSharing } from '@plannotator/ui/hooks/useSharing';
import { getCallbackConfig, CallbackAction, executeCallback, type ToastPayload } from '@plannotator/ui/utils/callback';
import { useAgents } from '@plannotator/ui/hooks/useAgents';
@@ -22,9 +22,10 @@ import { storage } from '@plannotator/ui/utils/storage';
import { configStore } from '@plannotator/ui/config';
import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay';
import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner';
-import { getObsidianSettings, getEffectiveVaultPath, isObsidianConfigured, CUSTOM_PATH_SENTINEL } from '@plannotator/ui/utils/obsidian';
+import { getObsidianSettings, getEffectiveVaultPath, isObsidianConfigured, CUSTOM_PATH_SENTINEL, isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian';
import { getBearSettings } from '@plannotator/ui/utils/bear';
import { getOctarineSettings, isOctarineConfigured } from '@plannotator/ui/utils/octarine';
+import { getRoamSettings, isRoamBrowserEnabled, isRoamConfigured } from '@plannotator/ui/utils/roam';
import { getDefaultNotesApp } from '@plannotator/ui/utils/defaultNotesApp';
import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch';
import { getPlanSaveSettings } from '@plannotator/ui/utils/planSave';
@@ -58,7 +59,6 @@ import { useExternalAnnotationHighlights } from '@plannotator/ui/hooks/useExtern
import { buildPlanAgentInstructions } from '@plannotator/ui/utils/planAgentInstructions';
import { hasNewSettings, markNewSettingsSeen } from '@plannotator/ui/utils/newSettingsHint';
import { useFileBrowser } from '@plannotator/ui/hooks/useFileBrowser';
-import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian';
import { isFileBrowserEnabled, getFileBrowserSettings } from '@plannotator/ui/utils/fileBrowser';
import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs';
import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer';
@@ -72,6 +72,7 @@ type NoteAutoSaveResults = {
obsidian?: boolean;
bear?: boolean;
octarine?: boolean;
+ roam?: boolean;
};
const App: React.FC = () => {
@@ -84,7 +85,6 @@ const App: React.FC = () => {
const [showImport, setShowImport] = useState(false);
const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false);
const [showClaudeCodeWarning, setShowClaudeCodeWarning] = useState(false);
- const [showExitWarning, setShowExitWarning] = useState(false);
const [showAgentWarning, setShowAgentWarning] = useState(false);
const [agentWarningMessage, setAgentWarningMessage] = useState('');
const [isPanelOpen, setIsPanelOpen] = useState(() => window.innerWidth >= 768);
@@ -137,8 +137,7 @@ const App: React.FC = () => {
const [imageBaseDir, setImageBaseDir] = useState(undefined);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
- const [isExiting, setIsExiting] = useState(false);
- const [submitted, setSubmitted] = useState<'approved' | 'denied' | 'exited' | null>(null);
+ const [submitted, setSubmitted] = useState<'approved' | 'denied' | null>(null);
const [pendingPasteImage, setPendingPasteImage] = useState<{ file: File; blobUrl: string; initialName: string } | null>(null);
const [showPermissionModeSetup, setShowPermissionModeSetup] = useState(false);
const [permissionMode, setPermissionMode] = useState('bypassPermissions');
@@ -227,15 +226,20 @@ const App: React.FC = () => {
setMarkdown, setAnnotations, setSelectedAnnotationId, setSubmitted,
});
- // Markdown file browser (also handles vault dirs via isVault flag)
+ // Markdown file browser (regular files, Obsidian, and Roam sources)
const fileBrowser = useFileBrowser();
const vaultPath = useMemo(() => {
if (!isVaultBrowserEnabled()) return '';
return getEffectiveVaultPath(getObsidianSettings());
- }, [uiPrefs]);
+ }, [uiPrefs, mobileSettingsOpen]);
+ const roamSettings = useMemo(() => getRoamSettings(), [uiPrefs, mobileSettingsOpen]);
+ const roamDirPath = useMemo(() => {
+ if (!isRoamBrowserEnabled()) return '';
+ return `roam:${roamSettings.graphType}:${roamSettings.graphName}`;
+ }, [roamSettings]);
const showFilesTab = useMemo(
- () => !!projectRoot || isFileBrowserEnabled() || isVaultBrowserEnabled(),
- [projectRoot, uiPrefs]
+ () => !!projectRoot || isFileBrowserEnabled() || isVaultBrowserEnabled() || isRoamBrowserEnabled(),
+ [projectRoot, uiPrefs, roamDirPath, mobileSettingsOpen]
);
const fileBrowserDirs = useMemo(() => {
const projectDirs = projectRoot ? [projectRoot] : [];
@@ -252,33 +256,43 @@ const App: React.FC = () => {
// When vault is disabled, prune any stale vault dirs immediately
useEffect(() => {
- if (!vaultPath) fileBrowser.clearVaultDirs();
- }, [vaultPath]);
+ if (!vaultPath) fileBrowser.clearSource('obsidian');
+ }, [vaultPath, fileBrowser]);
+
+ useEffect(() => {
+ if (!roamDirPath) fileBrowser.clearSource('roam');
+ }, [roamDirPath, fileBrowser]);
useEffect(() => {
if (sidebar.activeTab === 'files' && showFilesTab) {
// Load regular dirs
if (fileBrowserDirs.length > 0) {
- const regularLoaded = fileBrowser.dirs.filter(d => !d.isVault).map(d => d.path);
+ const regularLoaded = fileBrowser.dirs.filter(d => d.source === 'files').map(d => d.path);
const needsRegular = fileBrowserDirs.some(d => !regularLoaded.includes(d))
|| regularLoaded.some(d => !fileBrowserDirs.includes(d));
if (needsRegular) fileBrowser.fetchAll(fileBrowserDirs);
}
- // Load vault dir; addVaultDir atomically replaces any existing vault entry so
+ // Load Obsidian dir; addObsidianDir atomically replaces any existing vault entry so
// switching vault paths never accumulates stale sections
- if (vaultPath && !fileBrowser.dirs.find(d => d.isVault && d.path === vaultPath && !d.error)) {
- fileBrowser.addVaultDir(vaultPath);
+ if (vaultPath && !fileBrowser.dirs.find(d => d.source === 'obsidian' && d.path === vaultPath && !d.error)) {
+ fileBrowser.addObsidianDir(vaultPath);
+ }
+ if (roamDirPath && !fileBrowser.dirs.find(d => d.source === 'roam' && d.path === roamDirPath && !d.error)) {
+ fileBrowser.addRoamDir(roamSettings);
}
}
- }, [sidebar.activeTab, showFilesTab, fileBrowserDirs, vaultPath]);
+ }, [sidebar.activeTab, showFilesTab, fileBrowserDirs, vaultPath, roamDirPath, roamSettings, fileBrowser]);
// File browser file selection: open via linked doc system
- // For vault dirs (isVault), use the Obsidian doc endpoint; otherwise use generic /api/doc
const handleFileBrowserSelect = React.useCallback((absolutePath: string, dirPath: string) => {
const dirState = fileBrowser.dirs.find(d => d.path === dirPath);
- const buildUrl = dirState?.isVault
- ? (path: string) => `/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(dirPath)}&path=${encodeURIComponent(path)}`
- : (path: string) => `/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(dirPath)}`;
+ const buildUrl =
+ dirState?.source === 'obsidian'
+ ? (path: string) => `/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(dirPath)}&path=${encodeURIComponent(path.slice(dirPath.length + 1))}`
+ : dirState?.source === 'roam' && dirState.roamMeta
+ ? (path: string) =>
+ `/api/reference/roam/doc?graphName=${encodeURIComponent(dirState.roamMeta.graphName)}&graphType=${encodeURIComponent(dirState.roamMeta.graphType)}&port=${encodeURIComponent(String(dirState.roamMeta.port))}&token=${encodeURIComponent(dirState.roamMeta.token)}&uid=${encodeURIComponent(path.slice(dirPath.length + 1))}`
+ : (path: string) => `/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(dirPath)}`;
linkedDocHook.open(absolutePath, buildUrl, 'files');
fileBrowser.setActiveFile(absolutePath);
}, [linkedDocHook, fileBrowser]);
@@ -286,10 +300,17 @@ const App: React.FC = () => {
// Route linked doc opens through the correct endpoint based on current context
const handleOpenLinkedDoc = React.useCallback((docPath: string) => {
const activeDirState = fileBrowser.dirs.find(d => d.path === fileBrowser.activeDirPath);
- if (activeDirState?.isVault && fileBrowser.activeDirPath) {
+ if (activeDirState?.source === 'obsidian' && fileBrowser.activeDirPath) {
linkedDocHook.open(docPath, (path) =>
- `/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(fileBrowser.activeDirPath!)}&path=${encodeURIComponent(path)}`
+ `/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(fileBrowser.activeDirPath!)}&path=${encodeURIComponent(path.startsWith(`${fileBrowser.activeDirPath!}/`) ? path.slice(fileBrowser.activeDirPath!.length + 1) : path)}`
);
+ } else if (activeDirState?.source === 'roam' && activeDirState.roamMeta) {
+ linkedDocHook.open(docPath, (path) => {
+ const uid = path.startsWith(`${activeDirState.path}/`)
+ ? path.slice(activeDirState.path.length + 1)
+ : path;
+ return `/api/reference/roam/doc?graphName=${encodeURIComponent(activeDirState.roamMeta.graphName)}&graphType=${encodeURIComponent(activeDirState.roamMeta.graphType)}&port=${encodeURIComponent(String(activeDirState.roamMeta.port))}&token=${encodeURIComponent(activeDirState.roamMeta.token)}&uid=${encodeURIComponent(uid)}`;
+ });
} else if (fileBrowser.activeFile && fileBrowser.activeDirPath) {
// When viewing a file browser doc, resolve links relative to current file's directory
const baseDir = linkedDocHook.filepath?.replace(/\/[^/]+$/, '') || fileBrowser.activeDirPath;
@@ -618,7 +639,7 @@ const App: React.FC = () => {
if (!isApiMode || !markdown || isSharedSession || annotateMode || archive.archiveMode) return;
if (autoSaveAttempted.current) return;
- const body: { obsidian?: object; bear?: object; octarine?: object } = {};
+ const body: { obsidian?: object; bear?: object; octarine?: object; roam?: object } = {};
const targets: string[] = [];
const obsSettings = getObsidianSettings();
@@ -656,6 +677,22 @@ const App: React.FC = () => {
targets.push('Octarine');
}
+ const roamSettings = getRoamSettings();
+ if (roamSettings.autoSave && isRoamConfigured()) {
+ body.roam = {
+ graphName: roamSettings.graphName,
+ graphType: roamSettings.graphType,
+ token: roamSettings.token,
+ port: roamSettings.port || 3333,
+ plan: markdown,
+ saveLocation: roamSettings.saveLocation,
+ dailyNoteParent: roamSettings.dailyNoteParent,
+ ...(roamSettings.titleFormat && { titleFormat: roamSettings.titleFormat }),
+ ...(roamSettings.titleSeparator && roamSettings.titleSeparator !== 'space' && { titleSeparator: roamSettings.titleSeparator }),
+ };
+ targets.push('Roam');
+ }
+
if (targets.length === 0) return;
autoSaveAttempted.current = true;
@@ -670,6 +707,7 @@ const App: React.FC = () => {
...(body.obsidian ? { obsidian: Boolean(data.results?.obsidian?.success) } : {}),
...(body.bear ? { bear: Boolean(data.results?.bear?.success) } : {}),
...(body.octarine ? { octarine: Boolean(data.results?.octarine?.success) } : {}),
+ ...(body.roam ? { roam: Boolean(data.results?.roam?.success) } : {}),
};
autoSaveResultsRef.current = results;
@@ -754,14 +792,15 @@ const App: React.FC = () => {
const obsidianSettings = getObsidianSettings();
const bearSettings = getBearSettings();
const octarineSettings = getOctarineSettings();
+ const roamSettings = getRoamSettings();
const agentSwitchSettings = getAgentSwitchSettings();
const planSaveSettings = getPlanSaveSettings();
- const autoSaveResults = bearSettings.autoSave && autoSavePromiseRef.current
+ const autoSaveResults = (bearSettings.autoSave || roamSettings.autoSave) && autoSavePromiseRef.current
? await autoSavePromiseRef.current
: autoSaveResultsRef.current;
// Build request body - include integrations if enabled
- const body: { obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {};
+ const body: { obsidian?: object; bear?: object; octarine?: object; roam?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {};
// Include permission mode for Claude Code
if (origin === 'claude-code') {
@@ -809,6 +848,22 @@ const App: React.FC = () => {
};
}
+ // Roam creates a new page each time, so don't send it again on approve
+ // if the arrival auto-save already succeeded.
+ if (isRoamConfigured() && !(roamSettings.autoSave && autoSaveResults.roam)) {
+ body.roam = {
+ graphName: roamSettings.graphName,
+ graphType: roamSettings.graphType,
+ token: roamSettings.token,
+ port: roamSettings.port || 3333,
+ plan: markdown,
+ saveLocation: roamSettings.saveLocation,
+ dailyNoteParent: roamSettings.dailyNoteParent,
+ ...(roamSettings.titleFormat && { titleFormat: roamSettings.titleFormat }),
+ ...(roamSettings.titleSeparator && roamSettings.titleSeparator !== 'space' && { titleSeparator: roamSettings.titleSeparator }),
+ };
+ }
+
// Include annotations as feedback if any exist (for OpenCode "approve with notes")
const hasDocAnnotations = Array.from(linkedDocHook.getDocAnnotations().values()).some(
(d) => d.annotations.length > 0 || d.globalAttachments.length > 0
@@ -867,21 +922,6 @@ const App: React.FC = () => {
}
};
- // Exit annotation session without sending feedback
- const handleAnnotateExit = useCallback(async () => {
- setIsExiting(true);
- try {
- const res = await fetch('/api/exit', { method: 'POST' });
- if (res.ok) {
- setSubmitted('exited');
- } else {
- throw new Error('Failed to exit');
- }
- } catch {
- setIsExiting(false);
- }
- }, []);
-
// Global keyboard shortcuts (Cmd/Ctrl+Enter to submit)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -894,10 +934,10 @@ const App: React.FC = () => {
// Don't intercept if any modal is open
if (showExport || showImport || showFeedbackPrompt || showClaudeCodeWarning ||
- showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return;
+ showAgentWarning || showPermissionModeSetup || pendingPasteImage) return;
- // Don't intercept if already submitted, submitting, or exiting
- if (submitted || isSubmitting || isExiting) return;
+ // Don't intercept if already submitted or submitting
+ if (submitted || isSubmitting) return;
// Don't intercept in demo/share mode (no API)
if (!isApiMode) return;
@@ -937,9 +977,9 @@ const App: React.FC = () => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [
- showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning,
+ showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showAgentWarning,
showPermissionModeSetup, pendingPasteImage,
- submitted, isSubmitting, isExiting, isApiMode, linkedDocHook.isActive, annotations.length, externalAnnotations.length, annotateMode,
+ submitted, isSubmitting, isApiMode, linkedDocHook.isActive, annotations.length, externalAnnotations.length, annotateMode,
origin, getAgentWarning,
]);
@@ -1083,8 +1123,8 @@ const App: React.FC = () => {
setTimeout(() => setNoteSaveToast(null), 3000);
};
- const handleQuickSaveToNotes = async (target: 'obsidian' | 'bear' | 'octarine') => {
- const body: { obsidian?: object; bear?: object; octarine?: object } = {};
+ const handleQuickSaveToNotes = async (target: 'obsidian' | 'bear' | 'octarine' | 'roam') => {
+ const body: { obsidian?: object; bear?: object; octarine?: object; roam?: object } = {};
if (target === 'obsidian') {
const s = getObsidianSettings();
@@ -1115,8 +1155,28 @@ const App: React.FC = () => {
folder: os.folder || 'plannotator',
};
}
+ if (target === 'roam') {
+ const rs = getRoamSettings();
+ body.roam = {
+ graphName: rs.graphName,
+ graphType: rs.graphType,
+ token: rs.token,
+ port: rs.port || 3333,
+ plan: markdown,
+ saveLocation: rs.saveLocation,
+ dailyNoteParent: rs.dailyNoteParent,
+ ...(rs.titleFormat && { titleFormat: rs.titleFormat }),
+ ...(rs.titleSeparator && rs.titleSeparator !== 'space' && { titleSeparator: rs.titleSeparator }),
+ };
+ }
- const targetName = target === 'obsidian' ? 'Obsidian' : target === 'bear' ? 'Bear' : 'Octarine';
+ const targetName = target === 'obsidian'
+ ? 'Obsidian'
+ : target === 'bear'
+ ? 'Bear'
+ : target === 'octarine'
+ ? 'Octarine'
+ : 'Roam';
try {
const res = await fetch('/api/save-notes', {
method: 'POST',
@@ -1170,7 +1230,7 @@ const App: React.FC = () => {
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (showExport || showFeedbackPrompt || showClaudeCodeWarning ||
- showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return;
+ showAgentWarning || showPermissionModeSetup || pendingPasteImage) return;
if (submitted || !isApiMode) return;
@@ -1180,6 +1240,7 @@ const App: React.FC = () => {
const obsOk = isObsidianConfigured();
const bearOk = getBearSettings().enabled;
const octOk = isOctarineConfigured();
+ const roamOk = isRoamConfigured();
if (defaultApp === 'download') {
handleDownloadAnnotations();
@@ -1189,6 +1250,8 @@ const App: React.FC = () => {
handleQuickSaveToNotes('bear');
} else if (defaultApp === 'octarine' && octOk) {
handleQuickSaveToNotes('octarine');
+ } else if (defaultApp === 'roam' && roamOk) {
+ handleQuickSaveToNotes('roam');
} else {
setInitialExportTab('notes');
setShowExport(true);
@@ -1198,7 +1261,7 @@ const App: React.FC = () => {
window.addEventListener('keydown', handleSaveShortcut);
return () => window.removeEventListener('keydown', handleSaveShortcut);
}, [
- showExport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning,
+ showExport, showFeedbackPrompt, showClaudeCodeWarning, showAgentWarning,
showPermissionModeSetup, pendingPasteImage,
submitted, isApiMode, markdown, annotationsOutput,
]);
@@ -1212,7 +1275,7 @@ const App: React.FC = () => {
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (showExport || showFeedbackPrompt || showClaudeCodeWarning ||
- showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return;
+ showAgentWarning || showPermissionModeSetup || pendingPasteImage) return;
if (submitted) return;
@@ -1223,7 +1286,7 @@ const App: React.FC = () => {
window.addEventListener('keydown', handlePrintShortcut);
return () => window.removeEventListener('keydown', handlePrintShortcut);
}, [
- showExport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning,
+ showExport, showFeedbackPrompt, showClaudeCodeWarning, showAgentWarning,
showPermissionModeSetup, pendingPasteImage, submitted,
]);
@@ -1295,44 +1358,27 @@ const App: React.FC = () => {
{isApiMode && (!linkedDocHook.isActive || annotateMode) && !archive.archiveMode && (
<>
- {annotateMode ? (
- // Annotate mode: Close always visible, Send Annotations when annotations exist
- <>
- (allAnnotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 || globalAttachments.length > 0) ? setShowExitWarning(true) : handleAnnotateExit()}
- disabled={isSubmitting || isExiting}
- isLoading={isExiting}
- />
- {(allAnnotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 || globalAttachments.length > 0) && (
-
- )}
- >
- ) : (
- // Plan mode: Send Feedback
- {
- const docAnnotations = linkedDocHook.getDocAnnotations();
- const hasDocAnnotations = Array.from(docAnnotations.values()).some(
- (d) => d.annotations.length > 0 || d.globalAttachments.length > 0
- );
- if (allAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) {
- setShowFeedbackPrompt(true);
- } else {
- handleDeny();
- }
- }}
- disabled={isSubmitting}
- isLoading={isSubmitting}
- label="Send Feedback"
- title="Send Feedback"
- />
- )}
+ {
+ if (annotateMode) {
+ handleAnnotateFeedback();
+ return;
+ }
+ const docAnnotations = linkedDocHook.getDocAnnotations();
+ const hasDocAnnotations = Array.from(docAnnotations.values()).some(
+ (d) => d.annotations.length > 0 || d.globalAttachments.length > 0
+ );
+ if (allAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) {
+ setShowFeedbackPrompt(true);
+ } else {
+ handleDeny();
+ }
+ }}
+ disabled={isSubmitting}
+ isLoading={isSubmitting}
+ label={annotateMode ? (allAnnotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 ? 'Send Annotations' : 'Done') : 'Send Feedback'}
+ title={annotateMode ? (allAnnotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 ? 'Send Annotations' : 'Done') : 'Send Feedback'}
+ />
{!annotateMode &&
{
onSaveToObsidian={() => handleQuickSaveToNotes('obsidian')}
onSaveToBear={() => handleQuickSaveToNotes('bear')}
onSaveToOctarine={() => handleQuickSaveToNotes('octarine')}
+ onSaveToRoam={() => handleQuickSaveToNotes('roam')}
sharingEnabled={sharingEnabled}
isApiMode={isApiMode}
agentInstructionsEnabled={isApiMode && !archive.archiveMode && !annotateMode}
obsidianConfigured={isObsidianConfigured()}
bearConfigured={getBearSettings().enabled}
octarineConfigured={isOctarineConfigured()}
+ roamConfigured={isRoamConfigured()}
/>
@@ -1481,7 +1529,30 @@ const App: React.FC = () => {
fileBrowser={fileBrowser}
onFilesSelectFile={handleFileBrowserSelect}
onFilesFetchAll={() => fileBrowser.fetchAll(fileBrowserDirs)}
- onFilesRetryVaultDir={(vaultPath) => fileBrowser.addVaultDir(vaultPath)}
+ onFilesRetryVaultDir={(dirPath) => {
+ const dir = fileBrowser.dirs.find((entry) => entry.path === dirPath);
+ if (!dir) return;
+ if (dir.source === 'obsidian') {
+ fileBrowser.addObsidianDir(dir.path);
+ return;
+ }
+ if (dir.source === 'roam' && dir.roamMeta) {
+ fileBrowser.addRoamDir({
+ enabled: true,
+ graphName: dir.roamMeta.graphName,
+ graphType: dir.roamMeta.graphType,
+ token: dir.roamMeta.token,
+ port: dir.roamMeta.port,
+ titleSeparator: 'space',
+ saveLocation: 'page',
+ dailyNoteParent: '[[Plannotator Plans]]',
+ autoSave: false,
+ referenceBrowserEnabled: true,
+ });
+ return;
+ }
+ fileBrowser.fetchAll(fileBrowserDirs);
+ }}
hasFileAnnotations={hasFileAnnotations}
showVersionsTab={versionInfo !== null && versionInfo.totalVersions > 1}
versionInfo={versionInfo}
@@ -1617,7 +1688,12 @@ const App: React.FC = () => {
showDemoBadge={!isApiMode && !isLoadingShared && !isSharedSession}
maxWidth={planMaxWidth}
onOpenLinkedDoc={handleOpenLinkedDoc}
- linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: handleLinkedDocBack, label: fileBrowser.dirs.find(d => d.path === fileBrowser.activeDirPath)?.isVault ? 'Vault File' : fileBrowser.activeFile ? 'File' : undefined, backLabel } : null}
+ linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: handleLinkedDocBack, label: (() => {
+ const activeDir = fileBrowser.dirs.find(d => d.path === fileBrowser.activeDirPath);
+ if (activeDir?.source === 'obsidian') return 'Vault File';
+ if (activeDir?.source === 'roam') return 'Roam Page';
+ return fileBrowser.activeFile ? 'File' : undefined;
+ })(), backLabel } : null}
imageBaseDir={imageBaseDir}
copyLabel={annotateSource === 'message' ? 'Copy message' : annotateSource === 'file' || annotateSource === 'folder' ? 'Copy file' : undefined}
archiveInfo={archive.currentInfo}
@@ -1719,23 +1795,6 @@ const App: React.FC = () => {
showCancel
/>
- {/* Exit with annotations warning dialog */}
- setShowExitWarning(false)}
- onConfirm={() => {
- setShowExitWarning(false);
- handleAnnotateExit();
- }}
- title="Annotations Won't Be Sent"
- message={<>You have {allAnnotations.length + editorAnnotations.length + linkedDocHook.docAnnotationCount + globalAttachments.length} annotation{(allAnnotations.length + editorAnnotations.length + linkedDocHook.docAnnotationCount + globalAttachments.length) !== 1 ? 's' : ''} that will be lost if you close.>}
- subMessage="To send your annotations, use Send Annotations instead."
- confirmText="Close Anyway"
- cancelText="Cancel"
- variant="warning"
- showCancel
- />
-
{/* OpenCode agent not found warning dialog */}
{
{/* Completion overlay - shown after approve/deny */}
diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx
index 15f1ab6d..a752d628 100644
--- a/packages/review-editor/App.tsx
+++ b/packages/review-editor/App.tsx
@@ -3,8 +3,7 @@ import { type Origin, getAgentName } from '@plannotator/shared/agents';
import { ThemeProvider, useTheme } from '@plannotator/ui/components/ThemeProvider';
import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog';
import { Settings } from '@plannotator/ui/components/Settings';
-import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons';
-import { AgentReviewActions } from './components/AgentReviewActions';
+import { FeedbackButton, ApproveButton } from '@plannotator/ui/components/ToolbarButtons';
import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner';
import { storage } from '@plannotator/ui/utils/storage';
import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay';
@@ -159,10 +158,8 @@ const ReviewApp: React.FC = () => {
const [diffError, setDiffError] = useState(null);
const [isSendingFeedback, setIsSendingFeedback] = useState(false);
const [isApproving, setIsApproving] = useState(false);
- const [isExiting, setIsExiting] = useState(false);
- const [submitted, setSubmitted] = useState<'approved' | 'feedback' | 'exited' | false>(false);
+ const [submitted, setSubmitted] = useState<'approved' | 'feedback' | false>(false);
const [showApproveWarning, setShowApproveWarning] = useState(false);
- const [showExitWarning, setShowExitWarning] = useState(false);
const [sharingEnabled, setSharingEnabled] = useState(true);
const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null);
@@ -1092,22 +1089,6 @@ const ReviewApp: React.FC = () => {
}
}, [totalAnnotationCount, feedbackMarkdown, allAnnotations]);
- // Exit review session without sending any feedback
- const handleExit = useCallback(async () => {
- setIsExiting(true);
- try {
- const res = await fetch('/api/exit', { method: 'POST' });
- if (res.ok) {
- setSubmitted('exited');
- } else {
- throw new Error('Failed to exit');
- }
- } catch (error) {
- console.error('Failed to exit review:', error);
- setIsExiting(false);
- }
- }, []);
-
// Approve without feedback (LGTM)
const handleApprove = useCallback(async () => {
setIsApproving(true);
@@ -1297,8 +1278,8 @@ const ReviewApp: React.FC = () => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
- if (showExportModal || showNoAnnotationsDialog || showApproveWarning || showExitWarning) return;
- if (submitted || isSendingFeedback || isApproving || isExiting || isPlatformActioning) return;
+ if (showExportModal || showNoAnnotationsDialog || showApproveWarning) return;
+ if (submitted || isSendingFeedback || isApproving || isPlatformActioning) return;
if (!origin) return; // Demo mode
e.preventDefault();
@@ -1326,9 +1307,9 @@ const ReviewApp: React.FC = () => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [
- showExportModal, showNoAnnotationsDialog, showApproveWarning, showExitWarning,
+ showExportModal, showNoAnnotationsDialog, showApproveWarning,
platformCommentDialog, platformGeneralComment,
- submitted, isSendingFeedback, isApproving, isExiting, isPlatformActioning,
+ submitted, isSendingFeedback, isApproving, isPlatformActioning,
origin, platformMode, platformUser, prMetadata, totalAnnotationCount,
handleApprove, handleSendFeedback, handlePlatformAction
]);
@@ -1518,67 +1499,73 @@ const ReviewApp: React.FC = () => {
)}
- {/* Agent mode: Close/SendFeedback flip + Approve */}
- {!platformMode ? (
- totalAnnotationCount > 0 ? setShowApproveWarning(true) : handleApprove()}
- onExit={() => totalAnnotationCount > 0 ? setShowExitWarning(true) : handleExit()}
- />
- ) : (
- <>
- {/* Platform mode: Close + Post Comments + Approve */}
- totalAnnotationCount > 0 ? setShowExitWarning(true) : handleExit()}
- disabled={isSendingFeedback || isApproving || isExiting || isPlatformActioning}
- isLoading={isExiting}
- />
- {
+ {/* Send Feedback button — always the same label */}
+ {
+ if (platformMode) {
+ setPlatformGeneralComment('');
+ setPlatformCommentDialog({ action: 'comment' });
+ } else {
+ handleSendFeedback();
+ }
+ }}
+ disabled={
+ isSendingFeedback || isApproving || isPlatformActioning ||
+ (!platformMode && totalAnnotationCount === 0)
+ }
+ isLoading={isSendingFeedback || isPlatformActioning}
+ muted={!platformMode && totalAnnotationCount === 0 && !isSendingFeedback && !isApproving && !isPlatformActioning}
+ label={platformMode ? 'Post Comments' : 'Send Feedback'}
+ shortLabel={platformMode ? 'Post' : 'Send'}
+ loadingLabel={platformMode ? 'Posting...' : 'Sending...'}
+ shortLoadingLabel={platformMode ? 'Posting...' : 'Sending...'}
+ title={!platformMode && totalAnnotationCount === 0 ? "Add annotations to send feedback" : "Send feedback"}
+ />
+
+ {/* Approve button — always the same label */}
+
+
{
+ if (platformMode) {
+ if (platformUser && prMetadata?.author === platformUser) return;
setPlatformGeneralComment('');
- setPlatformCommentDialog({ action: 'comment' });
- }}
- disabled={isSendingFeedback || isApproving || isPlatformActioning}
- isLoading={isSendingFeedback || isPlatformActioning}
- label="Post Comments"
- shortLabel="Post"
- loadingLabel="Posting..."
- shortLoadingLabel="Posting..."
- title="Send feedback"
- />
-
-
{
- if (platformUser && prMetadata?.author === platformUser) return;
- setPlatformGeneralComment('');
- setPlatformCommentDialog({ action: 'approve' });
- }}
- disabled={
- isSendingFeedback || isApproving || isPlatformActioning ||
- (!!platformUser && prMetadata?.author === platformUser)
+ setPlatformCommentDialog({ action: 'approve' });
+ } else {
+ if (totalAnnotationCount > 0) {
+ setShowApproveWarning(true);
+ } else {
+ handleApprove();
}
- isLoading={isApproving}
- muted={!!platformUser && prMetadata?.author === platformUser && !isSendingFeedback && !isApproving && !isPlatformActioning}
- title={
- platformUser && prMetadata?.author === platformUser
- ? `You can't approve your own ${mrLabel}`
- : "Approve - no changes needed"
- }
- />
- {platformUser && prMetadata?.author === platformUser && (
-
-
-
- You can't approve your own {mrLabel === 'MR' ? 'merge request' : 'pull request'} on {platformLabel}.
-
- )}
+ }
+ }}
+ disabled={
+ isSendingFeedback || isApproving || isPlatformActioning ||
+ (platformMode && !!platformUser && prMetadata?.author === platformUser)
+ }
+ isLoading={isApproving}
+ dimmed={!platformMode && totalAnnotationCount > 0}
+ muted={platformMode && !!platformUser && prMetadata?.author === platformUser && !isSendingFeedback && !isApproving && !isPlatformActioning}
+ title={
+ platformMode && platformUser && prMetadata?.author === platformUser
+ ? `You can't approve your own ${mrLabel}`
+ : "Approve - no changes needed"
+ }
+ />
+ {/* Tooltip: own PR warning OR annotations-lost warning */}
+ {platformMode && platformUser && prMetadata?.author === platformUser ? (
+
+
+
+ You can't approve your own {mrLabel === 'MR' ? 'merge request' : 'pull request'} on {platformLabel}.
- >
- )}
+ ) : !platformMode && totalAnnotationCount > 0 ? (
+
+
+
+ Your {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} won't be sent if you approve.
+
+ ) : null}
+
>
) : (
+ {/* Roam */}
+
+
+
+
+ Roam
+
+ {isRoamReady ? (
+
+ ) : (
+
Not configured
+ )}
+
+ {isRoamReady && (
+
+ {roamSettings.graphName} ({roamSettings.graphType}) · {roamSettings.saveLocation === 'daily-note' ? `today's Daily Note -> ${roamSettings.dailyNoteParent}` : 'new page'}
+
+ )}
+ {!isRoamReady && (
+
+ Enable in Settings > Saving > Roam
+
+ )}
+ {saveErrors.roam && (
+
{saveErrors.roam}
+ )}
+
+
{/* Save All button */}
{readyCount >= 2 && (
@@ -1351,6 +1456,20 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange
+
>
)}
@@ -1538,6 +1657,186 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange
>
)}
+ {/* === ROAM TAB === */}
+ {activeTab === 'roam' && (
+ <>
+
+
+
Roam Integration
+
+ Save plans to Roam Desktop and browse recent pages
+
+
+
+
+
+ {roam.enabled && (
+ <>
+
+
+
+
+
+ handleRoamChange({ graphName: e.target.value })}
+ placeholder="my-graph"
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
+ />
+
+
+
+
+
+
+
+
+
+ handleRoamChange({ token: e.target.value })}
+ placeholder="roam-graph-local-token-..."
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
+ />
+
+
+
+
+ handleRoamChange({ port: normalizeRoamPort(e.target.value) })}
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono focus:outline-none focus:ring-1 focus:ring-primary/50"
+ />
+
+
+
+
+ {roamTestStatus.state === 'success' && (
+
+ Connected to {roamTestStatus.graphName} · API {roamTestStatus.apiVersion}
+
+ )}
+ {roamTestStatus.state === 'error' && (
+
+ {roamTestStatus.message}
+
+ )}
+
+
+
+
+
+
+
+ {roam.saveLocation === 'daily-note'
+ ? `Creates a per-plan child block under today's Daily Note, inside ${roam.dailyNoteParent || '[[Plannotator Plans]]'}.`
+ : 'Creates a standalone Roam page for each saved plan.'}
+
+
+
+
+
+
handleRoamChange({ titleFormat: e.target.value || undefined })}
+ placeholder={DEFAULT_FILENAME_FORMAT}
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
+ />
+
+ Variables: {'{title}'} {'{YYYY}'} {'{MM}'} {'{DD}'} {'{Mon}'} {'{D}'} {'{HH}'} {'{h}'} {'{hh}'} {'{mm}'} {'{ss}'} {'{ampm}'}
+
+
+ Preview: {getGeneratedTitlePreview(roam.titleFormat, roam.titleSeparator)}
+
+
+
+
+
+
+
+ Replaces spaces in the generated plan title for new pages and Daily Note plan blocks.
+
+
+
+ {roam.saveLocation === 'daily-note' && (
+
+
+
handleRoamChange({ dailyNoteParent: e.target.value })}
+ placeholder="[[Plannotator Plans]]"
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
+ />
+
+ If this block does not exist on today's Daily Note, Plannotator creates it and nests the saved plan underneath.
+
+
+ )}
+
+
+
+
handleRoamChange({ autoSave: value })}
+ label="Auto-save on Plan Arrival"
+ />
+ handleRoamChange({ referenceBrowserEnabled: value })}
+ label="Reference Browser"
+ />
+
+ >
+ )}
+ >
+ )}
+
{/* === SHORTCUTS TAB === */}
{activeTab === 'shortcuts' && (
diff --git a/packages/ui/components/ToolbarButtons.tsx b/packages/ui/components/ToolbarButtons.tsx
index 1e78923f..8d490667 100644
--- a/packages/ui/components/ToolbarButtons.tsx
+++ b/packages/ui/components/ToolbarButtons.tsx
@@ -92,31 +92,3 @@ export const ApproveButton: React.FC = ({
{isLoading ? loadingLabel : label}
);
-
-interface ExitButtonProps {
- onClick: () => void;
- disabled?: boolean;
- isLoading?: boolean;
- title?: string;
-}
-
-export const ExitButton: React.FC = ({
- onClick,
- disabled = false,
- isLoading = false,
- title = 'Close session without sending feedback',
-}) => (
-
-);
diff --git a/packages/ui/components/sidebar/FileBrowser.test.tsx b/packages/ui/components/sidebar/FileBrowser.test.tsx
new file mode 100644
index 00000000..e7e04eb3
--- /dev/null
+++ b/packages/ui/components/sidebar/FileBrowser.test.tsx
@@ -0,0 +1,47 @@
+import { describe, expect, test } from "bun:test";
+import { renderToStaticMarkup } from "react-dom/server";
+import type { DirState } from "../../hooks/useFileBrowser";
+import { FileBrowser } from "./FileBrowser";
+
+describe("FileBrowser", () => {
+ test("renders a readable Roam source section while keeping page titles and uid keys distinct", () => {
+ const html = renderToStaticMarkup(
+ {}}
+ collapsedDirs={new Set()}
+ onToggleCollapse={() => {}}
+ onSelectFile={() => {}}
+ activeFile={null}
+ onFetchAll={() => {}}
+ />,
+ );
+
+ expect(html).toContain("Roam");
+ expect(html).toContain("work-notes");
+ expect(html).toContain("Daily Notes");
+ expect(html).toContain('title="abc123"');
+ });
+});
diff --git a/packages/ui/components/sidebar/FileBrowser.tsx b/packages/ui/components/sidebar/FileBrowser.tsx
index 734fcb90..89553bf1 100644
--- a/packages/ui/components/sidebar/FileBrowser.tsx
+++ b/packages/ui/components/sidebar/FileBrowser.tsx
@@ -20,7 +20,7 @@ interface FileBrowserProps {
onSelectFile: (absolutePath: string, dirPath: string) => void;
activeFile: string | null;
onFetchAll: () => void;
- onRetryVaultDir?: (vaultPath: string) => void;
+ onRetryVaultDir?: (dirPath: string) => void;
annotationCounts?: Map;
highlightedFiles?: Set;
}
@@ -217,6 +217,7 @@ export const FileBrowser: React.FC = ({
)}
{dirs.map((dir) => {
const isCollapsed = collapsedDirs.has(dir.path);
+ const sourceLabel = dir.source === "roam" ? "Roam" : dir.source === "obsidian" ? "Obsidian" : null;
return (