/** * Chain Workbench Modal - UI for chain workbench. */ import { Modal, Setting, Notice } from "obsidian"; import type { App } from "obsidian"; import type { WorkbenchModel, WorkbenchMatch, WorkbenchTodoUnion } from "../workbench/types"; import type { MindnetSettings } from "../settings"; import type { ChainRolesConfig, ChainTemplatesConfig } from "../dictionary/types"; import type { Vocabulary } from "../vocab/Vocabulary"; export class ChainWorkbenchModal extends Modal { private model: WorkbenchModel; private settings: MindnetSettings; private chainRoles: ChainRolesConfig | null; private chainTemplates: ChainTemplatesConfig | null; private vocabulary: Vocabulary; private pluginInstance: any; private selectedMatch: WorkbenchMatch | null = null; private filterStatus: string | null = null; private searchQuery: string = ""; constructor( app: App, model: WorkbenchModel, settings: MindnetSettings, chainRoles: ChainRolesConfig | null, chainTemplates: ChainTemplatesConfig | null, vocabulary: Vocabulary, pluginInstance: any ) { super(app); this.model = model; this.settings = settings; this.chainRoles = chainRoles; this.chainTemplates = chainTemplates; this.vocabulary = vocabulary; this.pluginInstance = pluginInstance; } onOpen(): void { const { contentEl } = this; contentEl.empty(); // Header contentEl.createEl("h2", { text: "Chain Workbench" }); contentEl.createEl("p", { text: `Context: ${this.model.context.file}${this.model.context.heading ? `#${this.model.context.heading}` : ""}`, }); // Filters const filterContainer = contentEl.createDiv({ cls: "workbench-filters" }); filterContainer.createEl("label", { text: "Filter by Status:" }); const statusSelect = filterContainer.createEl("select"); statusSelect.createEl("option", { text: "All", value: "" }); statusSelect.createEl("option", { text: "Complete", value: "complete" }); statusSelect.createEl("option", { text: "Near Complete", value: "near_complete" }); statusSelect.createEl("option", { text: "Partial", value: "partial" }); statusSelect.createEl("option", { text: "Weak", value: "weak" }); statusSelect.addEventListener("change", (e) => { const target = e.target as HTMLSelectElement; this.filterStatus = target.value || null; this.render(); }); filterContainer.createEl("label", { text: "Search:" }); const searchInput = filterContainer.createEl("input", { type: "text", placeholder: "Template name..." }); searchInput.addEventListener("input", (e) => { const target = e.target as HTMLInputElement; this.searchQuery = target.value.toLowerCase(); this.render(); }); // Main container: list + details const mainContainer = contentEl.createDiv({ cls: "workbench-main" }); // Left: Match list const listContainer = mainContainer.createDiv({ cls: "workbench-list" }); listContainer.createEl("h3", { text: "Template Matches" }); // Right: Details const detailsContainer = mainContainer.createDiv({ cls: "workbench-details" }); detailsContainer.createEl("h3", { text: "Details" }); // Global todos section (if any) if (this.model.globalTodos && this.model.globalTodos.length > 0) { const globalTodosSection = contentEl.createDiv({ cls: "workbench-global-todos" }); globalTodosSection.createEl("h3", { text: "Section-Level Issues" }); globalTodosSection.createEl("p", { text: `Found ${this.model.globalTodos.length} issue(s) from Chain Inspector findings`, }); } this.render(); } private render(): void { const { contentEl } = this; // Filter matches let filteredMatches = this.model.matches; if (this.filterStatus) { filteredMatches = filteredMatches.filter((m) => m.status === this.filterStatus); } if (this.searchQuery) { filteredMatches = filteredMatches.filter((m) => m.templateName.toLowerCase().includes(this.searchQuery) ); } // Sort: near_complete first (default), then by score descending filteredMatches.sort((a, b) => { if (a.status === "near_complete" && b.status !== "near_complete") return -1; if (a.status !== "near_complete" && b.status === "near_complete") return 1; return b.score - a.score; }); // Render list const listContainer = contentEl.querySelector(".workbench-list"); if (listContainer) { listContainer.empty(); listContainer.createEl("h3", { text: `Template Matches (${filteredMatches.length})` }); for (const match of filteredMatches) { const matchEl = listContainer.createDiv({ cls: "workbench-match-item" }); if (this.selectedMatch === match) { matchEl.addClass("selected"); } const statusIcon = this.getStatusIcon(match.status); matchEl.createEl("div", { cls: "match-header", text: `${statusIcon} ${match.templateName}`, }); matchEl.createEl("div", { cls: "match-stats", text: `Slots: ${match.slotsFilled}/${match.slotsTotal} | Links: ${match.satisfiedLinks}/${match.requiredLinks} | Score: ${match.score}`, }); matchEl.createEl("div", { cls: "match-todos-count", text: `${match.todos.length} todo(s)`, }); matchEl.addEventListener("click", () => { this.selectedMatch = match; this.renderDetails(); }); } } // Render details this.renderDetails(); // Render global todos this.renderGlobalTodos(); } private renderGlobalTodos(): void { const globalTodosSection = this.contentEl.querySelector(".workbench-global-todos"); if (!globalTodosSection || !this.model.globalTodos || this.model.globalTodos.length === 0) { return; } // Clear and re-render const existingList = globalTodosSection.querySelector(".global-todos-list"); if (existingList) { existingList.remove(); } const todosList = globalTodosSection.createDiv({ cls: "global-todos-list" }); for (const todo of this.model.globalTodos) { const todoEl = todosList.createDiv({ cls: "global-todo-item" }); todoEl.createEl("div", { cls: "todo-type", text: todo.type }); todoEl.createEl("div", { cls: "todo-description", text: todo.description }); todoEl.createEl("div", { cls: "todo-priority", text: `Priority: ${todo.priority}` }); // Render actions const actionsContainer = todoEl.createDiv({ cls: "todo-actions" }); if ( todo.type === "dangling_target" || todo.type === "dangling_target_heading" || todo.type === "only_candidates" || todo.type === "missing_edges" || todo.type === "no_causal_roles" ) { const actions = (todo as any).actions || []; for (const action of actions) { const actionBtn = actionsContainer.createEl("button", { text: this.getActionLabel(action), }); actionBtn.addEventListener("click", () => { this.handleGlobalTodoAction(todo, action); }); } } else if (todo.type === "one_sided_connectivity") { // Informational only - no actions actionsContainer.createEl("span", { text: "Informational only", cls: "info-text" }); } } } private renderDetails(): void { const detailsContainer = this.contentEl.querySelector(".workbench-details"); if (!detailsContainer) return; detailsContainer.empty(); detailsContainer.createEl("h3", { text: "Details" }); if (!this.selectedMatch) { detailsContainer.createEl("p", { text: "Select a match to view details" }); return; } const match = this.selectedMatch; // Match info detailsContainer.createEl("h4", { text: match.templateName }); detailsContainer.createEl("p", { text: `Status: ${match.status} | Score: ${match.score} | Confidence: ${match.confidence}`, }); // Slots detailsContainer.createEl("h5", { text: "Slots" }); const slotsList = detailsContainer.createEl("ul"); for (const [slotId, assignment] of Object.entries(match.slotAssignments)) { if (assignment) { const nodeStr = assignment.heading ? `${assignment.file}#${assignment.heading}` : assignment.file; slotsList.createEl("li", { text: `${slotId}: ${nodeStr} [${assignment.noteType}]` }); } } if (match.missingSlots.length > 0) { slotsList.createEl("li", { text: `Missing: ${match.missingSlots.join(", ")}`, cls: "missing", }); } // Links detailsContainer.createEl("h5", { text: "Links" }); detailsContainer.createEl("p", { text: `${match.satisfiedLinks}/${match.requiredLinks} satisfied (${match.linksComplete ? "complete" : "incomplete"})`, }); // Todos detailsContainer.createEl("h5", { text: `Todos (${match.todos.length})` }); const todosList = detailsContainer.createEl("div", { cls: "todos-list" }); for (const todo of match.todos) { const todoEl = todosList.createDiv({ cls: `todo-item todo-${todo.type}` }); todoEl.createEl("div", { cls: "todo-description", text: todo.description }); todoEl.createEl("div", { cls: "todo-priority", text: `Priority: ${todo.priority}` }); // Render actions const actionsContainer = todoEl.createDiv({ cls: "todo-actions" }); if ( todo.type === "missing_slot" || todo.type === "missing_link" || todo.type === "weak_roles" || todo.type === "candidate_cleanup" ) { const actions = (todo as any).actions || []; for (const action of actions) { const actionBtn = actionsContainer.createEl("button", { text: this.getActionLabel(action), }); actionBtn.addEventListener("click", () => { this.handleTodoAction(todo as WorkbenchTodoUnion, action, match); }); } } } } private getStatusIcon(status: string): string { switch (status) { case "complete": return "✓"; case "near_complete": return "~"; case "partial": return "○"; case "weak": return "⚠"; default: return "?"; } } private getActionLabel(action: string): string { const labels: Record = { link_existing: "Link Existing", create_note_via_interview: "Create Note", insert_edge_forward: "Insert Edge", insert_edge_inverse: "Insert Inverse", choose_target_anchor: "Choose Anchor", change_edge_type: "Change Type", promote_candidate: "Promote", resolve_candidate: "Resolve", create_missing_note: "Create Missing Note", retarget_link: "Retarget Link", create_missing_heading: "Create Missing Heading", retarget_to_existing_heading: "Retarget to Existing Heading", promote_all_candidates: "Promote All Candidates", create_explicit_edges: "Create Explicit Edges", add_edges_to_section: "Add Edges to Section", }; return labels[action] || action; } private async handleTodoAction( todo: WorkbenchTodoUnion, action: string, match: WorkbenchMatch ): Promise { console.log("[Chain Workbench] Action:", action, "for todo:", todo.type); try { const activeFile = this.app.workspace.getActiveFile(); const activeEditor = this.app.workspace.activeEditor?.editor; if (!activeFile || !activeEditor) { new Notice("No active file or editor"); return; } // Import action handlers const { insertEdgeForward, promoteCandidate } = await import("../workbench/writerActions"); const graphSchema = await this.pluginInstance.ensureGraphSchemaLoaded(); switch (action) { case "insert_edge_forward": if (todo.type === "missing_link") { console.log("[Chain Workbench] Starting insert_edge_forward for missing_link todo"); // Let user choose zone console.log("[Chain Workbench] Opening zone chooser..."); const zoneChoice = await this.chooseZone(activeFile); console.log("[Chain Workbench] Zone choice:", zoneChoice); if (zoneChoice === null) { console.log("[Chain Workbench] User cancelled zone selection"); return; // User cancelled } // Get edge vocabulary console.log("[Chain Workbench] Loading edge vocabulary..."); const { VocabularyLoader } = await import("../vocab/VocabularyLoader"); const { parseEdgeVocabulary } = await import("../vocab/parseEdgeVocabulary"); const vocabText = await VocabularyLoader.loadText(this.app, this.settings.edgeVocabularyPath); const edgeVocabulary = parseEdgeVocabulary(vocabText); console.log("[Chain Workbench] Edge vocabulary loaded, entries:", edgeVocabulary.byCanonical.size); console.log("[Chain Workbench] Calling insertEdgeForward..."); try { await insertEdgeForward( this.app, activeEditor, activeFile, todo, this.vocabulary, edgeVocabulary, this.settings, graphSchema, zoneChoice ); console.log("[Chain Workbench] insertEdgeForward completed successfully"); } catch (error) { console.error("[Chain Workbench] Error in insertEdgeForward:", error); new Notice(`Error inserting edge: ${error instanceof Error ? error.message : String(error)}`); return; } // Re-run analysis console.log("[Chain Workbench] Refreshing workbench..."); await this.refreshWorkbench(); new Notice("Edge inserted successfully"); } break; case "promote_candidate": if (todo.type === "candidate_cleanup") { await promoteCandidate( this.app, activeEditor, activeFile, todo, this.settings ); // Re-run analysis await this.refreshWorkbench(); new Notice("Candidate promoted successfully"); } break; case "link_existing": if (todo.type === "missing_slot") { const { linkExisting } = await import("../workbench/linkExistingAction"); await linkExisting( this.app, activeEditor, activeFile, todo, this.model.context ); // Re-run analysis await this.refreshWorkbench(); new Notice("Link inserted. Re-run workbench to update slot assignments."); } break; case "create_note_via_interview": if (todo.type === "missing_slot") { const { createNoteViaInterview } = await import("../workbench/interviewOrchestration"); // Get interview config from plugin instance let interviewConfig = null; if (this.pluginInstance.ensureInterviewConfigLoaded) { interviewConfig = await this.pluginInstance.ensureInterviewConfigLoaded(); } if (!interviewConfig) { new Notice("Interview config not available"); return; } // Find the match for this todo const matchForTodo = this.model.matches.find((m) => m.todos.some((t) => t.id === todo.id) ); if (!matchForTodo) { new Notice("Could not find match for todo"); return; } // Get edge vocabulary - we need to load it from settings const { VocabularyLoader } = await import("../vocab/VocabularyLoader"); const { parseEdgeVocabulary } = await import("../vocab/parseEdgeVocabulary"); const vocabText = await VocabularyLoader.loadText(this.app, this.settings.edgeVocabularyPath); const edgeVocabulary = parseEdgeVocabulary(vocabText); await createNoteViaInterview( this.app, todo, matchForTodo.templateName, matchForTodo, interviewConfig, this.chainTemplates, this.chainRoles, this.settings, this.vocabulary, edgeVocabulary, this.pluginInstance ); // Re-run analysis after note creation await this.refreshWorkbench(); new Notice("Note created via interview"); } break; default: new Notice(`Action "${action}" not yet implemented`); } } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.error("[Chain Workbench] Error handling action:", e); new Notice(`Failed to execute action: ${msg}`); } } private async handleGlobalTodoAction(todo: WorkbenchTodoUnion, action: string): Promise { console.log("[Chain Workbench] Global Todo Action:", action, "for todo:", todo.type); try { const activeFile = this.app.workspace.getActiveFile(); const activeEditor = this.app.workspace.activeEditor?.editor; if (!activeFile || !activeEditor) { new Notice("No active file or editor"); return; } // Import action handlers const { promoteCandidate } = await import("../workbench/writerActions"); const { linkExisting } = await import("../workbench/linkExistingAction"); switch (action) { case "create_missing_note": if (todo.type === "dangling_target") { const danglingTodo = todo as any; // Use linkExisting action to create note await linkExisting( this.app, activeEditor, activeFile, { type: "missing_slot", id: `dangling_target_create_${danglingTodo.targetFile}`, description: `Create missing note: ${danglingTodo.targetFile}`, priority: "high", slotId: "", allowedNodeTypes: [], actions: ["create_note_via_interview"], }, this.model.context ); await this.refreshWorkbench(); new Notice("Note creation initiated"); } break; case "retarget_link": if (todo.type === "dangling_target") { const danglingTodo = todo as any; // Use linkExisting to retarget await linkExisting( this.app, activeEditor, activeFile, { type: "missing_slot", id: `dangling_target_retarget_${danglingTodo.targetFile}`, description: `Retarget link to existing note`, priority: "high", slotId: "", allowedNodeTypes: [], actions: ["link_existing"], }, this.model.context ); await this.refreshWorkbench(); new Notice("Link retargeting initiated"); } break; case "promote_all_candidates": if (todo.type === "only_candidates") { const onlyCandidatesTodo = todo as any; // Promote each candidate edge for (const candidateEdge of onlyCandidatesTodo.candidateEdges || []) { // Find the actual edge in the graph // This is a simplified version - full implementation would need to find the edge new Notice(`Promoting candidate edge: ${candidateEdge.rawEdgeType}`); } await this.refreshWorkbench(); new Notice("Candidates promoted"); } break; case "promote_candidate": // Similar to template-based promote_candidate if (todo.type === "candidate_cleanup") { await promoteCandidate( this.app, activeEditor, activeFile, todo, this.settings ); await this.refreshWorkbench(); new Notice("Candidate promoted"); } break; case "change_edge_type": if (todo.type === "no_causal_roles" || todo.type === "weak_roles") { // Use existing change_edge_type logic new Notice("Change edge type - feature coming soon"); } break; default: new Notice(`Action "${action}" not yet implemented for ${todo.type}`); break; } } catch (e) { console.error("[Chain Workbench] Error handling global todo action:", e); new Notice(`Error: ${e instanceof Error ? e.message : String(e)}`); } } private async chooseZone(file: any): Promise<"section" | "note_links" | "candidates" | null> { console.log("[chooseZone] Starting zone selection"); return new Promise((resolve) => { let resolved = false; const { Modal, Setting } = require("obsidian"); const modal = new Modal(this.app); modal.titleEl.textContent = "Choose Zone"; modal.contentEl.createEl("p", { text: "Where should the edge be inserted?" }); const doResolve = (value: "section" | "note_links" | "candidates" | null) => { if (!resolved) { resolved = true; resolve(value); } }; new Setting(modal.contentEl) .setName("Section-scope") .setDesc("Insert in source section (standard)") .addButton((btn: any) => btn.setButtonText("Select").onClick(() => { console.log("[chooseZone] User selected: section"); doResolve("section"); modal.close(); }) ); new Setting(modal.contentEl) .setName("Note-Verbindungen") .setDesc("Insert in Note-Verbindungen zone (note-scope)") .addButton((btn: any) => btn.setButtonText("Select").onClick(() => { console.log("[chooseZone] User selected: note_links"); doResolve("note_links"); modal.close(); }) ); new Setting(modal.contentEl) .setName("Kandidaten") .setDesc("Insert in Kandidaten zone (candidate)") .addButton((btn: any) => btn.setButtonText("Select").onClick(() => { console.log("[chooseZone] User selected: candidates"); doResolve("candidates"); modal.close(); }) ); modal.onClose = () => { console.log("[chooseZone] Modal closed, resolved:", resolved); if (!resolved) { console.log("[chooseZone] Resolving with null (user cancelled)"); doResolve(null); } }; console.log("[chooseZone] Opening modal..."); modal.open(); }); } private async refreshWorkbench(): Promise { // Close and reopen workbench to refresh this.close(); // Re-run command const { executeChainWorkbench } = await import("../commands/chainWorkbenchCommand"); const activeFile = this.app.workspace.getActiveFile(); const activeEditor = this.app.workspace.activeEditor?.editor; if (activeFile && activeEditor) { await executeChainWorkbench( this.app, activeEditor, activeFile.path, this.chainRoles, this.chainTemplates, undefined, this.settings, this.pluginInstance ); } } onClose(): void { const { contentEl } = this; contentEl.empty(); } }