diff --git a/AIClasses/ToolDefinitions/Tools/SubmitPlan.ts b/AIClasses/ToolDefinitions/Tools/SubmitPlan.ts index 99711b3..9792d2a 100644 --- a/AIClasses/ToolDefinitions/Tools/SubmitPlan.ts +++ b/AIClasses/ToolDefinitions/Tools/SubmitPlan.ts @@ -3,7 +3,7 @@ import type { IAIToolDefinition } from "../IAIToolDefinition"; export const SubmitPlan: IAIToolDefinition = { name: AITool.SubmitPlan, - description: `Submits an execution plan with ordered, actionable steps. + description: `Submits an execution plan with ordered, actionable steps for the user to review. Call this function: - After analyzing the goal and vault context to provide a structured plan @@ -26,11 +26,11 @@ Do NOT use this function: properties: { description: { type: "string", - description: "Brief summary of what this step accomplishes (e.g., 'Search for ML notes', 'Create index file'). This is user-facing and should be very concise." + description: "Brief title of what this step accomplishes (e.g., 'Search for ML notes', 'Create index file'). This is just a heading and should be very concise." }, instruction: { type: "string", - description: "Detailed instructions for executing this step. Should be specific enough to guide the execution without ambiguity. Examples: 'Search vault for all notes with tag #machine-learning using search_vault_files', 'Create new file ML-Index.md in /Research folder with heading structure', 'Update frontmatter in daily note 2024-01-15 to add tag #reviewed'" + description: "Detailed instructions for executing this step. Should be specific enough to guide the execution without ambiguity. This is user facing and should be formatted using markdown. Examples: 'Search vault for all notes tagged **#machine-learning**', 'Create new file `ML-Index.md` in `/Research` folder with the following heading structure:\\n- Overview\\n- Key Papers\\n- Open Questions', 'Update frontmatter in daily note `2024-01-15` to add tag **#reviewed**'" }, context: { type: "string", diff --git a/Components/ChatInput.svelte b/Components/ChatInput.svelte index 77189f9..a6a5961 100644 --- a/Components/ChatInput.svelte +++ b/Components/ChatInput.svelte @@ -12,9 +12,11 @@ import UserInstruction from "./UserInstruction.svelte"; import ChatModeSelector from "./ChatModeSelector.svelte"; import DiffControls from "./DiffControls.svelte"; + import PlanApprovalControls from "./PlanApprovalControls.svelte"; import type { EventService } from "Services/EventService"; import { Event } from "Enums/Event"; import type { DiffService } from "Services/DiffService"; + import type { PlanApprovalService } from "Services/PlanApprovalService"; import type { Attachment } from "Conversations/Attachment"; import ChatAttachments from "./ChatAttachments.svelte"; import InputDisplay from "./InputDisplay.svelte"; @@ -40,6 +42,7 @@ const userInputService: UserInputService = Resolve(Services.UserInputService); const searchStateStore: SearchStateStore = Resolve(Services.SearchStateStore); const diffService: DiffService = Resolve(Services.DiffService); + const planApprovalService: PlanApprovalService = Resolve(Services.PlanApprovalService); const eventService: EventService = Resolve(Services.EventService); const aiPrompt: IPrompt = Resolve(Services.IPrompt); @@ -74,6 +77,8 @@ const diffOpenedRef: EventRef = eventService.on(Event.DiffOpened, () => { inputMode = InputMode.Diff; focusInput(); }); const diffClosedRef: EventRef = eventService.on(Event.DiffClosed, () => { inputMode = InputMode.Normal; focusInput(); }); + const planApprovalOpenedRef: EventRef = eventService.on(Event.PlanApprovalOpened, () => { inputMode = InputMode.PlanApproval; focusInput(); }); + const planApprovalClosedRef: EventRef = eventService.on(Event.PlanApprovalClosed, () => { inputMode = InputMode.Normal; focusInput(); }); const rateLimitCountdownRef: EventRef = eventService.on(Event.RateLimitCountdown, (delayMs: number) => { startCountdown(delayMs); }); const settingsSubscription: object = settingsService.subscribeToSettingsChanged(changed => { @@ -97,6 +102,8 @@ onDestroy(() => { eventService.offref(diffOpenedRef); eventService.offref(diffClosedRef); + eventService.offref(planApprovalOpenedRef); + eventService.offref(planApprovalClosedRef); eventService.offref(rateLimitCountdownRef); settingsService.unsubscribe(settingsSubscription); stopCountdown(); @@ -215,7 +222,7 @@ })(); $: if (submitButton) { - if (inputMode === InputMode.Question || inputMode === InputMode.Diff) { + if (inputMode === InputMode.Question || inputMode === InputMode.Diff || inputMode === InputMode.PlanApproval) { setIcon(submitButton, userRequest.trim() === "" ? "square" : "send-horizontal"); } else { setIcon(submitButton, isSubmitting ? "square" : "send-horizontal"); @@ -233,11 +240,12 @@ $: inputPlaceholder = (() => { if (inputMode === InputMode.Question) return Copy.InputPlaceholderQuestion; if (inputMode === InputMode.Diff) return Copy.InputPlaceholderDiff; + if (inputMode === InputMode.PlanApproval) return Copy.InputPlaceholderPlanApproval; return Copy.InputPlaceholderNormal; })(); $: submitDisabled = (() => { - if (inputMode === InputMode.Diff || inputMode === InputMode.Question) { + if (inputMode === InputMode.Diff || inputMode === InputMode.Question || inputMode === InputMode.PlanApproval) { return false; } return !isSubmitting && userRequest.trim() === ""; @@ -250,6 +258,9 @@ if (inputMode === InputMode.Diff) { return userRequest.trim() === "" ? Copy.ButtonCancel : Copy.ButtonMakeSuggestion; } + if (inputMode === InputMode.PlanApproval) { + return userRequest.trim() === "" ? Copy.ButtonCancel : Copy.ButtonMakeSuggestion; + } return isSubmitting ? Copy.ButtonCancel : Copy.ButtonSendMessage; })(); @@ -273,6 +284,14 @@ diffService.onSuggest(suggestion.formattedRequest); } + function handlePlanSuggestion() { + if (userRequest.trim() === "" || inputMode !== InputMode.PlanApproval) { + return; + } + const suggestion = requestFromInput(); + planApprovalService.onSuggest(suggestion.formattedRequest); + } + function handleAnswer() { if (userRequest.trim() === "" || inputMode !== InputMode.Question) { return; @@ -341,6 +360,8 @@ handleAnswer(); } else if (inputMode === InputMode.Diff) { handleSuggestion(); + } else if (inputMode === InputMode.PlanApproval) { + handlePlanSuggestion(); } else { handleSubmit(); } @@ -555,8 +576,9 @@ -
+
+
0 ? "var(--size-4-2)" : 0}> @@ -637,6 +659,8 @@ userRequest.trim() === "" ? handleStop() : handleAnswer(); } else if (inputMode === InputMode.Diff) { userRequest.trim() === "" ? handleStop() : handleSuggestion(); + } else if (inputMode === InputMode.PlanApproval) { + userRequest.trim() === "" ? handleStop() : handlePlanSuggestion(); } else { isSubmitting ? handleStop() : handleSubmit(); } diff --git a/Components/ChatPlanArea.svelte b/Components/ChatPlanArea.svelte index 99352a7..e7616bc 100644 --- a/Components/ChatPlanArea.svelte +++ b/Components/ChatPlanArea.svelte @@ -39,20 +39,6 @@ scrollToActiveStep(); } - $: if (wrapperDiv && expanded !== undefined) { - handleExpandTransition(); - } - - function handleExpandTransition() { - isTransitioning = true; - const onTransitionEnd = () => { - isTransitioning = false; - wrapperDiv.removeEventListener('transitionend', onTransitionEnd); - scrollToActiveStep(); - }; - wrapperDiv.addEventListener('transitionend', onTransitionEnd); - } - function setupResizeObserver() { if (resizeObserver) { resizeObserver.disconnect(); @@ -110,6 +96,17 @@ requestAnimationFrame(() => { updateHeight(); }); + + isTransitioning = true; + const finishTransition = () => { + wrapperDiv.removeEventListener('transitionend', finishTransition); + clearTimeout(fallbackTimeoutId); + isTransitioning = false; + scrollToActiveStep(); + }; + // Fallback in case height doesn't actually change (e.g. <=3 steps), so transitionend never fires + const fallbackTimeoutId = setTimeout(finishTransition, 250); + wrapperDiv.addEventListener('transitionend', finishTransition, { once: true }); } diff --git a/Components/ChatWindow.svelte b/Components/ChatWindow.svelte index fa7a671..d200171 100644 --- a/Components/ChatWindow.svelte +++ b/Components/ChatWindow.svelte @@ -20,11 +20,13 @@ import type { StreamingMarkdownService } from "Services/StreamingMarkdownService"; import { AITool, fromString } from "Enums/AITool"; import { AIProvider } from "Enums/ApiProvider"; + import type { PlanApprovalService } from "Services/PlanApprovalService"; const plugin: VaultkeeperAIPlugin = Resolve(Services.VaultkeeperAIPlugin); const executionPlanStore: ExecutionPlanStore = Resolve(Services.ExecutionPlanStore); const settingsService: SettingsService = Resolve(Services.SettingsService); const chatService: ChatService = Resolve(Services.ChatService); + const planApprovalService: PlanApprovalService = Resolve(Services.PlanApprovalService); const workSpaceService: WorkSpaceService = Resolve(Services.WorkSpaceService); const conversationService: ConversationFileSystemService = Resolve(Services.ConversationFileSystemService); const streamingMarkdownService: StreamingMarkdownService = Resolve(Services.StreamingMarkdownService); @@ -144,6 +146,9 @@ chatInput.enterQuestionMode(resolve); }); }, + onPlanApprovalRequest: async (plan) => { + return planApprovalService.requestApproval(plan); + }, onPlanUpdate: (executionPlan) => { executionPlanStore.setPlan(executionPlan); }, diff --git a/Components/PlanApprovalControls.svelte b/Components/PlanApprovalControls.svelte new file mode 100644 index 0000000..542624b --- /dev/null +++ b/Components/PlanApprovalControls.svelte @@ -0,0 +1,97 @@ + + +
+
+ {#if planApprovalOpen} + + + {/if} +
+
+ + diff --git a/Components/PlanApprovalWindow.svelte b/Components/PlanApprovalWindow.svelte new file mode 100644 index 0000000..7db3d96 --- /dev/null +++ b/Components/PlanApprovalWindow.svelte @@ -0,0 +1,44 @@ + + +
+ {#each plan.executionSteps as step, index} +
+

{`${index + 1}. ${step.description}`}

+
+
+ {/each} +
+ + diff --git a/Enums/Copy.ts b/Enums/Copy.ts index 3c9e22e..cb69091 100644 --- a/Enums/Copy.ts +++ b/Enums/Copy.ts @@ -124,6 +124,7 @@ export enum Copy { // Chat Input Placeholders InputPlaceholderQuestion = "Provide an answer...", InputPlaceholderDiff = "Make a suggestion...", + InputPlaceholderPlanApproval = "Suggest a change to the plan...", InputPlaceholderNormal = "Type a message...", // Chat Input Button Labels @@ -140,6 +141,13 @@ export enum Copy { ButtonUserInstruction = "User Instruction", ButtonAttachFiles = "Attach Files", + // Plan Approval View + PlanApprovalViewTitle = "Vaultkeeper AI plan", + + ButtonApprove = "Approve", + ButtonReject = "Reject", + ButtonSuggest = "Suggest", + // Agent file message AttachedFile = `The file {fileName} is attached and its full contents follow below. This is the actual content of the file — read it directly to answer the user. This attachment may be a file the user uploaded to the chat, or a vault file you retrieved with a tool; either way, the content below is authoritative and you do NOT need to read or fetch this file again.`, @@ -169,8 +177,11 @@ The following context explains why you are doing the task. It is NOT an instruct PlanningFailedNoSteps = "The planned workflow has failed, however steps may have been completed. Consult with the user on how to continue.", WorkflowFailedAtStep = "The planned workflow failed when executing step '{stepDescription}'. Consult with the user on how to continue.", WorkflowAborted = "The planned workflow was aborted. Result: {abortContext}", - PlanReceived = "Plan received, now attempting to execute plan", + PlanReceived = "Plan received, now awaiting user approval", + PlanRejected = "The user has rejected the plan and chosen not to continue at this time", + PlanRejectedWithSuggestion = "The user has rejected the current plan. You should replan accounting for their feedback: {suggestion}", PlanningModeError = "First create a plan before executing any functions!", + PlanSubmissionRequired = "Error: Attempted to exit planning but plan has not yet been submitted!", UpdateMemoriesWithoutReadError = "Memories must be read before they can be updated. Retrieve the current memory contents first, then provide the complete revised content.", MemoriesInjectionHeader = `\n\n---\n\n## Current Memories\n\nThe following memories were recorded from previous sessions. Use them as context for this conversation.\n\n{memories}`, MemoriesEmpty = "No memories have been created yet.", @@ -199,8 +210,7 @@ The following context explains why you are doing the task. It is NOT an instruct DirectiveWebSearchDisabled = "- **Web Search**: DISABLED — the web search tool is unavailable; if the user requests it, inform them it is currently turned off in settings", DirectiveWebViewerEnabled = "- **Web Viewer**: ENABLED — you may call the web viewer tool to read the content of the page currently open in the Obsidian web viewer; call it proactively when the user asks about a web page", DirectiveWebViewerDisabled = "- **Web Viewer**: DISABLED — the web viewer tool is unavailable; if the user requests it, inform them it is currently turned off in settings", - - PlanSubmissionRequired = "Error: Attempted to exit planning but plan has not yet been submitted!", + MaxExecutionDepthReached = "Exceeded maximum plan execution attempts - consult with the user on how to continue.", // Execution Plan Request Templates diff --git a/Enums/Event.ts b/Enums/Event.ts index 64389e0..63633a6 100644 --- a/Enums/Event.ts +++ b/Enums/Event.ts @@ -1,5 +1,7 @@ export enum Event { DiffOpened = "diffOpened", DiffClosed = "diffClosed", + PlanApprovalOpened = "planApprovalOpened", + PlanApprovalClosed = "planApprovalClosed", RateLimitCountdown = "rateLimitCountdown" } \ No newline at end of file diff --git a/Enums/InputMode.ts b/Enums/InputMode.ts index 656d703..15fd128 100644 --- a/Enums/InputMode.ts +++ b/Enums/InputMode.ts @@ -1,5 +1,6 @@ export enum InputMode { Normal = "normal", Diff = "diff", - Question = "question" + Question = "question", + PlanApproval = "planApproval" } diff --git a/Services/AIServices/OrchestrationAgent.ts b/Services/AIServices/OrchestrationAgent.ts index d6372d9..e06706a 100644 --- a/Services/AIServices/OrchestrationAgent.ts +++ b/Services/AIServices/OrchestrationAgent.ts @@ -18,6 +18,7 @@ import { DebugColor } from "Enums/DebugColor"; import { AIToolUsageMode } from "Enums/AIToolUsageMode"; import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload"; import type { IAIToolDefinition } from "AIClasses/ToolDefinitions/IAIToolDefinition"; +import type { ExecutionPlan } from "Types/ExecutionPlan"; export class OrchestrationAgent extends BaseAgent { @@ -39,13 +40,49 @@ export class OrchestrationAgent extends BaseAgent { callbacks.onPlanReset(); callbacks.onPlanningStarted(); this.debugService?.log("OrchestrationAgent", "Spawning PlanningAgent to generate execution plan"); - const executionPlan = await planningAgent.runPlanningAgent(planningConversation, callbacks); - callbacks.onPlanningFinished(); + + let approved = false; + + let executionPlan: ExecutionPlan | undefined; + + while (!approved) { + executionPlan = await planningAgent.runPlanningAgent(planningConversation, callbacks); + + if (!executionPlan) { + callbacks.onPlanningFinished(); + this.debugService?.log("OrchestrationAgent", "Planning failed - no execution plan generated"); + return new AIToolResponsePayload({ message: Copy.PlanningFailedNoSteps }); + } + + this.debugService?.log("OrchestrationAgent", "Plan awaiting user approval"); + let response = await callbacks.onPlanApprovalRequest(executionPlan); + + if (response.approved) { + this.debugService?.log("OrchestrationAgent", "Execution plan approved"); + break; + } + + if (response.suggestion.trim() === "") { + callbacks.onPlanningFinished(); + this.debugService?.log("OrchestrationAgent", "Execution plan rejected"); + return new AIToolResponsePayload({ message: Copy.PlanRejected }); + } + + this.debugService?.log("OrchestrationAgent", "Execution plan rejected with user suggestion, commencing replanning"); + planningConversation.contents.push(new ConversationContent({ + role: Role.User, + content: replaceCopy(Copy.PlanRejectedWithSuggestion, [response.suggestion]), + shouldDisplayContent: false + })); + } if (!executionPlan) { + callbacks.onPlanningFinished(); this.debugService?.log("OrchestrationAgent", "Planning failed - no execution plan generated"); return new AIToolResponsePayload({ message: Copy.PlanningFailedNoSteps }); } + callbacks.onPlanningFinished(); + this.debugService?.log("OrchestrationAgent", `Execution plan received with ${executionPlan.executionSteps.length} steps`); callbacks.onPlanUpdate(executionPlan); @@ -93,6 +130,7 @@ export class OrchestrationAgent extends BaseAgent { : orchestrationResult.continueContext; } stepIndex++; + callbacks.onPlanStepUpdate(stepIndex); continue; } @@ -125,6 +163,7 @@ export class OrchestrationAgent extends BaseAgent { content: `Step ${stepIndex + 1} was skipped. Reason: ${orchestrationResult.skipReason}` })); stepIndex++; + callbacks.onPlanStepUpdate(stepIndex); continue; } diff --git a/Services/AIServices/QuickAgent.ts b/Services/AIServices/QuickAgent.ts index 71e5782..cb34281 100644 --- a/Services/AIServices/QuickAgent.ts +++ b/Services/AIServices/QuickAgent.ts @@ -47,6 +47,7 @@ export class QuickAgent extends BaseAgent { onPlanningStarted: () => {}, onPlanningFinished: () => {}, onUserQuestion: async () => new Promise(() => {}), + onPlanApprovalRequest: async () => new Promise(() => {}), onPlanUpdate: () => {}, onPlanStepUpdate: () => {}, onPlanReset: () => {}, diff --git a/Services/ChatService.ts b/Services/ChatService.ts index 67147b9..620dba6 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -17,6 +17,7 @@ import type { WorkSpaceService } from "./WorkSpaceService"; import type { ExecutionPlan } from "Types/ExecutionPlan"; import type { MainAgent } from "./AIServices/MainAgent"; import type { ChatMode } from "Enums/ChatMode"; +import type { PlanApprovalResponse } from "Types/PlanApprovalResponse"; export interface IChatServiceCallbacks { onSubmit: () => void; @@ -26,6 +27,7 @@ export interface IChatServiceCallbacks { onPlanningStarted: () => void; onPlanningFinished: () => void; onUserQuestion: (question: string) => Promise; + onPlanApprovalRequest: (plan: ExecutionPlan) => Promise onPlanUpdate: (executionPlan: ExecutionPlan) => void; onPlanStepUpdate: (currentStepIndex: number) => void; onPlanReset: () => void; diff --git a/Services/EventService.ts b/Services/EventService.ts index 168a676..b3ded08 100644 --- a/Services/EventService.ts +++ b/Services/EventService.ts @@ -5,6 +5,8 @@ export class EventService extends Events { public on(name: Event.DiffOpened, callback: () => void): EventRef; public on(name: Event.DiffClosed, callback: () => void): EventRef; + public on(name: Event.PlanApprovalOpened, callback: () => void): EventRef; + public on(name: Event.PlanApprovalClosed, callback: () => void): EventRef; public on(name: Event.RateLimitCountdown, callback: (delayMs: number) => void): EventRef; public on(name: string, callback: (...data: T) => unknown): EventRef { @@ -13,6 +15,8 @@ export class EventService extends Events { public trigger(name: Event.DiffOpened, data?: unknown): void; public trigger(name: Event.DiffClosed, data?: unknown): void; + public trigger(name: Event.PlanApprovalOpened, data?: unknown): void; + public trigger(name: Event.PlanApprovalClosed, data?: unknown): void; public trigger(name: Event.RateLimitCountdown, delayMs: number): void; public trigger(name: string, ...data: unknown[]): void { diff --git a/Services/PlanApprovalService.ts b/Services/PlanApprovalService.ts new file mode 100644 index 0000000..b132bfc --- /dev/null +++ b/Services/PlanApprovalService.ts @@ -0,0 +1,95 @@ +import type VaultkeeperAIPlugin from 'main'; +import { Resolve } from './DependencyService'; +import { Services } from './Services'; +import type { EventService } from './EventService'; +import { Event } from 'Enums/Event'; +import { Component } from 'obsidian'; +import { AbortService } from './AbortService'; +import type { ExecutionPlan } from 'Types/ExecutionPlan'; +import { PlanApprovalResponse } from 'Types/PlanApprovalResponse'; + +export class PlanApprovalService extends Component { + + private readonly plugin: VaultkeeperAIPlugin; + private readonly eventService: EventService; + private readonly abortService: AbortService; + + private planResolve?: (response: PlanApprovalResponse) => void; + + private ongoingApproval: boolean = false; + + public constructor() { + super(); + this.plugin = Resolve(Services.VaultkeeperAIPlugin); + this.eventService = Resolve(Services.EventService); + this.abortService = Resolve(Services.AbortService); + + this.registerEvent(this.eventService.on(Event.PlanApprovalClosed, () => { + this.cancelPendingApproval(); + })); + } + + public async requestApproval(plan: ExecutionPlan): Promise { + this.ongoingApproval = true; + + const signal = this.abortService.signal(); + return new Promise((resolve, reject) => { + if (signal.aborted) { + this.finishApproval(); + reject(this.abortService.reason()); + return; + } + + const abortHandler = () => { + this.finishApproval(); + reject(this.abortService.reason()); + }; + signal.addEventListener("abort", abortHandler, { once: true }); + + this.planResolve = (response: PlanApprovalResponse) => { + signal.removeEventListener("abort", abortHandler); + resolve(response); + }; + + void this.plugin.activatePlanApprovalView(plan); + this.eventService.trigger(Event.PlanApprovalOpened); + }); + } + + public onApprove() { + if (this.planResolve) { + this.planResolve(new PlanApprovalResponse(true)); + } + this.finishApproval(); + } + + public onReject() { + if (this.planResolve) { + this.planResolve(new PlanApprovalResponse(false)); + } + this.finishApproval(); + } + + public onSuggest(suggestion: string) { + if (this.planResolve) { + this.planResolve(new PlanApprovalResponse(false, suggestion)); + } + this.finishApproval(); + } + + private cancelPendingApproval() { + if (this.ongoingApproval) { + if (this.planResolve) { + this.planResolve(new PlanApprovalResponse(false)); + } + this.finishApproval(); + } + } + + private finishApproval() { + this.ongoingApproval = false; + this.planResolve = undefined; + this.eventService.trigger(Event.PlanApprovalClosed); + } + +} diff --git a/Services/ServiceRegistration.ts b/Services/ServiceRegistration.ts index 34a1ee0..604b42f 100644 --- a/Services/ServiceRegistration.ts +++ b/Services/ServiceRegistration.ts @@ -15,6 +15,7 @@ import { ConversationNamingService } from "./ConversationNamingService"; import { DebugService } from "./DebugService"; import { DiffService } from "./DiffService"; import { EventService } from "./EventService"; +import { PlanApprovalService } from "./PlanApprovalService"; import { FileSystemService } from "./FileSystemService"; import { HTMLService } from "./HTMLService"; import { InputService } from "./InputService"; @@ -81,6 +82,7 @@ export function RegisterDependencies() { RegisterSingleton(Services.HTMLService, new HTMLService()); RegisterSingleton(Services.SanitiserService, new SanitiserService()); RegisterSingleton(Services.DiffService, new DiffService()); + RegisterSingleton(Services.PlanApprovalService, new PlanApprovalService()); RegisterSingleton(Services.VaultService, new VaultService()); RegisterSingleton(Services.VaultCacheService, new VaultCacheService()); RegisterSingleton(Services.FileSystemService, new FileSystemService()); diff --git a/Services/Services.ts b/Services/Services.ts index 69d3f3d..82209d0 100644 --- a/Services/Services.ts +++ b/Services/Services.ts @@ -25,6 +25,7 @@ export class Services { static InputService = Symbol("InputService"); static WebViewerService = Symbol("WebViewerService"); static DiffService = Symbol("DiffService"); + static PlanApprovalService = Symbol("PlanApprovalService"); static MemoriesService = Symbol("MemoriesService"); static DebugService = Symbol("DebugService"); diff --git a/Styles/custom_styles.css b/Styles/custom_styles.css index f905d71..9457d11 100644 --- a/Styles/custom_styles.css +++ b/Styles/custom_styles.css @@ -83,6 +83,16 @@ body:not(.is-tablet) .workspace-drawer.mod-right { overflow: hidden; } +/* ============================== */ +/* Plan Approval View Customization */ +/* ============================== */ + +.workspace-leaf-content[data-type="vaultkeeper-ai-plan-approval-view"] .view-content { + container-type: size; + container-name: plan-approval-container; + overflow: hidden; +} + /* ============================== */ /* Settings Styles */ /* ============================== */ diff --git a/Styles/plan_approval_styles.css b/Styles/plan_approval_styles.css new file mode 100644 index 0000000..49bb1ba --- /dev/null +++ b/Styles/plan_approval_styles.css @@ -0,0 +1,92 @@ +/* ============================== */ +/* Structural / layout fixes */ +/* ============================== */ + +.plan-approval-wrapper { + container-type: size; + container-name: plan-approval-wrapper; + transform: translateZ(0); + height: 100cqh; + overflow: auto; +} + +/* ============================== */ +/* Mobile Controls */ +/* ============================== */ + +.plan-approval-mobile-controls { + display: none; /* Hidden by default (desktop) */ +} + +/* Avoid the floating obsidian controls */ +@media (max-width: 600px) { + .plan-approval-mobile-controls { + margin-bottom: 85px; + } +} + +.plan-approval-view-mobile .plan-approval-mobile-controls { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: var(--size-4-2); + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: var(--size-4-3); + z-index: 10; +} + +.plan-approval-mobile-controls .plan-approval-mobile-button { + min-height: 44px; + font-size: var(--font-ui-medium); + font-weight: var(--font-semibold); + border-radius: var(--button-radius); + cursor: pointer; + border: none; + color: var(--text-on-accent); +} + +.plan-approval-mobile-controls .plan-approval-mobile-approve { + background-color: color-mix( + in srgb, + var(--color-green) 75%, + var(--background-primary) 25% + ); +} + +.plan-approval-mobile-controls .plan-approval-mobile-approve:active { + background-color: var(--color-green); +} + +.plan-approval-mobile-controls .plan-approval-mobile-suggest { + background-color: var(--interactive-accent); +} + +.plan-approval-mobile-controls .plan-approval-mobile-suggest:active { + background-color: var(--interactive-accent-hover); +} + +.plan-approval-mobile-controls .plan-approval-mobile-reject { + background-color: color-mix( + in srgb, + var(--color-red) 75%, + var(--background-primary) 25% + ); +} + +.plan-approval-mobile-controls .plan-approval-mobile-reject:active { + background-color: var(--color-red); +} + +/* Adjust plan height on mobile to account for buttons */ +.plan-approval-view-mobile .plan-approval-wrapper { + height: calc(100cqh - 40px); +} + +/* Avoid the floating obsidian controls */ +@media (max-width: 600px) { + .plan-approval-view-mobile .plan-approval-wrapper { + height: calc(100cqh - 120px); + } +} diff --git a/Types/PlanApprovalResponse.ts b/Types/PlanApprovalResponse.ts new file mode 100644 index 0000000..8e55478 --- /dev/null +++ b/Types/PlanApprovalResponse.ts @@ -0,0 +1,11 @@ +export class PlanApprovalResponse { + + public approved: boolean; + public suggestion: string; + + public constructor(approved: boolean, suggestion: string = "") { + this.approved = approved; + this.suggestion = suggestion; + } + +} \ No newline at end of file diff --git a/Views/DiffView.ts b/Views/DiffView.ts index ecedcf0..1e8c0c0 100644 --- a/Views/DiffView.ts +++ b/Views/DiffView.ts @@ -7,6 +7,7 @@ import type { DiffService } from "Services/DiffService"; import { Services } from "Services/Services"; import { VIEW_TYPE_MAIN, type MainView } from "./MainView"; import { tick } from "svelte"; +import { Copy } from "Enums/Copy"; export const VIEW_TYPE_DIFF = 'vaultkeeper-ai-diff-view'; @@ -111,21 +112,21 @@ export class DiffView extends ItemView { const acceptButton = container.createEl('button', { cls: 'diff-mobile-button diff-mobile-accept', - text: 'Accept' + text: Copy.ButtonApprove }); - acceptButton.setAttribute('aria-label', 'Accept changes'); + acceptButton.setAttribute('aria-label', Copy.ButtonApprove); const suggestButton = container.createEl('button', { cls: 'diff-mobile-button diff-mobile-suggest', - text: 'Suggest' + text: Copy.ButtonSuggest }); - suggestButton.setAttribute('aria-label', 'Make a suggestion'); + suggestButton.setAttribute('aria-label', Copy.ButtonSuggest); const rejectButton = container.createEl('button', { cls: 'diff-mobile-button diff-mobile-reject', - text: 'Reject' + text: Copy.ButtonReject }); - rejectButton.setAttribute('aria-label', 'Reject changes'); + rejectButton.setAttribute('aria-label', Copy.ButtonReject); this.registerDomEvent(acceptButton, 'click', async () => { this.diffService.onAccept(); diff --git a/Views/PlanApprovalView.ts b/Views/PlanApprovalView.ts new file mode 100644 index 0000000..e556d8e --- /dev/null +++ b/Views/PlanApprovalView.ts @@ -0,0 +1,177 @@ +import { Event } from "Enums/Event"; +import { Copy } from "Enums/Copy"; +import { ItemView, WorkspaceLeaf, Platform, type ViewStateResult } from "obsidian"; +import { mount, unmount } from "svelte"; +import { Resolve } from "Services/DependencyService"; +import type { EventService } from "Services/EventService"; +import type { PlanApprovalService } from "Services/PlanApprovalService"; +import { Services } from "Services/Services"; +import { VIEW_TYPE_MAIN, type MainView } from "./MainView"; +import PlanApprovalWindow from "Components/PlanApprovalWindow.svelte"; +import type { ExecutionPlan } from "Types/ExecutionPlan"; +import { tick } from "svelte"; + +export const VIEW_TYPE_PLAN_APPROVAL = 'vaultkeeper-ai-plan-approval-view'; + +interface PlanApprovalViewState { + plan: ExecutionPlan; +} + +export class PlanApprovalView extends ItemView { + + private readonly eventService: EventService; + private readonly planApprovalService: PlanApprovalService; + + private plan: ExecutionPlan | undefined; + + private planWindow: ReturnType | undefined; + private planContainer: HTMLElement | null = null; + private mobileControlsContainer: HTMLElement | null = null; + + constructor(leaf: WorkspaceLeaf) { + super(leaf); + + this.eventService = Resolve(Services.EventService); + this.planApprovalService = Resolve(Services.PlanApprovalService); + + this.registerEvent(this.eventService.on(Event.PlanApprovalClosed, () => { + this.leaf.detach(); + })); + } + + protected override onClose(): Promise { + // trigger PlanApprovalClosed event in case the user closed the tab + this.eventService.trigger(Event.PlanApprovalClosed); + + if (this.planWindow) { + void unmount(this.planWindow); + this.planWindow = undefined; + } + + return Promise.resolve(); + } + + public getViewType(): string { + return VIEW_TYPE_PLAN_APPROVAL; + } + + public getDisplayText(): string { + return Copy.PlanApprovalViewTitle; + } + + public async setState(state: PlanApprovalViewState, result: ViewStateResult): Promise { + this.plan = state.plan; + + this.renderPlan(); + + return super.setState(state, result); + } + + public getState(): Record { + return { + plan: this.plan + }; + } + + private renderPlan() { + const container = this.resetContainer(); + + if (!this.plan) { + return; + } + + if (Platform.isMobile) { + container.addClass('plan-approval-view-mobile'); + } + + this.planContainer = container.createDiv({ cls: 'plan-approval-wrapper' }); + + this.planWindow = mount(PlanApprovalWindow, { + target: this.planContainer, + props: { + plan: this.plan + } + }); + + if (Platform.isMobile) { + this.mobileControlsContainer = this.createMobileButtons(); + } + } + + private resetContainer(): HTMLElement { + const container = this.contentEl; + container.empty(); + + if (this.planWindow) { + void unmount(this.planWindow); + this.planWindow = undefined; + } + + if (this.planContainer) { + this.planContainer.remove(); + this.planContainer = null; + } + + if (this.mobileControlsContainer) { + this.mobileControlsContainer.remove(); + this.mobileControlsContainer = null; + } + + return container; + } + + private createMobileButtons(): HTMLElement { + const container = this.contentEl.createDiv({ cls: 'plan-approval-mobile-controls' }); + + const approveButton = container.createEl('button', { + cls: 'plan-approval-mobile-button plan-approval-mobile-approve', + text: Copy.ButtonApprove + }); + approveButton.setAttribute('aria-label', Copy.ButtonApprove); + + const suggestButton = container.createEl('button', { + cls: 'plan-approval-mobile-button plan-approval-mobile-suggest', + text: Copy.ButtonSuggest + }); + suggestButton.setAttribute('aria-label', Copy.ButtonSuggest); + + const rejectButton = container.createEl('button', { + cls: 'plan-approval-mobile-button plan-approval-mobile-reject', + text: Copy.ButtonReject + }); + rejectButton.setAttribute('aria-label', Copy.ButtonReject); + + this.registerDomEvent(approveButton, 'click', async () => { + this.planApprovalService.onApprove(); + await this.refocusMainView(); + }); + + this.registerDomEvent(suggestButton, 'click', async () => { + await this.refocusMainView(true); + }); + + this.registerDomEvent(rejectButton, 'click', async () => { + this.planApprovalService.onReject(); + await this.refocusMainView(); + }); + + return container; + } + + private async refocusMainView(focusChatInput: boolean = false): Promise { + await tick().then(async () => { + const { workspace } = this.app; + const leaves = workspace.getLeavesOfType(VIEW_TYPE_MAIN); + + if (leaves.length > 0) { + await workspace.revealLeaf(leaves[0]); + workspace.setActiveLeaf(leaves[0], { focus: true }); + + if (focusChatInput) { + (leaves[0].view as MainView).input?.focusInput(true); + } + } + }); + } + +} diff --git a/main.ts b/main.ts index cc6417c..976b4b7 100644 --- a/main.ts +++ b/main.ts @@ -3,6 +3,7 @@ import { MainView, VIEW_TYPE_MAIN } from "Views/MainView"; import { RegisterDependencies, RegisterPlugin } from "Services/ServiceRegistration"; import { VaultkeeperAISettingTab } from "Views/VaultkeeperAISettingTab"; import { DiffView, VIEW_TYPE_DIFF } from "Views/DiffView"; +import { PlanApprovalView, VIEW_TYPE_PLAN_APPROVAL } from "Views/PlanApprovalView"; import { Services } from "Services/Services"; import { DeregisterAllServices, Resolve } from "Services/DependencyService"; import type { VaultService } from "Services/VaultService"; @@ -10,6 +11,7 @@ import { Path } from "Enums/Path"; import { Copy } from "Enums/Copy"; import type { SettingsService } from "Services/SettingsService"; import type { Diff2HtmlUIConfig } from "diff2html/lib/ui/js/diff2html-ui"; +import type { ExecutionPlan } from "Types/ExecutionPlan"; import "katex/dist/katex.min.css"; import 'highlight.js/styles/monokai.min.css'; @@ -30,6 +32,10 @@ export default class VaultkeeperAIPlugin extends Plugin { VIEW_TYPE_DIFF, (leaf) => new DiffView(leaf) ); + this.registerView( + VIEW_TYPE_PLAN_APPROVAL, + (leaf) => new PlanApprovalView(leaf) + ); this.addCommand({ id: "open", @@ -79,7 +85,7 @@ export default class VaultkeeperAIPlugin extends Plugin { const leaves = workspace.getLeavesOfType(VIEW_TYPE_DIFF); const leaf = leaves.length > 0 ? leaves[0] : workspace.getLeaf("tab"); - await leaf?.setViewState({ + await leaf?.setViewState({ type: VIEW_TYPE_DIFF, active: true, state: { diffString, config } @@ -90,6 +96,23 @@ export default class VaultkeeperAIPlugin extends Plugin { } } + public async activatePlanApprovalView(plan: ExecutionPlan) { + const { workspace } = this.app; + + const leaves = workspace.getLeavesOfType(VIEW_TYPE_PLAN_APPROVAL); + const leaf = leaves.length > 0 ? leaves[0] : workspace.getLeaf("tab"); + + await leaf?.setViewState({ + type: VIEW_TYPE_PLAN_APPROVAL, + active: true, + state: { plan } + }); + + if (leaf != null) { + await workspace.revealLeaf(leaf); + } + } + // create example user instruction (on first launch only) private async setup() { const settingsService = Resolve(Services.SettingsService);