feat: add plan approval UI with user review workflow

Add PlanApprovalService and PlanApprovalView to enable user review
and approval of AI-generated execution plans before execution begins.
Users can approve, reject, or suggest changes to plans through a new
input mode and dedicated view.
This commit is contained in:
Andrew Beal 2026-07-06 16:11:30 +01:00
parent 4f57798295
commit fa92e15b8c
22 changed files with 671 additions and 33 deletions

View file

@ -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",

View file

@ -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<UserInputService>(Services.UserInputService);
const searchStateStore: SearchStateStore = Resolve<SearchStateStore>(Services.SearchStateStore);
const diffService: DiffService = Resolve<DiffService>(Services.DiffService);
const planApprovalService: PlanApprovalService = Resolve<PlanApprovalService>(Services.PlanApprovalService);
const eventService: EventService = Resolve<EventService>(Services.EventService);
const aiPrompt: IPrompt = Resolve<IPrompt>(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 @@
<ChatAttachments bind:attachments={attachments}/>
</div>
<div id="diff-controls-container" style:padding-top={inputMode === InputMode.Diff ? "var(--size-4-2)" : 0}>
<div id="diff-controls-container" style:padding-top={(inputMode === InputMode.Diff || inputMode === InputMode.PlanApproval) ? "var(--size-4-2)" : 0}>
<DiffControls diffOpen={inputMode === InputMode.Diff}/>
<PlanApprovalControls planApprovalOpen={inputMode === InputMode.PlanApproval}/>
</div>
<div id="input-search-results-container" style:padding-top={$searchState.results.length > 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();
}

View file

@ -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 });
}
</script>

View file

@ -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<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
const executionPlanStore: ExecutionPlanStore = Resolve<ExecutionPlanStore>(Services.ExecutionPlanStore);
const settingsService: SettingsService = Resolve<SettingsService>(Services.SettingsService);
const chatService: ChatService = Resolve<ChatService>(Services.ChatService);
const planApprovalService: PlanApprovalService = Resolve<PlanApprovalService>(Services.PlanApprovalService);
const workSpaceService: WorkSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
const conversationService: ConversationFileSystemService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
const streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
@ -144,6 +146,9 @@
chatInput.enterQuestionMode(resolve);
});
},
onPlanApprovalRequest: async (plan) => {
return planApprovalService.requestApproval(plan);
},
onPlanUpdate: (executionPlan) => {
executionPlanStore.setPlan(executionPlan);
},

View file

@ -0,0 +1,97 @@
<script lang="ts">
import { Resolve } from "Services/DependencyService";
import type { PlanApprovalService } from "Services/PlanApprovalService";
import { Services } from "Services/Services";
import { Copy } from "Enums/Copy";
import { tick } from "svelte";
export let planApprovalOpen = false;
const planApprovalService: PlanApprovalService = Resolve<PlanApprovalService>(Services.PlanApprovalService);
let contentDiv: HTMLDivElement;
let height = 0;
$: planApprovalOpen, updateHeight();
function updateHeight() {
tick().then(() => {
if (contentDiv) {
height = contentDiv.scrollHeight;
}
});
}
</script>
<div id="plan-approval-controls-wrapper" style:height="{height}px">
<div id="plan-approval-controls" bind:this={contentDiv}>
{#if planApprovalOpen}
<button
id="plan-approve"
class="plan-approval-button"
aria-label={Copy.ButtonApprove}
on:click={() => planApprovalService.onApprove()}>
{Copy.ButtonApprove}
</button>
<button
id="plan-reject"
class="plan-approval-button"
aria-label={Copy.ButtonReject}
on:click={() => planApprovalService.onReject()}>
{Copy.ButtonReject}
</button>
{/if}
</div>
</div>
<style>
#plan-approval-controls-wrapper {
transition: height 0.2s ease-out;
overflow: hidden;
}
#plan-approval-controls {
display: grid;
grid-template-columns: 1fr var(--size-4-2) 1fr;
grid-template-rows: auto;
}
#plan-approve {
grid-column: 1;
background-color: color-mix(
in srgb,
var(--color-green) 75%,
var(--background-primary) 25%
);
}
#plan-approve:hover {
background-color: var(--color-green);
}
#plan-approve:focus {
background-color: var(--color-green);
}
#plan-reject {
grid-column: 3;
background-color: color-mix(
in srgb,
var(--color-red) 75%,
var(--background-primary) 25%
);
}
#plan-reject:hover {
background-color: var(--color-red);
}
#plan-reject:focus {
background-color: var(--color-red);
}
.plan-approval-button {
border-radius: var(--button-radius);
transition-duration: 0.5s;
}
</style>

