summaryrefslogtreecommitdiff
path: root/makima/frontend/src/lib
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-11 05:52:14 +0000
committersoryu <soryu@soryu.co>2026-01-15 00:21:16 +0000
commit87044a747b47bd83249d61a45842c7f7b2eae56d (patch)
treeef2000ce79ffcc2723ef841acef5aa1deb1d5378 /makima/frontend/src/lib
parent077820c4167c168072d217a1b01df840463a12a8 (diff)
downloadsoryu-87044a747b47bd83249d61a45842c7f7b2eae56d.tar.gz
soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.zip
Contract system
Diffstat (limited to 'makima/frontend/src/lib')
-rw-r--r--makima/frontend/src/lib/api.ts542
-rw-r--r--makima/frontend/src/lib/markdown.ts228
2 files changed, 768 insertions, 2 deletions
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index a11f15e..d77c85c 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -132,7 +132,8 @@ export type BodyElement =
data: Record<string, unknown>[];
config?: Record<string, unknown>;
}
- | { type: "image"; src: string; alt?: string; caption?: string };
+ | { type: "image"; src: string; alt?: string; caption?: string }
+ | { type: "markdown"; content: string };
export interface FileSummary {
id: string;
@@ -141,8 +142,16 @@ export interface FileSummary {
transcriptCount: number;
duration: number | null;
version: number;
+ /** Path to linked repository file (e.g., "README.md") */
+ repoFilePath: string | null;
+ /** Sync status: 'none', 'synced', 'modified', 'conflict' */
+ repoSyncStatus: 'none' | 'synced' | 'modified' | 'conflict' | null;
createdAt: string;
updatedAt: string;
+ // Contract info (joined from contracts table)
+ contractId: string | null;
+ contractName: string | null;
+ contractPhase: ContractPhase | null;
}
export interface FileDetail {
@@ -155,6 +164,12 @@ export interface FileDetail {
summary: string | null;
body: BodyElement[];
version: number;
+ /** Path to linked repository file (e.g., "README.md") */
+ repoFilePath: string | null;
+ /** When file was last synced from repository */
+ repoSyncedAt: string | null;
+ /** Sync status: 'none', 'synced', 'modified', 'conflict' */
+ repoSyncStatus: 'none' | 'synced' | 'modified' | 'conflict' | null;
createdAt: string;
updatedAt: string;
}
@@ -165,10 +180,14 @@ export interface FileListResponse {
}
export interface CreateFileRequest {
+ /** Contract this file belongs to (required - files must belong to a contract) */
+ contractId: string;
name?: string;
description?: string;
- transcript: TranscriptEntry[];
+ transcript?: TranscriptEntry[];
location?: string;
+ /** Initial body elements (e.g., from a template) */
+ body?: BodyElement[];
}
export interface UpdateFileRequest {
@@ -400,6 +419,23 @@ export async function restoreFileVersion(
return res.json();
}
+/**
+ * Sync a file from its linked repository file.
+ * Triggers an async operation - the file will be updated when the daemon responds.
+ * Returns 202 Accepted if the sync started successfully.
+ */
+export async function syncFileFromRepo(fileId: string): Promise<{ message: string; fileId: string }> {
+ const res = await authFetch(`${API_BASE}/api/v1/files/${fileId}/sync-from-repo`, {
+ method: "POST",
+ });
+
+ if (!res.ok) {
+ const error = await res.json();
+ throw new Error(error.message || `Failed to sync file: ${res.statusText}`);
+ }
+ return res.json();
+}
+
// =============================================================================
// LLM Tool Definitions for Version History
// =============================================================================
@@ -490,6 +526,12 @@ export type DaemonStatus = "connected" | "disconnected" | "unhealthy";
export interface TaskSummary {
id: string;
+ /** Contract this task belongs to */
+ contractId: string | null;
+ /** Contract name (joined from contracts table) */
+ contractName: string | null;
+ /** Contract phase (joined from contracts table) */
+ contractPhase: ContractPhase | null;
parentTaskId: string | null;
depth: number;
name: string;
@@ -497,6 +539,8 @@ export interface TaskSummary {
priority: number;
progressSummary: string | null;
subtaskCount: number;
+ /** Whether this is a supervisor task (contract orchestrator) */
+ isSupervisor: boolean;
version: number;
createdAt: string;
updatedAt: string;
@@ -505,6 +549,8 @@ export interface TaskSummary {
export interface Task {
id: string;
ownerId: string;
+ /** Contract this task belongs to */
+ contractId: string | null;
parentTaskId: string | null;
depth: number;
name: string;
@@ -556,6 +602,8 @@ export interface TaskListResponse {
}
export interface CreateTaskRequest {
+ /** Contract this task belongs to (required) */
+ contractId: string;
name: string;
description?: string;
plan: string;
@@ -1289,3 +1337,493 @@ export async function deleteAccount(
}
return res.json();
}
+
+// =============================================================================
+// Contract Types for Workflow Management
+// =============================================================================
+
+export type ContractPhase = "research" | "specify" | "plan" | "execute" | "review";
+export type ContractStatus = "active" | "completed" | "archived";
+export type RepositorySourceType = "remote" | "local" | "managed";
+export type RepositoryStatus = "ready" | "pending" | "creating" | "failed";
+
+export interface ContractRepository {
+ id: string;
+ contractId: string;
+ name: string;
+ repositoryUrl: string | null;
+ localPath: string | null;
+ sourceType: RepositorySourceType;
+ status: RepositoryStatus;
+ isPrimary: boolean;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface ContractSummary {
+ id: string;
+ name: string;
+ description: string | null;
+ phase: ContractPhase;
+ status: ContractStatus;
+ fileCount: number;
+ taskCount: number;
+ repositoryCount: number;
+ version: number;
+ createdAt: string;
+}
+
+export interface Contract {
+ id: string;
+ ownerId: string;
+ name: string;
+ description: string | null;
+ phase: ContractPhase;
+ status: ContractStatus;
+ /** Supervisor task ID for contract orchestration */
+ supervisorTaskId: string | null;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface ContractWithRelations extends Contract {
+ repositories: ContractRepository[];
+ files: FileSummary[];
+ tasks: TaskSummary[];
+}
+
+export interface ContractEvent {
+ id: string;
+ contractId: string;
+ eventType: string;
+ previousPhase: string | null;
+ newPhase: string | null;
+ eventData: Record<string, unknown> | null;
+ createdAt: string;
+}
+
+export interface ContractListResponse {
+ contracts: ContractSummary[];
+ total: number;
+}
+
+export interface CreateContractRequest {
+ name: string;
+ description?: string;
+ /** Initial phase to start in (defaults to "research") */
+ initialPhase?: ContractPhase;
+}
+
+export interface UpdateContractRequest {
+ name?: string;
+ description?: string;
+ phase?: ContractPhase;
+ status?: ContractStatus;
+ version?: number;
+}
+
+export interface AddRemoteRepositoryRequest {
+ name: string;
+ repositoryUrl: string;
+ isPrimary?: boolean;
+}
+
+export interface AddLocalRepositoryRequest {
+ name: string;
+ localPath: string;
+ isPrimary?: boolean;
+}
+
+export interface CreateManagedRepositoryRequest {
+ name: string;
+ isPrimary?: boolean;
+}
+
+export interface ChangePhaseRequest {
+ phase: ContractPhase;
+}
+
+// =============================================================================
+// Contract API Functions
+// =============================================================================
+
+/**
+ * List all contracts.
+ */
+export async function listContracts(): Promise<ContractListResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts`);
+ if (!res.ok) {
+ throw new Error(`Failed to list contracts: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Get a contract with all its relations.
+ */
+export async function getContract(id: string): Promise<ContractWithRelations> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}`);
+ if (!res.ok) {
+ throw new Error(`Failed to get contract: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Create a new contract.
+ */
+export async function createContract(
+ data: CreateContractRequest
+): Promise<ContractSummary> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts`, {
+ method: "POST",
+ body: JSON.stringify(data),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to create contract: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Update a contract.
+ */
+export async function updateContract(
+ id: string,
+ data: UpdateContractRequest
+): Promise<ContractSummary> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}`, {
+ method: "PUT",
+ body: JSON.stringify(data),
+ });
+
+ if (res.status === 409) {
+ const conflict = (await res.json()) as ConflictErrorResponse;
+ throw new VersionConflictError(conflict);
+ }
+
+ if (!res.ok) {
+ throw new Error(`Failed to update contract: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Delete a contract.
+ */
+export async function deleteContract(id: string): Promise<void> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to delete contract: ${res.statusText}`);
+ }
+}
+
+/**
+ * Change contract phase.
+ */
+export async function changeContractPhase(
+ id: string,
+ phase: ContractPhase
+): Promise<ContractSummary> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}/phase`, {
+ method: "POST",
+ body: JSON.stringify({ phase }),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to change phase: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Get contract event history.
+ */
+export async function getContractEvents(
+ id: string
+): Promise<ContractEvent[]> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}/events`);
+ if (!res.ok) {
+ throw new Error(`Failed to get events: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+// =============================================================================
+// Contract Repository Management
+// =============================================================================
+
+/**
+ * Add a remote repository to a contract.
+ */
+export async function addRemoteRepository(
+ contractId: string,
+ data: AddRemoteRepositoryRequest
+): Promise<ContractRepository> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/repositories/remote`,
+ {
+ method: "POST",
+ body: JSON.stringify(data),
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to add remote repository: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Add a local repository to a contract.
+ */
+export async function addLocalRepository(
+ contractId: string,
+ data: AddLocalRepositoryRequest
+): Promise<ContractRepository> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/repositories/local`,
+ {
+ method: "POST",
+ body: JSON.stringify(data),
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to add local repository: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Create a managed repository (daemon will create it).
+ */
+export async function createManagedRepository(
+ contractId: string,
+ data: CreateManagedRepositoryRequest
+): Promise<ContractRepository> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/repositories/managed`,
+ {
+ method: "POST",
+ body: JSON.stringify(data),
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to create managed repository: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Delete a repository from a contract.
+ */
+export async function deleteContractRepository(
+ contractId: string,
+ repoId: string
+): Promise<void> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/repositories/${repoId}`,
+ {
+ method: "DELETE",
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to delete repository: ${res.statusText}`);
+ }
+}
+
+/**
+ * Set a repository as primary.
+ */
+export async function setRepositoryPrimary(
+ contractId: string,
+ repoId: string
+): Promise<void> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/repositories/${repoId}/primary`,
+ {
+ method: "PUT",
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to set repository as primary: ${res.statusText}`);
+ }
+}
+
+// =============================================================================
+// Contract Task Association
+// =============================================================================
+
+/**
+ * Add a task to a contract.
+ */
+export async function addTaskToContract(
+ contractId: string,
+ taskId: string
+): Promise<void> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/tasks/${taskId}`,
+ {
+ method: "POST",
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to add task to contract: ${res.statusText}`);
+ }
+}
+
+/**
+ * Remove a task from a contract.
+ */
+export async function removeTaskFromContract(
+ contractId: string,
+ taskId: string
+): Promise<void> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/tasks/${taskId}`,
+ {
+ method: "DELETE",
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to remove task from contract: ${res.statusText}`);
+ }
+}
+
+// =============================================================================
+// Contract Chat Types and API
+// =============================================================================
+
+export interface ContractChatRequest {
+ message: string;
+ model?: LlmModel;
+ history?: ChatMessage[];
+}
+
+export interface ContractToolCallInfo {
+ name: string;
+ result: {
+ success: boolean;
+ message: string;
+ };
+}
+
+export interface ContractChatResponse {
+ response: string;
+ toolCalls: ContractToolCallInfo[];
+ pendingQuestions?: UserQuestion[];
+}
+
+/**
+ * Chat with a contract using LLM-powered management tools.
+ */
+export async function chatWithContract(
+ contractId: string,
+ message: string,
+ model?: LlmModel,
+ history?: ChatMessage[]
+): Promise<ContractChatResponse> {
+ const body: ContractChatRequest = { message };
+ if (model) {
+ body.model = model;
+ }
+ if (history && history.length > 0) {
+ body.history = history;
+ }
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${contractId}/chat`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Contract chat failed: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+// Contract chat history types
+export interface ContractChatMessage {
+ id: string;
+ conversationId: string;
+ role: "user" | "assistant" | "error";
+ content: string;
+ toolCalls?: unknown;
+ pendingQuestions?: unknown;
+ createdAt: string;
+}
+
+export interface ContractChatHistoryResponse {
+ contractId: string;
+ conversationId: string;
+ messages: ContractChatMessage[];
+}
+
+/** Get contract chat history */
+export async function getContractChatHistory(
+ contractId: string
+): Promise<ContractChatHistoryResponse> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/chat/history`
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to fetch contract chat history: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/** Clear contract chat history (starts a new conversation) */
+export async function clearContractChatHistory(
+ contractId: string
+): Promise<void> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/chat/history`,
+ { method: "DELETE" }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to clear contract chat history: ${res.statusText}`);
+ }
+}
+
+// =============================================================================
+// Template Types and API
+// =============================================================================
+
+export interface TemplateSummary {
+ id: string;
+ name: string;
+ phase: ContractPhase;
+ description: string;
+ elementCount: number;
+}
+
+export interface FileTemplate {
+ id: string;
+ name: string;
+ phase: ContractPhase;
+ description: string;
+ suggestedBody: BodyElement[];
+}
+
+export interface ListTemplatesResponse {
+ templates: TemplateSummary[];
+}
+
+export async function listTemplates(
+ phase?: ContractPhase
+): Promise<ListTemplatesResponse> {
+ const params = phase ? `?phase=${phase}` : "";
+ const res = await authFetch(`${API_BASE}/api/v1/templates${params}`);
+ if (!res.ok) {
+ throw new Error(`Failed to list templates: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function getTemplate(id: string): Promise<FileTemplate> {
+ const res = await authFetch(`${API_BASE}/api/v1/templates/${id}`);
+ if (!res.ok) {
+ throw new Error(`Failed to get template: ${res.statusText}`);
+ }
+ return res.json();
+}
diff --git a/makima/frontend/src/lib/markdown.ts b/makima/frontend/src/lib/markdown.ts
new file mode 100644
index 0000000..b6e860a
--- /dev/null
+++ b/makima/frontend/src/lib/markdown.ts
@@ -0,0 +1,228 @@
+/**
+ * Markdown conversion utilities for BodyElement arrays.
+ *
+ * Provides bidirectional conversion between structured BodyElement[] and markdown strings.
+ */
+
+import { BodyElement } from "./api";
+
+/**
+ * Convert an array of BodyElements to a markdown string.
+ *
+ * Handles:
+ * - Headings: # through ###### based on level
+ * - Paragraphs: plain text with blank lines between
+ * - Code blocks: ```language\ncontent\n```
+ * - Lists: ordered (1. 2. 3.) and unordered (- - -)
+ * - Charts: rendered as fenced JSON
+ * - Images: rendered as markdown image syntax
+ */
+export function bodyToMarkdown(elements: BodyElement[]): string {
+ return elements
+ .map((elem) => {
+ switch (elem.type) {
+ case "heading": {
+ const hashes = "#".repeat(Math.min(elem.level, 6));
+ return `${hashes} ${elem.text}`;
+ }
+ case "paragraph":
+ return elem.text;
+ case "code": {
+ const lang = elem.language || "";
+ return `\`\`\`${lang}\n${elem.content}\n\`\`\``;
+ }
+ case "list": {
+ return elem.items
+ .map((item, i) => (elem.ordered ? `${i + 1}. ${item}` : `- ${item}`))
+ .join("\n");
+ }
+ case "chart": {
+ const titleStr = elem.title ? ` - ${elem.title}` : "";
+ const dataStr = JSON.stringify(elem.data, null, 2);
+ return `\`\`\`chart:${elem.chartType}${titleStr}\n${dataStr}\n\`\`\``;
+ }
+ case "image": {
+ const alt = elem.alt || "image";
+ const caption = elem.caption ? `\n*${elem.caption}*` : "";
+ return `![${alt}](${elem.src})${caption}`;
+ }
+ case "markdown":
+ // Markdown elements output their content directly
+ return elem.content;
+ default:
+ return "";
+ }
+ })
+ .filter((s) => s !== "")
+ .join("\n\n");
+}
+
+/**
+ * Parse a markdown string into an array of BodyElements.
+ *
+ * Handles:
+ * - Headings: lines starting with # through ######
+ * - Code blocks: ```language ... ```
+ * - Ordered lists: lines starting with 1. 2. etc.
+ * - Unordered lists: lines starting with - or * or +
+ * - Paragraphs: all other non-empty lines
+ */
+export function markdownToBody(markdown: string): BodyElement[] {
+ const elements: BodyElement[] = [];
+ const lines = markdown.split("\n");
+ let currentParagraph: string[] = [];
+ let inCodeBlock = false;
+ let codeBlockLanguage: string | undefined;
+ let codeBlockContent: string[] = [];
+ let currentList: { ordered: boolean; items: string[] } | null = null;
+
+ const flushParagraph = () => {
+ if (currentParagraph.length > 0) {
+ const text = currentParagraph.join("\n").trim();
+ if (text) {
+ elements.push({ type: "paragraph", text });
+ }
+ currentParagraph = [];
+ }
+ };
+
+ const flushCodeBlock = () => {
+ if (codeBlockContent.length > 0 || inCodeBlock) {
+ elements.push({
+ type: "code",
+ language: codeBlockLanguage || undefined,
+ content: codeBlockContent.join("\n"),
+ });
+ codeBlockContent = [];
+ codeBlockLanguage = undefined;
+ }
+ };
+
+ const flushList = () => {
+ if (currentList && currentList.items.length > 0) {
+ elements.push({
+ type: "list",
+ ordered: currentList.ordered,
+ items: currentList.items,
+ });
+ currentList = null;
+ }
+ };
+
+ // Convert image syntax ![alt](url) to link syntax [alt](url) or [image](url)
+ const convertImagesToLinks = (text: string): string => {
+ return text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
+ const linkText = alt || "image";
+ return `[${linkText}](${url})`;
+ });
+ };
+
+ for (const rawLine of lines) {
+ // Check for code block fence (``` or ~~~)
+ const codeFenceMatch = rawLine.match(/^(`{3,}|~{3,})(\w*)?$/);
+ if (codeFenceMatch) {
+ if (!inCodeBlock) {
+ // Starting a code block
+ flushParagraph();
+ flushList();
+ inCodeBlock = true;
+ codeBlockLanguage = codeFenceMatch[2] || undefined;
+ codeBlockContent = [];
+ } else {
+ // Ending a code block
+ flushCodeBlock();
+ inCodeBlock = false;
+ }
+ continue;
+ }
+
+ // If inside a code block, add line as-is
+ if (inCodeBlock) {
+ codeBlockContent.push(rawLine);
+ continue;
+ }
+
+ // Convert images to links in all lines
+ const line = convertImagesToLinks(rawLine);
+
+ // Check for headings
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
+ if (headingMatch) {
+ flushParagraph();
+ flushList();
+ const level = headingMatch[1].length;
+ const text = headingMatch[2].trim();
+ elements.push({ type: "heading", level, text });
+ continue;
+ }
+
+ // Check for unordered list items (-, *, +)
+ const unorderedMatch = line.match(/^[\s]*[-*+]\s+(.+)$/);
+ if (unorderedMatch) {
+ flushParagraph();
+ const itemText = unorderedMatch[1].trim();
+ if (currentList && currentList.ordered) {
+ // Switch from ordered to unordered
+ flushList();
+ }
+ if (!currentList) {
+ currentList = { ordered: false, items: [] };
+ }
+ currentList.items.push(itemText);
+ continue;
+ }
+
+ // Check for ordered list items (1. 2. etc)
+ const orderedMatch = line.match(/^[\s]*\d+\.\s+(.+)$/);
+ if (orderedMatch) {
+ flushParagraph();
+ const itemText = orderedMatch[1].trim();
+ if (currentList && !currentList.ordered) {
+ // Switch from unordered to ordered
+ flushList();
+ }
+ if (!currentList) {
+ currentList = { ordered: true, items: [] };
+ }
+ currentList.items.push(itemText);
+ continue;
+ }
+
+ // Empty line - flush everything
+ if (line.trim() === "") {
+ flushParagraph();
+ flushList();
+ continue;
+ }
+
+ // Regular text - flush list first, then add to paragraph
+ flushList();
+ currentParagraph.push(line);
+ }
+
+ // Flush any remaining content
+ if (inCodeBlock) {
+ flushCodeBlock();
+ }
+ flushParagraph();
+ flushList();
+
+ return elements;
+}
+
+/**
+ * Copy markdown to clipboard.
+ * Returns true if successful, false otherwise.
+ */
+export async function copyMarkdownToClipboard(
+ elements: BodyElement[]
+): Promise<boolean> {
+ try {
+ const markdown = bodyToMarkdown(elements);
+ await navigator.clipboard.writeText(markdown);
+ return true;
+ } catch (error) {
+ console.error("Failed to copy markdown to clipboard:", error);
+ return false;
+ }
+}