From 6d435c56f8e144ce3a1ca9ef2d9780e0bf404e07 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:38:02 -0700 Subject: [PATCH] feat: add Roam integration --- apps/hook/server/index.ts | 22 +- apps/opencode-plugin/commands.ts | 12 - apps/pi-extension/index.ts | 12 +- apps/pi-extension/plannotator-browser.ts | 6 +- apps/pi-extension/server.test.ts | 37 --- apps/pi-extension/server/integrations.ts | 138 +++++++- apps/pi-extension/server/reference.ts | 147 ++++++++- apps/pi-extension/server/roam-client.test.ts | 69 ++++ apps/pi-extension/server/roam-client.ts | 219 +++++++++++++ apps/pi-extension/server/serverAnnotate.ts | 17 +- apps/pi-extension/server/serverPlan.ts | 38 +++ apps/pi-extension/server/serverReview.ts | 7 - bun.lock | 7 +- packages/editor/App.tsx | 305 +++++++++-------- packages/review-editor/App.tsx | 193 +++++------ .../components/AgentReviewActions.tsx | 75 ----- packages/server/annotate.ts | 36 +- packages/server/index.ts | 40 ++- packages/server/integrations.test.ts | 137 ++++++++ packages/server/integrations.ts | 153 ++++++++- packages/server/project.ts | 4 +- packages/server/reference-handlers.ts | 150 ++++++++- packages/server/review.ts | 10 - packages/server/roam-client.test.ts | 294 +++++++++++++++++ packages/server/roam-client.ts | 191 +++++++++++ packages/server/roam-reference.test.ts | 204 ++++++++++++ packages/shared/integrations-common.ts | 259 +++++++++++++++ packages/ui/components/CompletionOverlay.tsx | 2 +- packages/ui/components/ExportModal.test.tsx | 93 ++++++ packages/ui/components/ExportModal.tsx | 74 ++++- packages/ui/components/PlanHeaderMenu.tsx | 17 +- packages/ui/components/Settings.tsx | 307 +++++++++++++++++- packages/ui/components/ToolbarButtons.tsx | 28 -- .../components/sidebar/FileBrowser.test.tsx | 47 +++ .../ui/components/sidebar/FileBrowser.tsx | 12 +- packages/ui/hooks/useFileBrowser.ts | 147 +++++++-- packages/ui/utils/defaultNotesApp.test.ts | 46 +++ packages/ui/utils/defaultNotesApp.ts | 12 +- packages/ui/utils/roam.test.ts | 138 ++++++++ packages/ui/utils/roam.ts | 127 ++++++++ 40 files changed, 3315 insertions(+), 517 deletions(-) create mode 100644 apps/pi-extension/server/roam-client.test.ts create mode 100644 apps/pi-extension/server/roam-client.ts delete mode 100644 packages/review-editor/components/AgentReviewActions.tsx create mode 100644 packages/server/roam-client.test.ts create mode 100644 packages/server/roam-client.ts create mode 100644 packages/server/roam-reference.test.ts create mode 100644 packages/ui/components/ExportModal.test.tsx create mode 100644 packages/ui/components/sidebar/FileBrowser.test.tsx create mode 100644 packages/ui/utils/defaultNotesApp.test.ts create mode 100644 packages/ui/utils/roam.test.ts create mode 100644 packages/ui/utils/roam.ts 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} +
) : ( + ) : ( + 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 (