/** * Chain Workbench Modal - UI for chain workbench. */ import { Modal, Setting, Notice, TFile } 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"; import type { IndexedEdge, SectionNode } from "../analysis/graphIndex"; import type { DictionaryLoadResult } from "../dictionary/types"; export class ChainWorkbenchModal extends Modal { private model: WorkbenchModel; private settings: MindnetSettings; private chainRoles: ChainRolesConfig | null; private chainTemplates: ChainTemplatesConfig | null; private templatesLoadResult: DictionaryLoadResult | undefined; 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, templatesLoadResult?: DictionaryLoadResult ) { super(app); this.model = model; this.settings = settings; this.chainRoles = chainRoles; this.chainTemplates = chainTemplates; this.templatesLoadResult = templatesLoadResult; this.vocabulary = vocabulary; this.pluginInstance = pluginInstance; // Add CSS class for wide modal this.modalEl.addClass("chain-workbench-modal"); } onOpen(): void { const { contentEl } = this; contentEl.empty(); // Header const header = contentEl.createDiv({ cls: "workbench-header" }); header.createEl("h2", { text: "Chain Workbench" }); header.createEl("p", { cls: "context-info", 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: two-column layout const mainContainer = contentEl.createDiv({ cls: "workbench-main" }); // Left: Tree View const treeContainer = mainContainer.createDiv({ cls: "workbench-tree" }); treeContainer.createEl("h3", { text: "Templates & Chains" }); // Right: Details View const detailsContainer = mainContainer.createDiv({ cls: "workbench-details" }); detailsContainer.createEl("h3", { text: "Chain Details" }); this.render(); } private render(): void { const { contentEl } = this; // Debug: Log render state console.log("[Chain Workbench] Render - model.matches.length:", this.model.matches.length); console.log("[Chain Workbench] Render - filterStatus:", this.filterStatus, "searchQuery:", this.searchQuery); // Filter matches let filteredMatches = this.model.matches; if (this.filterStatus) { filteredMatches = filteredMatches.filter((m) => m.status === this.filterStatus); console.log("[Chain Workbench] After status filter:", filteredMatches.length); } if (this.searchQuery) { filteredMatches = filteredMatches.filter((m) => m.templateName.toLowerCase().includes(this.searchQuery) ); console.log("[Chain Workbench] After search filter:", filteredMatches.length); } console.log("[Chain Workbench] Final filtered matches:", filteredMatches.length); // Sort: near_complete first (default), then by score descending // Use stable sort to preserve order within same status/score groups 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; const scoreDiff = b.score - a.score; if (scoreDiff !== 0) return scoreDiff; // Preserve original order for matches with same status and score // Use templateName + slotAssignments as tiebreaker for stability const aId = this.getMatchIdentifier(a); const bId = this.getMatchIdentifier(b); return aId.localeCompare(bId); }); // Render tree view (left column) this.renderTreeView(filteredMatches); // Render details (right column) this.renderDetails(); } private renderTreeView(matches: WorkbenchMatch[]): void { const treeContainer = this.contentEl.querySelector(".workbench-tree"); if (!treeContainer) return; treeContainer.empty(); treeContainer.createEl("h3", { text: "Templates & Chains" }); // Show message if no matches if (matches.length === 0) { const emptyMessage = treeContainer.createDiv({ cls: "workbench-empty-message" }); emptyMessage.createEl("p", { text: "Keine Chains gefunden." }); if (this.filterStatus || this.searchQuery) { emptyMessage.createEl("p", { cls: "workbench-empty-hint", text: "Versuchen Sie, die Filter zu löschen, um alle Chains anzuzeigen." }); } return; } // Group matches by template name const matchesByTemplate = new Map(); for (const match of matches) { if (!matchesByTemplate.has(match.templateName)) { matchesByTemplate.set(match.templateName, []); } matchesByTemplate.get(match.templateName)!.push(match); } // Create template tree const templateTree = treeContainer.createDiv({ cls: "template-tree" }); for (const [templateName, templateMatches] of matchesByTemplate.entries()) { const templateItem = templateTree.createDiv({ cls: "template-tree-item" }); const templateHeader = templateItem.createDiv({ cls: "template-tree-header" }); templateHeader.createSpan({ cls: "template-tree-toggle", text: "▶" }); templateHeader.createSpan({ cls: "template-tree-name", text: templateName }); templateHeader.createSpan({ cls: "template-tree-count", text: `(${templateMatches.length})` }); const chainsContainer = templateItem.createDiv({ cls: "template-tree-chains" }); // Add chains for this template for (const match of templateMatches) { const chainItem = chainsContainer.createDiv({ cls: "chain-item" }); if (this.selectedMatch === match) { chainItem.addClass("selected"); } const chainHeader = chainItem.createDiv({ cls: "chain-item-header" }); chainHeader.createSpan({ cls: "chain-status-icon", text: this.getStatusIcon(match.status) }); chainHeader.createSpan({ text: `Chain #${templateMatches.indexOf(match) + 1}` }); chainItem.createDiv({ cls: "chain-item-info", text: `Slots: ${match.slotsFilled}/${match.slotsTotal} | Links: ${match.satisfiedLinks}/${match.requiredLinks} | Score: ${match.score}` }); // Show affected notes const notes = new Set(); for (const assignment of Object.values(match.slotAssignments)) { if (assignment) { notes.add(assignment.file); } } if (notes.size > 0) { chainItem.createDiv({ cls: "chain-item-notes", text: `Notes: ${Array.from(notes).slice(0, 3).join(", ")}${notes.size > 3 ? "..." : ""}` }); } chainItem.addEventListener("click", (e) => { e.stopPropagation(); this.selectedMatch = match; this.render(); }); templateHeader.addEventListener("click", () => { templateItem.classList.toggle("expanded"); const toggle = templateHeader.querySelector(".template-tree-toggle"); if (toggle) { toggle.textContent = templateItem.classList.contains("expanded") ? "▼" : "▶"; } }); } } } 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") as HTMLElement; if (!detailsContainer) return; detailsContainer.empty(); detailsContainer.createEl("h3", { text: "Chain Details" }); if (!this.selectedMatch) { detailsContainer.createDiv({ cls: "workbench-details-placeholder", text: "Wählen Sie eine Chain aus der linken Liste aus, um Details anzuzeigen" }); return; } const match = this.selectedMatch; const template = this.chainTemplates?.templates?.find(t => t.name === match.templateName); // Chain Header const header = detailsContainer.createDiv({ cls: "chain-visualization-header" }); header.createEl("h4", { text: match.templateName }); header.createDiv({ cls: "chain-meta", text: `Status: ${match.status} | Score: ${match.score} | Confidence: ${match.confidence} | Slots: ${match.slotsFilled}/${match.slotsTotal} | Links: ${match.satisfiedLinks}/${match.requiredLinks}` }); // Chain Visualization const visualization = detailsContainer.createDiv({ cls: "chain-visualization" }); visualization.createEl("h4", { text: "Chain Flow" }); // Render chain flow as table this.renderChainFlowTable(visualization, match, template); // Edge Detection Details this.renderEdgeDetectionDetails(detailsContainer, match, template); // Todos Section this.renderTodosSection(detailsContainer, match); } private renderChainFlowTable( container: HTMLElement, match: WorkbenchMatch, template: import("../dictionary/types").ChainTemplate | undefined ): void { const table = container.createEl("table", { cls: "chain-flow-table" }); // Header const thead = table.createEl("thead"); const headerRow = thead.createEl("tr"); headerRow.createEl("th", { text: "Von Slot" }); headerRow.createEl("th", { text: "Referenz" }); headerRow.createEl("th", { text: "→" }); headerRow.createEl("th", { text: "Edge Type" }); headerRow.createEl("th", { text: "Rolle" }); headerRow.createEl("th", { text: "→" }); headerRow.createEl("th", { text: "Zu Slot" }); headerRow.createEl("th", { text: "Referenz" }); const tbody = table.createEl("tbody"); if (!template || !template.links || template.links.length === 0) { const row = tbody.createEl("tr"); row.createEl("td", { attr: { colspan: "7" }, text: "Keine Links im Template definiert", cls: "missing" }); return; } // Build chain flow: follow links in order const links = template.links; const visitedSlots = new Set(); for (let i = 0; i < links.length; i++) { const link = links[i]; if (!link) continue; const fromSlot = link.from; const toSlot = link.to; const row = tbody.createEl("tr"); // From Slot const fromAssignment = match.slotAssignments[fromSlot]; if (fromAssignment) { row.createEl("td", { cls: "slot-cell", text: fromSlot }); const refCell = row.createEl("td", { cls: "node-cell" }); const refText = fromAssignment.heading ? `${fromAssignment.file}#${fromAssignment.heading}` : fromAssignment.file; refCell.createEl("code", { text: refText }); refCell.createEl("br"); refCell.createSpan({ text: `[${fromAssignment.noteType}]`, cls: "node-type-badge" }); } else { row.createEl("td", { cls: "slot-cell missing", text: fromSlot }); row.createEl("td", { cls: "missing", text: "FEHLEND" }); } // Arrow row.createEl("td", { cls: "edge-cell", text: "→" }); // Edge Type const edgeEvidence = match.roleEvidence?.find(e => e.from === fromSlot && e.to === toSlot); if (edgeEvidence) { row.createEl("td", { cls: "edge-cell", text: edgeEvidence.rawEdgeType }); row.createEl("td", { cls: "edge-cell", text: `[${edgeEvidence.edgeRole}]` }); } else { row.createEl("td", { cls: "edge-cell missing", text: "FEHLEND" }); row.createEl("td", { cls: "edge-cell missing", text: "" }); } // Arrow row.createEl("td", { cls: "edge-cell", text: "→" }); // To Slot const toAssignment = match.slotAssignments[toSlot]; if (toAssignment) { row.createEl("td", { cls: "slot-cell", text: toSlot }); const refCell = row.createEl("td", { cls: "node-cell" }); const refText = toAssignment.heading ? `${toAssignment.file}#${toAssignment.heading}` : toAssignment.file; refCell.createEl("code", { text: refText }); refCell.createEl("br"); refCell.createSpan({ text: `[${toAssignment.noteType}]`, cls: "node-type-badge" }); } else { row.createEl("td", { cls: "slot-cell missing", text: toSlot }); row.createEl("td", { cls: "missing", text: "FEHLEND" }); } } } private renderEdgeDetectionDetails( container: HTMLElement, match: WorkbenchMatch, template: import("../dictionary/types").ChainTemplate | undefined ): void { const section = container.createDiv({ cls: "edge-detection-details" }); section.createEl("h4", { text: "Edge-Erkennung" }); if (!template || !template.links || template.links.length === 0) { section.createEl("p", { text: "Keine Links im Template definiert", cls: "missing" }); return; } const edgeList = section.createDiv({ cls: "edge-detection-list" }); for (const link of template.links) { const edgeEvidence = match.roleEvidence?.find(e => e.from === link.from && e.to === link.to); const fromAssignment = match.slotAssignments[link.from]; const toAssignment = match.slotAssignments[link.to]; const item = edgeList.createDiv({ cls: `edge-detection-item ${edgeEvidence ? "matched" : "not-matched"}` }); const header = item.createDiv({ cls: "edge-detection-item-header" }); header.createSpan({ cls: `edge-detection-status ${edgeEvidence ? "matched" : "not-matched"}`, text: edgeEvidence ? "✓ GEFUNDEN" : "✗ NICHT GEFUNDEN" }); header.createSpan({ text: `${link.from} → ${link.to}` }); const info = item.createDiv({ cls: "edge-detection-info" }); if (edgeEvidence) { info.createDiv({ cls: "info-row" }).innerHTML = ` Edge Type: ${edgeEvidence.rawEdgeType} [${edgeEvidence.edgeRole}] `; } else { info.createDiv({ cls: "info-row" }).innerHTML = ` Grund: Kein Edge zwischen ${link.from} und ${link.to} gefunden `; } if (fromAssignment && toAssignment) { info.createDiv({ cls: "info-row" }).innerHTML = ` Von: ${fromAssignment.file}${fromAssignment.heading ? `#${fromAssignment.heading}` : ""} `; info.createDiv({ cls: "info-row" }).innerHTML = ` Zu: ${toAssignment.file}${toAssignment.heading ? `#${toAssignment.heading}` : ""} `; } else { if (!fromAssignment) { info.createDiv({ cls: "info-row" }).innerHTML = ` Von Slot: ${link.from} fehlt `; } if (!toAssignment) { info.createDiv({ cls: "info-row" }).innerHTML = ` Zu Slot: ${link.to} fehlt `; } } if (link.allowed_edge_roles && link.allowed_edge_roles.length > 0) { info.createDiv({ cls: "info-row" }).innerHTML = ` Erlaubte Rollen: ${link.allowed_edge_roles.join(", ")} `; } } } private renderTodosSection(container: HTMLElement, match: WorkbenchMatch): void { const section = container.createDiv({ cls: "workbench-todos-section" }); section.createEl("h4", { text: `Todos (${match.todos.length})` }); if (match.todos.length === 0) { section.createEl("p", { text: "Keine Todos für diese Chain", cls: "info-text" }); return; } const todosList = section.createDiv({ cls: "todos-list" }); for (const todo of match.todos) { const todoEl = todosList.createDiv({ cls: `todo-item todo-${todo.type} ${todo.priority}` }); 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"); // Check if source note has defined sections (with > [!section] callouts) // If not, offer choice between section and note_links let zoneChoice: "section" | "note_links" | "candidates" = "section"; // Try to find source file let sourceFile: TFile | null = null; const fileRef = todo.fromNodeRef.file; const possiblePaths = [ fileRef, fileRef + ".md", fileRef.replace(/\.md$/, ""), fileRef.replace(/\.md$/, "") + ".md", ]; const currentDir = activeFile.path.split("/").slice(0, -1).join("/"); if (currentDir) { possiblePaths.push( `${currentDir}/${fileRef}`, `${currentDir}/${fileRef}.md`, `${currentDir}/${fileRef.replace(/\.md$/, "")}.md` ); } for (const path of possiblePaths) { const found = this.app.vault.getAbstractFileByPath(path); if (found && found instanceof TFile) { sourceFile = found; break; } } if (!sourceFile) { const basename = fileRef.replace(/\.md$/, "").split("/").pop() || fileRef; const resolved = this.app.metadataCache.getFirstLinkpathDest(basename, activeFile.path); if (resolved) { sourceFile = resolved; } } // Check if source file has defined sections if (sourceFile) { const { splitIntoSections } = await import("../mapping/sectionParser"); const content = await this.app.vault.read(sourceFile); const sections = splitIntoSections(content); // Check if any section has sectionType defined (has > [!section] callout) const hasDefinedSections = sections.some(s => s.sectionType !== null); // If no defined sections but multiple headings exist, offer choice if (!hasDefinedSections && sections.length > 1) { const contentSections = sections.filter( s => s.heading !== "Kandidaten" && s.heading !== "Note-Verbindungen" ); if (contentSections.length > 0) { // Offer choice between section and note_links const choice = await this.chooseZone(sourceFile); if (choice === null) { return; // User cancelled } zoneChoice = choice; } } } // 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 with zoneChoice:", zoneChoice); 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(); }); } /** * Generate a unique identifier for a match to restore selection after refresh. */ private getMatchIdentifier(match: WorkbenchMatch): string { // Create a stable identifier based on template name and slot assignments const slotKeys = Object.keys(match.slotAssignments).sort(); const slotValues = slotKeys.map(key => { const assignment = match.slotAssignments[key]; if (!assignment) return `${key}:null`; return `${key}:${assignment.file}#${assignment.heading || ''}`; }); return `${match.templateName}|${slotValues.join('|')}`; } /** * Find a match in the new model that corresponds to the saved identifier. */ private findMatchByIdentifier(identifier: string, matches: WorkbenchMatch[]): WorkbenchMatch | null { for (const match of matches) { if (this.getMatchIdentifier(match) === identifier) { return match; } } return null; } private async refreshWorkbench(): Promise { try { // Save current state before refresh const savedSelectedMatchId = this.selectedMatch ? this.getMatchIdentifier(this.selectedMatch) : null; const savedFilterStatus = this.filterStatus; const savedSearchQuery = this.searchQuery; // Reload the workbench model without closing the modal const activeFile = this.app.workspace.getActiveFile(); const activeEditor = this.app.workspace.activeEditor?.editor; if (!activeFile || !activeEditor) { new Notice("No active file or editor"); return; } // Resolve section context const { resolveSectionContext } = await import("../analysis/sectionContext"); const context = resolveSectionContext(activeEditor, activeFile.path); // Build inspector options const inspectorOptions = { includeNoteLinks: true, includeCandidates: this.settings.chainInspectorIncludeCandidates, maxDepth: 3, direction: "both" as const, maxTemplateMatches: undefined, maxMatchesPerTemplateDefault: this.settings.maxMatchesPerTemplateDefault, maxAssignmentsCollectedDefault: this.settings.maxAssignmentsCollectedDefault, debugLogging: this.settings.debugLogging, }; // Load 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); // Prepare templates source info for inspectChains // This is required for template matching to work! let templatesSourceInfo: { path: string; status: string; loadedAt: number | null; templateCount: number } | undefined; if (this.templatesLoadResult) { templatesSourceInfo = { path: this.templatesLoadResult.resolvedPath, status: this.templatesLoadResult.status, loadedAt: this.templatesLoadResult.loadedAt, templateCount: this.chainTemplates?.templates?.length || 0, }; } else if (this.chainTemplates) { // Fallback: create a minimal templatesSourceInfo if templatesLoadResult is not available templatesSourceInfo = { path: this.settings.chainTemplatesPath || "unknown", status: "loaded", loadedAt: Date.now(), templateCount: this.chainTemplates.templates?.length || 0, }; } // Inspect chains - pass editor content if available to avoid stale vault cache const { inspectChains } = await import("../analysis/chainInspector"); const editorContent = activeEditor ? activeEditor.getValue() : undefined; console.log("[Chain Workbench] Calling inspectChains with context:", context); console.log("[Chain Workbench] Using editor content:", !!editorContent); console.log("[Chain Workbench] chainTemplates:", this.chainTemplates ? "loaded" : "null"); console.log("[Chain Workbench] chainRoles:", this.chainRoles ? "loaded" : "null"); console.log("[Chain Workbench] templatesSourceInfo:", templatesSourceInfo ? "provided" : "null"); const report = await inspectChains( this.app, context, inspectorOptions, this.chainRoles, this.settings.edgeVocabularyPath, this.chainTemplates, templatesSourceInfo, this.settings.templateMatchingProfile, editorContent ); console.log("[Chain Workbench] inspectChains returned - templateMatches:", report.templateMatches?.length || 0); // Build all edges index for workbench model (using same editor content if available) const { buildNoteIndex, buildNoteIndexFromContent } = await import("../analysis/graphIndex"); const fileObj = this.app.vault.getAbstractFileByPath(activeFile.path); if (!fileObj || !(fileObj instanceof TFile)) { throw new Error("Active file not found"); } // Use editor content if available to ensure consistency with inspectChains let allEdges: IndexedEdge[]; if (editorContent) { const result = await buildNoteIndexFromContent(this.app, fileObj, editorContent); allEdges = result.edges; console.log("[Chain Workbench] Built index from editor content for workbench, edges:", allEdges.length); } else { const result = await buildNoteIndex(this.app, fileObj); allEdges = result.edges; console.log("[Chain Workbench] Built index from vault for workbench, edges:", allEdges.length); } // Build workbench model const { buildWorkbenchModel } = await import("../workbench/workbenchBuilder"); console.log("[Chain Workbench] Building workbench model..."); console.log("[Chain Workbench] Report has templateMatches:", report.templateMatches?.length || 0); console.log("[Chain Workbench] allEdges count:", allEdges.length); const newModel = await buildWorkbenchModel( this.app, report, this.chainTemplates, this.chainRoles, edgeVocabulary, allEdges, this.settings.debugLogging ); // Update model this.model = newModel; // Debug: Log model state console.log("[Chain Workbench] Refresh complete - matches:", newModel.matches.length); if (newModel.matches.length === 0) { console.warn("[Chain Workbench] WARNING: No matches found after refresh!"); console.log("[Chain Workbench] Report templateMatches:", report.templateMatches?.length || 0); console.log("[Chain Workbench] Context:", context); console.log("[Chain Workbench] chainTemplates:", this.chainTemplates ? `loaded (${this.chainTemplates.templates?.length || 0} templates)` : "null"); console.log("[Chain Workbench] allEdges:", allEdges.length); if (report.templateMatches && report.templateMatches.length > 0) { console.warn("[Chain Workbench] Report HAS templateMatches but model.matches is empty!"); console.log("[Chain Workbench] First templateMatch:", report.templateMatches[0]); } } else { console.log("[Chain Workbench] Model has matches, first match:", newModel.matches[0]?.templateName); } // Restore selection if possible if (savedSelectedMatchId && newModel.matches.length > 0) { const restoredMatch = this.findMatchByIdentifier(savedSelectedMatchId, newModel.matches); this.selectedMatch = restoredMatch; // If exact match not found, try to find a similar match (same template) if (!this.selectedMatch && savedSelectedMatchId) { const templateName = savedSelectedMatchId.split('|')[0]; const templateMatches = newModel.matches.filter(m => m.templateName === templateName); if (templateMatches.length > 0) { // Select the first match of the same template as fallback this.selectedMatch = templateMatches[0] || null; } } } else { this.selectedMatch = null; } // Restore filters this.filterStatus = savedFilterStatus; this.searchQuery = savedSearchQuery; // Debug: Log filter state console.log("[Chain Workbench] Filter status:", this.filterStatus, "Search query:", this.searchQuery); // Clear and re-render the entire UI const { contentEl } = this; contentEl.empty(); // Rebuild the UI structure const header = contentEl.createDiv({ cls: "workbench-header" }); header.createEl("h2", { text: "Chain Workbench" }); header.createEl("p", { cls: "context-info", 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" }); // Restore filter value if (this.filterStatus) { statusSelect.value = this.filterStatus; } 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..." }); // Restore search query if (this.searchQuery) { searchInput.value = this.searchQuery; } searchInput.addEventListener("input", (e) => { const target = e.target as HTMLInputElement; this.searchQuery = target.value.toLowerCase(); this.render(); }); // Main container: two-column layout const mainContainer = contentEl.createDiv({ cls: "workbench-main" }); // Left: Tree View const treeContainer = mainContainer.createDiv({ cls: "workbench-tree" }); treeContainer.createEl("h3", { text: "Templates & Chains" }); // Right: Details View const detailsContainer = mainContainer.createDiv({ cls: "workbench-details" }); detailsContainer.createEl("h3", { text: "Chain Details" }); // Now render the content console.log("[Chain Workbench] About to call render() - model.matches.length:", this.model.matches.length); this.render(); console.log("[Chain Workbench] render() completed"); } catch (error) { console.error("[Chain Workbench] Error refreshing workbench:", error); new Notice(`Error refreshing workbench: ${error instanceof Error ? error.message : String(error)}`); } } onClose(): void { const { contentEl } = this; contentEl.empty(); } }