View file

@ -0,0 +1,44 @@
<script lang="ts">
import { Resolve } from "Services/DependencyService";
import { Services } from "Services/Services";
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
import type { ExecutionPlan } from "Types/ExecutionPlan";
export let plan: ExecutionPlan;
const streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
function instructionRenderAction(element: HTMLElement, instruction: string) {
streamingMarkdownService.render(instruction, element, true);
return {
update(newInstruction: string) {
streamingMarkdownService.render(newInstruction, element, true);
}
};
}
</script>
<div id="plan-approval-container">
{#each plan.executionSteps as step, index}
<div class="plan-approval-step">
<h3 class="plan-approval-step-heading">{`${index + 1}. ${step.description}`}</h3>
<div class="plan-approval-step-instruction" use:instructionRenderAction={step.instruction}></div>
</div>
{/each}
</div>
<style>
#plan-approval-container {
max-width: 800px;
margin: 0 auto;
padding: var(--size-4-4);
}
.plan-approval-step {
margin-bottom: var(--size-4-6);
}
.plan-approval-step-heading {
margin-bottom: var(--size-4-2);
}
</style>

View file

@ -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

View file

@ -1,5 +1,7 @@
export enum Event {
DiffOpened = "diffOpened",
DiffClosed = "diffClosed",
PlanApprovalOpened = "planApprovalOpened",
PlanApprovalClosed = "planApprovalClosed",
RateLimitCountdown = "rateLimitCountdown"
}

View file

@ -1,5 +1,6 @@
export enum InputMode {
Normal = "normal",
Diff = "diff",
Question = "question"
Question = "question",
PlanApproval = "planApproval"
}

View file

@ -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;
}

View file

@ -47,6 +47,7 @@ export class QuickAgent extends BaseAgent {
onPlanningStarted: () => {},
onPlanningFinished: () => {},
onUserQuestion: async () => new Promise<string>(() => {}),
onPlanApprovalRequest: async () => new Promise(() => {}),
onPlanUpdate: () => {},
onPlanStepUpdate: () => {},
onPlanReset: () => {},

View file

@ -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<string>;
onPlanApprovalRequest: (plan: ExecutionPlan) => Promise<PlanApprovalResponse>
onPlanUpdate: (executionPlan: ExecutionPlan) => void;
onPlanStepUpdate: (currentStepIndex: number) => void;
onPlanReset: () => void;

View file

@ -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<T extends unknown[]>(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 {

View file

@ -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<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
this.eventService = Resolve<EventService>(Services.EventService);
this.abortService = Resolve<AbortService>(Services.AbortService);
this.registerEvent(this.eventService.on(Event.PlanApprovalClosed, () => {
this.cancelPendingApproval();
}));
}
public async requestApproval(plan: ExecutionPlan): Promise<PlanApprovalResponse> {
this.ongoingApproval = true;
const signal = this.abortService.signal();
return new Promise<PlanApprovalResponse>((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);
}
}

View file

@ -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<HTMLService>(Services.HTMLService, new HTMLService());
RegisterSingleton<SanitiserService>(Services.SanitiserService, new SanitiserService());
RegisterSingleton<DiffService>(Services.DiffService, new DiffService());
RegisterSingleton<PlanApprovalService>(Services.PlanApprovalService, new PlanApprovalService());
RegisterSingleton<VaultService>(Services.VaultService, new VaultService());
RegisterSingleton<VaultCacheService>(Services.VaultCacheService, new VaultCacheService());
RegisterSingleton<FileSystemService>(Services.FileSystemService, new FileSystemService());

View file

@ -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");

View file

@ -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 */
/* ============================== */

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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();

177
Views/PlanApprovalView.ts Normal file
View file

@ -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<typeof PlanApprovalWindow> | undefined;
private planContainer: HTMLElement | null = null;
private mobileControlsContainer: HTMLElement | null = null;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
this.eventService = Resolve<EventService>(Services.EventService);
this.planApprovalService = Resolve<PlanApprovalService>(Services.PlanApprovalService);
this.registerEvent(this.eventService.on(Event.PlanApprovalClosed, () => {
this.leaf.detach();
}));
}
protected override onClose(): Promise<void> {
// 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<void> {
this.plan = state.plan;
this.renderPlan();
return super.setState(state, result);
}
public getState(): Record<string, unknown> {
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<void> {
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);
}
}
});
}
}

25
main.ts
View file

@ -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<SettingsService>(Services.SettingsService);