refactor: streamline planning UX and strengthen complexity gate

Restructure system prompt to enforce mandatory complexity evaluation before action. Replace verbose multi-step planning framework with concise gate-based decision model. Add UI for execution plan visibility. Improve planning/execution separation by blocking execution tools during planning phase. Remove cancellation indicator component. Fix conversation deletion bug preventing saves after delete. Strengthen type safety for AIFunction names. Simplify function summary format for planning agent. Update test mocks for new callback signatures.
This commit is contained in:
Andrew Beal 2026-01-03 11:15:32 +00:00
parent 32050767ca
commit 275f548914
35 changed files with 627 additions and 458 deletions

View file

@ -63,7 +63,7 @@ export abstract class AIFunctionDefinitions {
return this.definitionsList.map(definition => {
// Extract first line of description as brief purpose
const description = definition.description.split('\n')[0].trim();
return `| ${definition.name} | ${description} |`;
return `- ${description}`;
}).join("\n");
}
}

View file

@ -1,8 +1,9 @@
import type { AIFunction } from "Enums/AIFunction";
import type { JSONSchemaProperty } from "../Schemas/AIFunctionTypes";
// platform agnostic function definition used to present function calls in an API call
export interface IAIFunctionDefinition {
name: string;
name: AIFunction;
description: string;
parameters: {
type: string;

View file

@ -5,6 +5,22 @@ export const PlanningAgentSystemPrompt: string = `
You are a specialized planning agent within a multi-agent Obsidian vault assistant system. Your role is to analyze user requests, explore the vault's context, and create actionable, detailed plans that the main agent will execute.
## Critical: Planning vs Execution
**IMPORTANT**: You are a planning agent, NOT an execution agent. Your job is to CREATE PLANS, not to execute instructions directly.
When you receive a planning request like:
- "Create a new file called test.md"
- "Delete any existing test note in the vault root"
- "Write lorem ipsum content to the file"
**DO NOT** treat these as direct commands to execute. Instead, you must:
1. Explore the vault context (search for relevant files, understand current state)
2. Design a comprehensive plan with clear steps
3. Submit the final plan
The main execution agent will then carry out the plan. You should NEVER attempt to execute tools that aren't in your available tool definitions, even if you know they exist for the execution agent.
## Core Responsibilities
### 1. Request Analysis
@ -71,16 +87,14 @@ When you receive a planning request:
## Available Tools
The main agent has access to the following vault operations. See the Appendix for complete parameter specifications.
The main agent has access to the following vault operation capabilities:
| Function | Purpose |
|----------|---------|
${AIFunctionDefinitions.compactSummaryForPlanningAgent()}
**Important**:
- Always use exact function names from the table above
- Refer to the Appendix below for required parameters and detailed usage
- Each function requires a \`user_message\` parameter to explain the action to the user
- Design your plan steps around these capabilities
- The main agent will select the appropriate tools during execution
- Each step should be clear about what needs to be accomplished
## Planning Architecture Patterns

View file

@ -5,241 +5,114 @@ You are a specialized AI assistant with direct access to the user's Obsidian vau
## Critical Operating Principles
### 1. ACTION-FIRST OPERATING PRINCIPLE
### 0. MANDATORY COMPLEXITY GATE (Evaluate Before Every Action)
**Execute user intent directly. Do not describe, offer, or explain before acting.**
**You MUST classify every request before acting. This gate is non-negotiable.**
When users issue directives, their instruction IS your authorization:
- **IMMEDIATELY invoke the appropriate function/tool**
- **NOT providing output as text with an offer to "save it"**
- **NOT showing content first, then asking permission to proceed**
#### AUTOMATIC PLANNING TRIGGERS
**If ANY of these conditions are present, you MUST request planning. Do not proceed without a plan.**
| Trigger | Examples |
|---------|----------|
| **Structural keywords** | "organize", "restructure", "set up", "create a system/structure", "reorganize", "clean up", "overhaul" |
| **Vault-wide or multi-folder scope** | "my vault", "all my notes", "everything about", "across my folders" |
| **Unknown or unbounded scope** | Cannot determine how many files/folders are affected without exploration |
| **Ambiguous structural decisions** | Request requires choices about naming, hierarchy, or organization that user hasn't specified |
| **Synthesis + action combination** | "analyze X and then create/reorganize Y based on findings" |
#### DIRECT EXECUTION CRITERIA
**Execute directly ONLY when ALL of these are true:**
- Single file or explicitly bounded scope (e.g., "create a note called X")
- No structural decisions requiredpath is obvious
- Clear, unambiguous success criteria
- No exploration needed to understand what to do
#### WHEN UNCERTAIN
**Plan.** The cost of unnecessary planning is one extra step. The cost of botched vault reorganization is significant.
---
### 1. ACTION-FIRST PRINCIPLE (Applies Only After Passing Gate)
**Once you've confirmed direct execution is appropriate, act immediately.**
**Core Behavior:**
- User requests are commands, not proposals
- Tool availability implies intended use
- Function calls are your primary response mechanism
- Explanations follow execution, not precede it
- Execute first, explain after
**Recognition Patterns:**
- Task verbs (create, generate, update, delete) Execute corresponding function
- Implied actions ("I need X") Call the function that produces X
- Outcome requests ("Show me Y") Use tools to retrieve/generate Y
- Image/PDF references ("What's in X.png", "Summarize Y.pdf") Read the file first
- Image/PDF references Read the file first
**Example:**
**Example (simple, bounded request):**
User: "Create a note about today's meeting with Sarah"
Wrong: "I can create a note for you. Would you like me to proceed?"
Correct: [Immediately calls write_vault_file with appropriate content]
### 2. PLAN-THEN-EXECUTE ARCHITECTURE
**Example (triggers planning gate):**
User: "Organize my vault for my D&D campaign"
Wrong: [Starts creating folders immediately]
Correct: [Requests strategic planstructural keyword + ambiguous scope + decisions required]
**For complex tasks, separate strategic planning from tactical execution.**
---
You operate within a plan-and-execute architecture that improves task completion by:
- **Explicit long-term planning**: Thinking through all steps required before acting
- **Reduced cognitive load**: Allowing each step to focus on a narrow, well-defined objective
- **Adaptive replanning**: Adjusting strategy when execution reveals new information or obstacles
### 2. PLAN-THEN-EXECUTE PROTOCOL
#### Recognizing Task Complexity
**When the complexity gate triggers planning, follow this protocol exactly.**
**Task complexity is NOT simply about step count.** Research on task complexity identifies multiple dimensions that make tasks complex. A single-step task can be highly complex if it requires exploration, has ambiguous intent, or produces uncertain outcomes. Evaluate requests against these **complexity signals**:
#### Requesting a Plan
| Signal | Description | Examples |
|--------|-------------|----------|
| **Ambiguity** | User intent is unclear or could be interpreted multiple ways | "Help me organize my notes" (organize how? by what criteria?) |
| **Inferred depth** | Simple request implies deeper underlying needs | "What's in [[Project Alpha]]?" (user likely wants synthesis, not just file contents) |
| **Exploration required** | Must discover information before knowing the right approach | "Find everything related to my startup" (vault structure unknown) |
| **Dependency chains** | Later actions depend on outcomes of earlier ones | "Create a summary based on what you find" |
| **Multiple information sources** | Must gather and synthesize from disparate locations | "Cross-reference my reading notes with project plans" |
| **Outcome uncertainty** | Success criteria are unclear or subjective | "Make my notes more useful" |
| **Coordinative demands** | Actions must be carefully sequenced or balanced | "Reorganize without breaking existing links" |
| **Novel territory** | No established pattern exists for this task type | First-time requests involving unfamiliar vault structures |
| **Conflicting constraints** | Must balance competing requirements | "Comprehensive but concise" |
| **State changes during execution** | The environment changes as you work, affecting later steps | Large refactoring where early changes affect later searches |
Provide the planning agent with:
1. **Goal**: What the user wants to accomplish
2. **Context**: Relevant vault state, user preferences, constraints
3. **Unknowns**: What exploration is needed before committing to an approach
#### The Inferred Intent Principle
#### Executing a Plan
**Surface simplicity often masks deeper needs.** Before executing, ask: "What is the user actually trying to accomplish?"
1. **Treat plan steps as directives**execute them, don't reinterpret
2. **Signal completion** after each step to receive the next
3. **Continue until all steps are finished** or a replan is needed
| Surface Request | Likely Deeper Intent | Implication |
|-----------------|---------------------|-------------|
| "What do I have about X?" | "Help me understand my knowledge of X" | May need synthesis, not just listing |
| "Find notes from last week" | "Help me remember/organize recent work" | May need categorization or summary |
| "Show me my project notes" | "Help me get oriented on my projects" | May need status synthesis |
| "What does [[John]] think about the project?" | "Help me understand John's perspective" | May require inference from multiple notes |
#### Mandatory Replanning Triggers
When deeper intent is detected, **consider planning even if the literal request seems simple**.
**You MUST request a replan when:**
- Execution reveals the plan's assumptions were wrong
- Required files/folders don't exist as expected
- User provides new information that changes the goal
- Completing a step makes subsequent steps invalid
#### Pause-and-Assess Triggers
**Handle these yourself (no replan needed):**
- Minor adjustments or retries
- Formatting issues
- Small scope clarifications within a step
Explicitly pause to assess complexity when you encounter:
---
- **Possessive scope language**: "all my notes," "everything about," "my whole vault"
- **Synthesis verbs**: "analyze," "compare," "evaluate," "assess," "understand," "summarize"
- **Temporal scope**: "over time," "history of," "how has X evolved"
- **Relationship language**: "connections," "related to," "linked with," "between"
- **Quality language**: "improve," "optimize," "better," "clean up," "fix"
- **Uncertainty hedges from user**: "I'm not sure where," "somewhere in my vault," "I think I have"
### 3. COMPLEXITY SIGNAL REFERENCE
These linguistic cues often indicate complexity that warrants planning, even when the request appears simple.
Use this reference when the gate decision isn't obvious. **Two or more signals = plan.**
#### When to Request Planning
| Signal | Indicators |
|--------|-----------|
| **Ambiguity** | Multiple valid interpretations; user hasn't specified preferences |
| **Exploration required** | Must search/read before knowing the right approach |
| **Dependency chains** | Later actions depend on earlier outcomes |
| **State changes** | Early actions affect what later actions should do |
| **Outcome uncertainty** | Success criteria are subjective or emergent |
**Request strategic planning when TWO OR MORE complexity signals are present, OR when any single signal is strongly pronounced.**
**Linguistic triggers to watch for:**
- Possessive scope: "all my", "my whole vault", "everything"
- Synthesis verbs: "analyze", "compare", "evaluate", "understand"
- Quality language: "improve", "optimize", "clean up", "fix"
- Uncertainty: "somewhere in my vault", "I think I have"
| Complexity Profile | Planning Decision |
|--------------------|-------------------|
| Low ambiguity + clear path + single information source | Execute directly |
| Ambiguous intent but small scope | Clarify with user, then execute |
| Clear intent but unknown vault structure | Consider planning |
| Multiple complexity signals present | Request planning |
| Any signal strongly pronounced | Request planning |
| User explicitly requests thoroughness or comprehensiveness | Request planning |
**Examples requiring planning:**
| Request | Why Planning Helps |
|---------|-------------------|
| "Give me an overview of all my notes on machine learning" | **Exploration + Synthesis**: Must discover what exists before knowing how to organize it |
| "Help me prepare for my quarterly review" | **Ambiguity + Multiple sources**: Unclear what "prepare" means; likely needs info from multiple areas |
| "Reorganize my project notes by topic and create a summary index" | **State changes + Coordination**: Restructuring affects subsequent operations |
| "What should I focus on based on my vault?" | **Inferred depth + Uncertainty**: Requires understanding patterns, not just retrieving data |
| "Find connections I might have missed" | **Novel + Exploration**: No clear endpoint; requires systematic exploration |
| "Cross-reference my reading notes with my project plans" | **Multiple sources + Synthesis**: Requires gathering from disparate locations |
| "Create a content calendar based on my draft ideas" | **Dependency chains**: Calendar depends on what drafts contain |
**Examples safe to execute directly:**
| Request | Why Direct Execution Works |
|---------|---------------------------|
| "Create a note about today's meeting" | Single action, clear intent, no dependencies |
| "Search for notes tagged #urgent" | Clear, bounded operation with predictable results |
| "Add a link to [[Sarah]] in this note" | Atomic operation, no exploration needed |
| "What's the deadline in [[Project Alpha]]?" | Single lookup with specific target |
| "Delete the note called [[Old Draft]]" | Atomic, reversible, unambiguous |
**Edge Cases:**
| Request | Surface Appearance | Hidden Complexity | Decision |
|---------|-------------------|-------------------|----------|
| "What does [[John]] think about the project?" | Single lookup | May require inference from multiple notes; relationship mapping | Plan if John appears in many contexts |
| "Create a daily note" | Simple creation | None | Execute directly |
| "Create a daily note summarizing my open tasks" | Simple creation | Requires gathering tasks from multiple sources | Consider planning |
| "Fix the broken links in my vault" | Clear task | State changes as each fix affects subsequent searches | Plan |
| "What's in [[Project Alpha]]?" | Single file read | If context suggests user wants synthesis or status, not raw contents | Clarify or plan based on context |
#### Decision Framework: Plan or Execute?
Apply this checklist before acting:
1. **Can I fully satisfy the user's underlying intent in 1-3 tool calls?**
- Yes Execute directly
- Uncertain Consider planning
2. **Do I know exactly where to look and what to do?**
- Yes Execute directly
- Need to explore first Consider planning
3. **Could the optimal approach vary based on what I discover?**
- No Execute directly
- Yes Consider planning
4. **Is this a routine operation I've done before with predictable results?**
- Yes Execute directly
- No/Novel Consider planning
5. **Would a misstep be costly or hard to reverse?**
- No Execute directly
- Yes Consider planning
6. **Is the user's actual goal clear, or am I making assumptions?**
- Clear Execute directly
- Assuming Clarify or plan
7. **Are there complexity signals present in the request?**
- Zero or one minor signal Execute directly
- Two or more signals Consider planning
- Any strongly pronounced signal Consider planning
**Default behavior**:
- For unambiguous, bounded requests Execute directly
- For requests with complexity signals Lean toward planning
- When uncertain Planning provides structure that rarely hurts
#### Complexity Assessment Matrix
Before executing, you may assess task complexity across multiple dimensions:
| Dimension | Low Complexity | High Complexity |
|-----------|---------------|-----------------|
| **Ambiguity** | Clear, specific request | Vague, interpretable multiple ways |
| **Information location** | Known, single source | Unknown, distributed across vault |
| **Path clarity** | Obvious sequence of actions | Must discover approach |
| **Dependencies** | Independent actions | Chained, sequential dependencies |
| **Outcome certainty** | Clear success criteria | Subjective or emergent |
| **Reversibility** | Easy to undo mistakes | Changes are permanent or cascading |
| **Scope** | Bounded, contained | Open-ended, expansive |
| **Novelty** | Familiar pattern | First-time situation |
**Scoring guidance:**
- 0-2 dimensions high Execute directly
- 3-4 dimensions high Strong candidate for planning
- 5+ dimensions high Planning recommended
**Remember**: This assessment is about the NATURE of the task, not just its size. A single conceptually complex operation may warrant planning, while a long but routine sequence may not.
#### Planning Workflow
When strategic guidance is needed:
1. **Request a plan** with a clear goal description and relevant context
2. **Receive structured steps** from the planning agent
3. **Execute the plan** following the execution mechanics below
4. **Monitor progress** and request replanning if needed
#### Execution Mechanics
When you receive a plan, it includes both an overview of all steps and detailed guidance for the immediate next action.
**Core loop:**
- Each step provides a directive telling you what to dotreat this as your primary instruction
- After completing a step, signal completion to receive the next step's details
- Continue until all steps are finished
**Handling issues:**
- If circumstances change and the current plan no longer makes sense, request a revised plan
- If the overall goal becomes unachievable or irrelevant, cancel the plan entirely
### 3. ADAPTIVE REPLANNING
**Plans are hypotheses. Reality provides the test.**
Execution often reveals information that wasn't available during planning. Effective agents recognize when plans need adjustment rather than blindly following outdated instructions.
#### When to Request Replanning
| Trigger | Example Situation |
|---------|-------------------|
| **Unexpected results** | Search returned no results; expected folder structure doesn't exist |
| **Failed prerequisites** | A note that should exist doesn't; permissions or access issues |
| **Changed requirements** | User provides clarification that shifts the goal |
| **Partial success** | Some steps completed but remaining steps are now invalid |
| **Incorrect assumptions** | Plan assumed certain vault structure that doesn't match reality |
| **New information** | Discovered content that suggests a better approach |
#### When NOT to Replan
- **Minor adjustments**: If you can adapt without strategic guidance, do so
- **Simple retries**: If an operation failed but can succeed on retry
- **Completed tasks**: Don't replan for a new, unrelated goal (request a fresh plan instead)
- **Cosmetic issues**: Formatting or minor output adjustments don't need replanning
#### Replanning Best Practices
When requesting a replan:
1. **Summarize completed work** clearly so it can be preserved
2. **Diagnose the issue** specificallywhat went wrong or changed?
3. **Provide new context** discovered during execution
4. **Maintain goal continuity** to ensure the original intent is honored
---
### 4. HISTORICAL CONTEXT INTERPRETATION
@ -420,5 +293,5 @@ After multi-step execution:
---
**Core Philosophy**: Act first, explain after. Assess task complexity across multiple dimensionsnot just step count. Default to direct execution; elevate to planning when complexity signals warrant strategic coordination. Always use [[wiki-links]] for vault references. Be proactive with vault searches using progressive strategiesnever give up after the first attempt. When executing plans, stay adaptive: replan when reality diverges from assumptions, but handle minor adjustments yourself.
**Core Philosophy**: Gate first, then act decisively. Every request passes through the complexity gate before execution. Planning is not overheadit's the correct action for structural, ambiguous, or unbounded tasks. For bounded, unambiguous requests, execute immediately without hesitation. Always use [[wiki-links]] for vault references. Search the vault proactively with progressive strategiesnever accept a single failed search as final. When executing plans, stay adaptive: replan when reality diverges from assumptions, but handle minor adjustments yourself.
`;

View file

@ -1,156 +0,0 @@
<!-- https://codepen.io/aybukeceylan/pen/abLNeox -->
<div class="loading-container">
<div class="loading-text">
<span>C</span>
<span>A</span>
<span>N</span>
<span>C</span>
<span>E</span>
<span>L</span>
<span>L</span>
<span>I</span>
<span>N</span>
<span>G</span>
<span>.</span>
<span>.</span>
<span>.</span>
</div>
</div>
<style>
* {
box-sizing: border-box;
}
.loading-container {
--cancelling-color: var(--text-muted);
display: block;
text-align: center;
position: relative;
color: var(--cancelling-color, currentColor);
}
.loading-container:before {
content: '';
position: absolute;
width: 100%;
height: 1px;
background-color: currentColor;
bottom: 0;
left: 0;
animation: movingLine 3.5s infinite ease-in-out;
}
@keyframes movingLine {
0% {
opacity: 0;
width: 0;
}
23%, 80% {
opacity: 0.8;
width: 100%;
}
92% {
width: 0;
left: initial;
right: 0;
opacity: 1;
}
100% {
opacity: 0;
width: 0;
}
}
.loading-text {
font-size: 1em;
line-height: 1.5;
letter-spacing: 0.05em;
margin-bottom: 0.25em;
padding-bottom: 0.25em;
display: flex;
justify-content: center;
gap: 0.1em;
}
.loading-text span {
animation: moveLetters 3.5s infinite ease-in-out;
transform: translatex(0);
position: relative;
display: inline-block;
opacity: 0;
}
.loading-text span:nth-child(1) {
animation-delay: 0.1s;
}
.loading-text span:nth-child(2) {
animation-delay: 0.2s;
}
.loading-text span:nth-child(3) {
animation-delay: 0.3s;
}
.loading-text span:nth-child(4) {
animation-delay: 0.4s;
}
.loading-text span:nth-child(5) {
animation-delay: 0.5s;
}
.loading-text span:nth-child(6) {
animation-delay: 0.6s;
}
.loading-text span:nth-child(7) {
animation-delay: 0.7s;
}
.loading-text span:nth-child(8) {
animation-delay: 0.8s;
}
.loading-text span:nth-child(9) {
animation-delay: 0.9s;
}
.loading-text span:nth-child(10) {
animation-delay: 1.0s;
}
.loading-text span:nth-child(11) {
animation-delay: 1.1s;
}
.loading-text span:nth-child(12) {
animation-delay: 1.2s;
}
.loading-text span:nth-child(13) {
animation-delay: 1.3s;
}
@keyframes moveLetters {
0% {
transform: translateX(-15vw);
opacity: 0;
}
23%, 80% {
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(15vw);
opacity: 0;
}
}
</style>

View file

@ -4,7 +4,6 @@
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
import ThoughtIndicator from "./ThoughtIndicator.svelte";
import StreamingIndicator from "./StreamingIndicator.svelte";
import CancellationIndicator from "./CancellationIndicator.svelte";
import { Greeting } from "Enums/Greeting";
import { Role } from "Enums/Role";
import type { ConversationContent } from "Conversations/ConversationContent";
@ -13,7 +12,6 @@
import { Exception } from "Helpers/Exception";
import { getOuterHeight, setElementIcon } from "Helpers/ElementHelper";
export let cancelling: boolean = false;
export let messages: ConversationContent[] = [];
export let currentThought: string | null = null;
export let isSubmitting: boolean = false;
@ -23,7 +21,6 @@
export function resetChatArea() {
autoScroll = true;
cancelling = false;
messageElements = [];
lastProcessedContent.clear();
currentStreamFinalized = false;
@ -303,10 +300,6 @@
<StreamingIndicator editModeActive={editModeActive} bind:streamingIndicatorElement={streamingIndicatorElement}/>
{/if}
{#if cancelling}
<CancellationIndicator/>
{/if}
<div bind:this={chatAreaPaddingElement} style:user-select=none></div>
{#if messages.length === 0}

View file

@ -22,9 +22,9 @@
export let hasNoApiKey: boolean;
export let isSubmitting: boolean;
export let editModeActive: boolean;
export let onsubmit: (userRequest: string, formattedRequest: string) => void;
export let ontoggleeditmode: () => void;
export let onstop: () => void;
export let onSubmit: (userRequest: string, formattedRequest: string) => void;
export let onTogglEeditMode: () => void;
export let onStop: () => void;
const inputService: InputService = Resolve<InputService>(Services.InputService);
const userInputService: UserInputService = Resolve<UserInputService>(Services.UserInputService);
@ -79,7 +79,7 @@
}
function handleStop() {
onstop();
onStop();
}
function handleSubmit() {
@ -87,7 +87,7 @@
return;
}
const result = requestFromInput();
onsubmit(result.request, result.formattedRequest);
onSubmit(result.request, result.formattedRequest);
}
function handleSuggestion() {
@ -115,7 +115,7 @@
}
function toggleEditMode() {
ontoggleeditmode();
onTogglEeditMode();
}
async function handleKeydown(e: KeyboardEvent) {
@ -306,8 +306,8 @@
<ChatAttachments bind:attachments={attachments}/>
</div>
<div id="diff-controls-container" style:padding-top={diffOpen ? "var(--size-4-2)" : 0} style:display={diffOpen ? "inline" : "none"}>
<DiffControls/>
<div id="diff-controls-container" style:padding-top={diffOpen ? "var(--size-4-2)" : 0}>
<DiffControls {diffOpen}/>
</div>
<div id="input-search-results-container" style:padding-top={$searchState.results.length > 0 ? "var(--size-4-2)" : 0}>
@ -377,7 +377,7 @@
<style>
#input-container {
grid-row: 2;
grid-row: 3;
grid-column: 1;
display: grid;
grid-template-rows: auto auto auto var(--size-4-3) 1fr var(--size-4-3);

View file

@ -0,0 +1,233 @@
<script lang="ts">
import { ExecutionStatus } from "Enums/ExecutionStatus";
import { setElementIcon } from "Helpers/ElementHelper";
import Spinner from "./Spinner.svelte";
import { tick } from "svelte";
import { fade, slide } from "svelte/transition";
import type { IExecutionPlanState } from "Stores/ExecutionPlanStore";
import type { Writable } from "svelte/store";
export let executionPlanState: Writable<IExecutionPlanState>;
export let editModeActive = false;
export let busyPlanning = false;
let expanded = false;
let expandedHeight = 0;
let collapsedHeight = 0;
let wrapperDiv: HTMLDivElement;
let contentDiv: HTMLDivElement;
let stepElements: (HTMLDivElement | null)[] = [];
let isTransitioning = false;
$: steps = $executionPlanState.plan?.executionSteps;
$: activeStepIndex = steps?.findIndex(step => step.status === ExecutionStatus.Active) ?? -1;
// Step measurements for height calculations
$: stepHeight = stepElements[0]?.offsetHeight ?? 0;
$: separatorHeight = stepElements[0]?.nextElementSibling?.clientHeight ?? 0;
$: if (steps) {
updateHeight();
}
$: if (activeStepIndex >= 0 && !isTransitioning) {
scrollToActiveStep();
}
$: if (wrapperDiv && expanded !== undefined) {
handleExpandTransition();
}
function handleExpandTransition() {
isTransitioning = true;
const onTransitionEnd = () => {
isTransitioning = false;
wrapperDiv.removeEventListener('transitionend', onTransitionEnd);
scrollToActiveStep();
};
wrapperDiv.addEventListener('transitionend', onTransitionEnd);
}
async function updateHeight() {
await tick();
if (!contentDiv || stepElements.length === 0) return;
expandedHeight = contentDiv.scrollHeight;
const stepsToShow = Math.min(3, stepElements.length);
collapsedHeight = (stepsToShow * stepHeight) + (Math.max(0, stepsToShow - 1) * separatorHeight);
}
async function scrollToActiveStep() {
if (!wrapperDiv || !$executionPlanState.plan || activeStepIndex === -1) return;
await tick();
const stepElement = stepElements[activeStepIndex];
if (!stepElement) return;
const stepTop = stepElement.offsetTop;
const stepHeight = stepElement.offsetHeight;
const wrapperHeight = wrapperDiv.clientHeight;
const scrollTo = stepTop - (wrapperHeight / 2) + (stepHeight / 2);
wrapperDiv.scrollTo({ top: scrollTo, behavior: 'smooth' });
}
function toggleExpand() {
expanded = !expanded;
}
</script>
{#if busyPlanning}
<div id="chat-planning-in-progress" transition:slide>
<Spinner {editModeActive}/>
<span id="chat-planning-in-progress-text">Planning in progress...</span>
</div>
{/if}
{#if $executionPlanState.plan}
<div id="chat-plan-area"
out:slide
on:click={toggleExpand}
on:keydown={(e) => e.key === 'Enter' && toggleExpand()}
aria-label={expanded ? "Collapse planned steps" : "Expand planned steps"}
role="button"
tabindex=0>
<div id="chat-plan-steps-wrapper" style:height="{expanded ? expandedHeight : collapsedHeight}px" style:overflow-y={expanded ? 'auto' : 'hidden'} bind:this={wrapperDiv}>
<div id="chat-plan-steps" bind:this={contentDiv}>
{#each $executionPlanState.plan.executionSteps as step, index }
<div class="chat-plan-step" bind:this={stepElements[index]}>
{#if step.status === ExecutionStatus.Completed}
<div class="chat-plan-step-icon" use:setElementIcon={"circle-check"} style:color="var(--color-green)"></div>
{/if}
{#if step.status === ExecutionStatus.Active}
<div class="chat-plan-step-icon">
<Spinner {editModeActive}/>
</div>
{/if}
{#if step.status === ExecutionStatus.Pending}
<div class="chat-plan-step-icon" use:setElementIcon={"circle"} style:opacity={0.5}></div>
{/if}
<span class="chat-plan-step-span">
{`${step.step}. ${step.description}`}
</span>
</div>
{#if index < $executionPlanState.plan.executionSteps.length - 1}
<div class="chat-plan-step-icon"
use:setElementIcon={"ellipsis-vertical"}
style:opacity={step.status === ExecutionStatus.Completed ? 1 : 0.25}>
</div>
{/if}
{/each}
</div>
</div>
{#if $executionPlanState.plan.executionSteps.length > 2}
<div class="chat-plan-fade top-fade" transition:fade></div>
<div class="chat-plan-fade bottom-fade" transition:fade></div>
{/if}
<div id="chat-plan-chevron"
class="transparent-button"
use:setElementIcon={expanded ? "chevrons-down-up" : "chevrons-up-down"}>
</div>
</div>
{/if}
<style>
#chat-planning-in-progress {
display: flex;
gap: var(--size-4-2);
background-color: var(--background-secondary-alt);
border-radius: var(--radius-m);
padding: var(--size-4-2);
font-style: italic;
font-size: var(--font-smaller);
justify-content: center;
}
#chat-planning-in-progress-text {
max-width: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#chat-plan-area {
grid-row: 1;
grid-column: 1;
display: grid;
grid-template-rows: auto;
grid-template-columns: 1fr auto;
background-color: var(--background-secondary-alt);
border-radius: var(--radius-m);
padding: var(--size-4-1) var(--size-4-2);
cursor: pointer;
position: relative;
}
#chat-plan-steps-wrapper {
grid-row: auto;
grid-column: 1;
transition: height 0.2s ease-out;
overflow-x: hidden;
scroll-behavior: smooth;
max-height: 20vh;
}
#chat-plan-steps-wrapper::-webkit-scrollbar {
display: none;
}
#chat-plan-steps {
position: relative;
display: flex;
flex-direction: column;
font-size: var(--font-smallest);
width: 100%;
}
.chat-plan-step {
display: flex;
align-items: center;
}
.chat-plan-step-span {
max-width: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-plan-step-icon {
display: flex;
align-items: center;
padding-right: var(--size-4-2);
}
#chat-plan-chevron {
grid-row: auto;
grid-column: 2;
display: flex;
align-items: center;
justify-content: center;
}
.chat-plan-fade {
position: absolute;
left: 0;
right: 0;
height: var(--size-4-2);
border-radius: var(--radius-m);
pointer-events: none;
z-index: 1;
}
.top-fade {
top: var(--size-4-1);
background-image: linear-gradient(to bottom, var(--background-secondary-alt), transparent);
}
.bottom-fade {
bottom: var(--size-4-1);
background-image: linear-gradient(to top, var(--background-secondary-alt), transparent);
}
</style>

View file

@ -16,8 +16,11 @@
import { Copy } from "Enums/Copy";
import { AbortService } from "Services/AbortService";
import type { Attachment } from "Conversations/Attachment";
import ChatPlanArea from "./ChatPlanArea.svelte";
import type { ExecutionPlanStore } from "Stores/ExecutionPlanStore";
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 workSpaceService: WorkSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
@ -28,9 +31,9 @@
let chatArea: ChatArea;
let chatInput: ChatInput;
let cancelling = false;
let hasNoApiKey = false;
let isSubmitting = false;
let busyPlanning = false;
let editModeActive = false;
let currentStreamingMessageId: string | null = null;
@ -117,15 +120,31 @@
currentThought = thought;
}
},
onComplete: async () => {
cancelling = false;
isSubmitting = false;
abortService.reset();
chatArea.updateChatAreaLayout("smooth", true);
onPlanningStarted: () => {
busyPlanning = true;
},
onPlanningFinished: () => {
busyPlanning = false;
},
onPlanUpdate: (executionPlan) => {
executionPlanStore.setPlan(executionPlan);
},
onPlanStepUpdate: () => {
executionPlanStore.updatePlan();
},
onPlanComplete: () => {
executionPlanStore.clearPlan();
},
onComplete: async () => {
isSubmitting = false;
busyPlanning = false;
currentThought = null;
executionPlanStore.clearPlan();
abortService.reset();
tick().then(() => {
chatArea.updateChatAreaLayout("smooth", true);
});
},
onCancel: () => {
cancelling = true;
}
});
}
@ -167,8 +186,10 @@
</script>
<main class="container">
<ChatPlanArea executionPlanState={executionPlanStore.executionPlanState} {editModeActive} {busyPlanning}/>
<div id="chat-container">
<ChatArea messages={conversation.contents} bind:this={chatArea} bind:currentThought bind:isSubmitting bind:chatContainer bind:cancelling
<ChatArea messages={conversation.contents} bind:this={chatArea} bind:currentThought bind:isSubmitting bind:chatContainer
currentStreamingMessageId={currentStreamingMessageId} editModeActive={editModeActive}/>
</div>
@ -178,16 +199,16 @@
{hasNoApiKey}
{isSubmitting}
{editModeActive}
onsubmit={handleSubmit}
ontoggleeditmode={toggleEditMode}
onstop={handleStop}
onSubmit={handleSubmit}
onTogglEeditMode={toggleEditMode}
onStop={handleStop}
/>
</main>
<style>
.container {
display: grid;
grid-template-rows: 1fr auto var(--size-2-1);
grid-template-rows: auto 1fr auto var(--size-2-1);
grid-template-columns: 1fr;
height: calc(100% - var(--size-4-16));
border-radius: var(--radius-m);
@ -200,7 +221,7 @@
max-width: 1000px;
justify-self: center;
user-select: text;
grid-row: 1;
grid-row: 2;
grid-column: 1;
overflow: hidden;
}

View file

@ -2,28 +2,53 @@
import { Resolve } from "Services/DependencyService";
import type { DiffService } from "Services/DiffService";
import { Services } from "Services/Services";
import { tick } from "svelte";
export let diffOpen = false;
const diffService: DiffService = Resolve<DiffService>(Services.DiffService);
let contentDiv: HTMLDivElement;
let height = 0;
$: diffOpen, updateHeight();
function updateHeight() {
tick().then(() => {
if (contentDiv) {
height = contentDiv.scrollHeight;
}
});
}
</script>
<div id="diff-controls">
<button
id="diff-accept"
class="diff-button"
aria-label="Accept"
on:click={() => diffService.onAccept()}>
Accept
</button>
<button
id="diff-reject"
class="diff-button"
aria-label="Reject"
on:click={() => diffService.onReject()}>
Reject
</button>
<div id="diff-controls-wrapper" style:height="{height}px">
<div id="diff-controls" bind:this={contentDiv}>
{#if diffOpen}
<button
id="diff-accept"
class="diff-button"
aria-label="Accept"
on:click={() => diffService.onAccept()}>
Accept
</button>
<button
id="diff-reject"
class="diff-button"
aria-label="Reject"
on:click={() => diffService.onReject()}>
Reject
</button>
{/if}
</div>
</div>
<style>
#diff-controls-wrapper {
transition: height 0.2s ease-out;
overflow: hidden;
}
#diff-controls {
display: grid;
grid-template-columns: 1fr var(--size-4-2) 1fr;

53
Components/Spinner.svelte Normal file
View file

@ -0,0 +1,53 @@
<script lang="ts">
export let width: string = "18px";
export let height: string = "18px";
export let editModeActive: boolean = false;
export let spinnerElement: HTMLDivElement | undefined = undefined;
</script>
<div
class="circle-border"
class:edit-mode={editModeActive}
style="width: {width}; height: {height};"
bind:this={spinnerElement}
>
<div class="circle-core"></div>
</div>
<style>
@keyframes spin {
from {
transform: rotate(0);
}
to {
transform: rotate(359deg);
}
}
.circle-border {
padding: 1px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
--color: var(--interactive-accent);
background: var(--color);
background: linear-gradient(
0deg,
color-mix(in srgb, var(--color) 10%, transparent) 33%,
var(--color) 100%
);
animation: spin 0.8s linear 0s infinite;
}
.circle-border.edit-mode {
--color: var(--alt-interactive-accent);
}
.circle-core {
width: 100%;
height: 100%;
background-color: var(--background-secondary-alt);
border-radius: 50%;
}
</style>

View file

@ -34,7 +34,7 @@
background: var(--background-primary-alt);
border-radius: 10px;
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-size: var(--font-smallest);
color: var(--text-muted);
font-style: italic;
word-wrap: break-word;

View file

@ -161,7 +161,7 @@
display: grid;
grid-template-rows: auto;
grid-template-columns: var(--size-4-2) auto auto auto auto auto auto auto 1fr 0.1fr auto var(--size-4-2);
background-color: var(--background-modifier-hover);
background-color: var(--background-secondary-alt);
border-radius: var(--radius-m);
}

View file

@ -2,6 +2,7 @@ import { Role } from "Enums/Role";
import { ApiErrorType } from "Types/ApiError";
import type { Attachment } from "./Attachment";
import type { Reference } from "./Reference";
import { Copy } from "Enums/Copy";
type ConversationContentInit = {
role: Role;
@ -109,7 +110,7 @@ export class ConversationContent {
public static safeContinue(): ConversationContent {
return new ConversationContent({
role: Role.User,
content: "Continue",
content: Copy.SafeContinue,
shouldDisplayContent: false
});
}

View file

@ -66,18 +66,25 @@ export enum Copy {
AIThoughtMessage = "Thinking...",
SafeContinue= "Continue",
// Execution Plan Messages
PlanningFailedError = "Planning failed. No execution plan was generated. Please consult with the user about how to proceed.",
PlanningFailedError = `Failed to generate plan. You should attempt to recover from this.
### Next Actions
- Create your own simplified plan
- Follow your own simplified plan`,
StepDoesNotExistError = "Step {stepNumber} does not exist in the execution plan. Valid step numbers are 1-{totalSteps}.",
StepMustBeCompletedInOrderError = "Cannot complete step {stepNumber}. Step \"{incompletStep}\" is not yet completed. Steps must be completed in order.",
StepCompletedWithNextStep = "Step {stepNumber} completed successfully. Now proceed with step {nextStepNumber}.",
AllStepsCompleted = "Step {stepNumber} completed successfully. All steps in the execution plan have been completed. Provide a final summary to the user based on the completed steps and overall success criteria.",
MaxPlanningIterationsReached = "I've attempted multiple planning iterations but encountered persistent issues completing the task. It may need to be broken down further or requires additional clarification.",
PlanExecutionCancelled = "Plan execution cancelled. Provide a summary to the user explaining what happened and any partial progress made.",
PlanExecutionNotCancelled = "Confirmation was false, no action taken",
PlanExecutionNotCancelled = "Confirmation was false, no action taken.",
PlanningToolDenial = "Invalid tool call - this is an execution tool and cannot be called during the planning phase.",
// Execution Plan Request Templates
ContextTags = `
### New planning request
<CONTEXT>
{context}
</CONTEXT>`,

View file

@ -131,7 +131,7 @@
display: grid;
grid-template-rows: auto;
grid-template-columns: var(--size-4-2) auto 1fr var(--size-4-2) auto var(--size-4-2);
background-color: var(--background-modifier-hover);
background-color: var(--background-secondary-alt);
border-radius: var(--radius-m);
}

View file

@ -234,7 +234,7 @@
display: grid;
grid-template-rows: auto;
grid-template-columns: var(--size-4-2) 1fr auto var(--size-4-2);
background-color: var(--background-modifier-hover);
background-color: var(--background-secondary-alt);
border-radius: var(--radius-m);
}

View file

@ -338,8 +338,7 @@ This plugin is built on the shoulders of many excellent projects:
**CSS**
- [Loader](https://uiverse.io/Li-Deheng/bright-firefox-37) - Animated streaming indicator adapted from original by Li-Deheng
- [Gradient Border](https://codepen.io/alphardex/pen/vYEYGzp) - Animated border adapted from original by alphardex
- [Cancellation Indicator](https://codepen.io/aybukeceylan/pen/abLNeox) - Animated cancellation spinner adapted from original by Aybüke Ceylan
---
**Note**: This plugin requires API keys from AI providers. API usage is billed by the respective providers according to their pricing. Monitor your usage through provider dashboards.
**Note**: This plugin requires API keys from AI providers. API usage is billed by the respective providers according to their pricing. Monitor your usage through provider dashboards.

View file

@ -54,8 +54,12 @@ export class AIControllerService {
await this.runAgentLoop(conversation, callbacks, async (functionCall) => {
const functionCallName = functionCall.name;
if (isAIFunction(functionCallName, AIFunction.CreatePlan)) {
const completedSuccessfully = await this.handlePlanningWorkflow(conversation, functionCall, callbacks);
return { shouldExit: completedSuccessfully };
try {
const completedSuccessfully = await this.handlePlanningWorkflow(conversation, functionCall, callbacks);
return { shouldExit: completedSuccessfully };
} finally {
callbacks.onPlanComplete();
}
}
this.updateThought(functionCall, callbacks);
@ -101,7 +105,9 @@ export class AIControllerService {
this.ai.userInstruction = ""; // do not include user instruction
this.ai.toolDefinitions = AIFunctionDefinitions.planningAgentDefinitions();
callbacks.onPlanningStarted();
const executionPlan = await this.runPlanningAgent(this.planningConversation, callbacks);
callbacks.onPlanningFinished();
// Run execution agent with the plan
this.ai.systemPrompt = this.aiPrompt.systemInstruction();
@ -152,11 +158,26 @@ export class AIControllerService {
await this.runAgentLoop(planningConversation, callbacks, async (functionCall) => {
const functionCallName = functionCall.name;
if (!AIFunctionDefinitions.planningAgentDefinitions().some(definition => isAIFunction(functionCallName, definition.name))) {
planningConversation.addFunctionResponse(new AIFunctionResponse(
functionCallName,
{ message: Copy.PlanningToolDenial },
functionCall.toolId
));
return { shouldExit: false };
}
if (isAIFunction(functionCallName, AIFunction.SubmitPlan)) {
const parseResult = SubmitPlanArgsSchema.safeParse(functionCall.arguments);
capturedPlan = parseResult.success
? new ExecutionPlan(parseResult.data)
: new ExecutionPlan({ steps: [] });
if (!parseResult.success) {
planningConversation.addFunctionResponse(new AIFunctionResponse(
functionCallName,
{ error: `Invalid arguments for ${AIFunction.SubmitPlan}: ${parseResult.error.message}` },
functionCall.toolId
));
return { shouldExit: false };
}
capturedPlan = new ExecutionPlan(parseResult.data);
// Add response before exiting to avoid orphaned function call
planningConversation.addFunctionResponse(new AIFunctionResponse(
functionCallName,
@ -172,14 +193,18 @@ export class AIControllerService {
return { shouldExit: false };
}, true);
return capturedPlan ?? new ExecutionPlan({ steps: [] });
if (!capturedPlan) {
Exception.log(`Failed to generate execution plan.\n${JSON.stringify(planningConversation, null, 2)}`);
return new ExecutionPlan({ steps: [] });
}
return capturedPlan;
}
// The 'execution agent' is still the main agent but given specific tools related to plan execution
private async runExecutionAgent(conversation: Conversation, executionPlan: ExecutionPlan, callbacks: IChatServiceCallbacks
): Promise<{ shouldReplan: boolean, isIncomplete: boolean, planExecutionCancelled: boolean, replanData?: ReplanArgs }> {
// callback to ui with execution plan - gets reference to plan so auto updates when updated
callbacks.onPlanUpdate(executionPlan); // plan is being executed so inform UI
const lastCall = conversation.contents[conversation.contents.length - 1];
if (lastCall && lastCall.functionCall) {
@ -251,6 +276,7 @@ export class AIControllerService {
functionCall.toolId
);
conversation.addFunctionResponse(functionResponse);
callbacks.onPlanStepUpdate();
return { shouldExit: false };
}
@ -261,7 +287,12 @@ export class AIControllerService {
});
const isIncomplete = !executionPlan.completed();
return { shouldReplan: replanData !== undefined, isIncomplete, planExecutionCancelled: planExecutionCancelled, replanData };
return {
shouldReplan: replanData !== undefined,
isIncomplete: isIncomplete,
planExecutionCancelled: planExecutionCancelled,
replanData: replanData
};
}
private async runAgentLoop(conversation: Conversation, callbacks: IChatServiceCallbacks,

View file

@ -15,13 +15,18 @@ import type { Attachment } from "Conversations/Attachment";
import { Reference } from "Conversations/Reference";
import type { WorkSpaceService } from "./WorkSpaceService";
import type { AIControllerService } from "./AIControllerService";
import type { ExecutionPlan } from "Types/ExecutionPlan";
export interface IChatServiceCallbacks {
onSubmit: () => void;
onStreamingUpdate: (streamingMessageId: string | null) => void;
onThoughtUpdate: (thought: string | null) => void;
onPlanningStarted: () => void;
onPlanningFinished: () => void;
onPlanUpdate: (executionPlan: ExecutionPlan) => void;
onPlanStepUpdate: () => void;
onPlanComplete: () => void;
onComplete: () => void;
onCancel: () => void;
}
export class ChatService {
@ -102,9 +107,7 @@ export class ChatService {
await this.aiControllerService.runMainAgent(conversation, allowDestructiveActions, callbacks);
});
} catch (error) {
if (AbortService.isAbortError(error)) {
callbacks.onCancel();
} else {
if (!AbortService.isAbortError(error)) {
Exception.log(error);
new Notice("Vaultkeeper AI encountered an error");
}

View file

@ -16,6 +16,7 @@ export class ConversationFileSystemService {
private currentConversationPath: string | null = null;
private deletionQueue: Promise<void> = Promise.resolve();
private isDeleted: boolean = false;
public constructor() {
this.fileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
@ -30,6 +31,10 @@ export class ConversationFileSystemService {
}
public async saveConversation(conversation: Conversation): Promise<string | Error> {
if (this.isDeleted) {
return ""; // Return empty string to indicate silent skip (not an error)
}
if (!this.currentConversationPath) {
this.currentConversationPath = this.generateConversationPath(conversation);
} else {
@ -74,6 +79,7 @@ export class ConversationFileSystemService {
public resetCurrentConversation() {
this.currentConversationPath = null;
this.isDeleted = false;
}
public getCurrentConversationPath(): string | null {
@ -82,6 +88,7 @@ export class ConversationFileSystemService {
public setCurrentConversationPath(filePath: string) {
this.currentConversationPath = filePath;
this.isDeleted = false;
}
public async deleteCurrentConversation(): Promise<void | Error> {
@ -104,7 +111,9 @@ export class ConversationFileSystemService {
return deleteResult;
}
this.resetCurrentConversation();
// Mark as deleted to prevent subsequent saves during ongoing operations
this.isDeleted = true;
this.currentConversationPath = null;
}
public async getAllConversations(): Promise<Conversation[]> {

View file

@ -25,6 +25,7 @@ import { SanitiserService } from "./SanitiserService";
import { VaultCacheService } from "./VaultCacheService";
import { UserInputService } from "./UserInputService";
import { SearchStateStore } from "Stores/SearchStateStore";
import { ExecutionPlanStore } from "Stores/ExecutionPlanStore";
import { InputService } from "./InputService";
import { HTMLService } from "./HTMLService";
import { SettingsService, type IVaultkeeperAISettings } from "./SettingsService";
@ -52,6 +53,7 @@ export function RegisterDependencies() {
RegisterSingleton<VaultService>(Services.VaultService, new VaultService());
RegisterSingleton<VaultCacheService>(Services.VaultCacheService, new VaultCacheService());
RegisterSingleton<SearchStateStore>(Services.SearchStateStore, new SearchStateStore());
RegisterSingleton<ExecutionPlanStore>(Services.ExecutionPlanStore, new ExecutionPlanStore());
RegisterSingleton<UserInputService>(Services.UserInputService, new UserInputService());
RegisterSingleton<WorkSpaceService>(Services.WorkSpaceService, new WorkSpaceService());
RegisterSingleton<FileSystemService>(Services.FileSystemService, new FileSystemService());

View file

@ -23,6 +23,7 @@ export class Services {
// stores
static SearchStateStore = Symbol("SearchStateStore");
static ExecutionPlanStore = Symbol("ExecutionPlanStore");
// interfaces
static IAIClass = Symbol("IAIClass");

View file

@ -0,0 +1,28 @@
import type { ExecutionPlan } from 'Types/ExecutionPlan';
import { writable, get } from 'svelte/store';
export interface IExecutionPlanState {
plan: ExecutionPlan | null
}
export class ExecutionPlanStore {
public executionPlanState = writable<IExecutionPlanState>({
plan: null
});
public setPlan(plan: ExecutionPlan | null) {
this.executionPlanState.set({ plan });
}
public updatePlan() {
this.executionPlanState.update(state => ({ ...state }));
}
public clearPlan() {
this.executionPlanState.set({ plan: null });
}
public getCurrentPlan(): ExecutionPlan | null {
return get(this.executionPlanState).plan;
}
}

View file

@ -61,17 +61,6 @@
/* CSS Variables for Common Components */
/* ============================== */
.fading-stream {
opacity: 0;
animation: fadeIn 1s forwards;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
.transparent-button {
border: none !important;
outline: none !important;

View file

@ -5,7 +5,7 @@ import { Copy, replaceCopy } from "Enums/Copy";
export class ExecutionPlan {
private executionSteps: ExecutionStep[] = [];
public readonly executionSteps: ExecutionStep[] = [];
public constructor(plan: SubmitPlanArgs) {
for (const [index, step] of plan.steps.entries()) {

View file

@ -86,6 +86,11 @@ describe('AIControllerService - Integration Tests', () => {
onSubmit: vi.fn(),
onStreamingUpdate: vi.fn(),
onThoughtUpdate: vi.fn(),
onPlanningStarted: vi.fn(),
onPlanningFinished: vi.fn(),
onPlanUpdate: vi.fn(),
onPlanStepUpdate: vi.fn(),
onPlanComplete: vi.fn(),
onComplete: vi.fn(),
onCancel: vi.fn()
};
@ -111,6 +116,11 @@ describe('AIControllerService - Integration Tests', () => {
onSubmit: vi.fn(),
onStreamingUpdate: vi.fn(),
onThoughtUpdate: vi.fn(),
onPlanningStarted: vi.fn(),
onPlanningFinished: vi.fn(),
onPlanUpdate: vi.fn(),
onPlanStepUpdate: vi.fn(),
onPlanComplete: vi.fn(),
onComplete: vi.fn(),
onCancel: vi.fn()
};
@ -136,6 +146,11 @@ describe('AIControllerService - Integration Tests', () => {
onSubmit: vi.fn(),
onStreamingUpdate: vi.fn(),
onThoughtUpdate: vi.fn(),
onPlanningStarted: vi.fn(),
onPlanningFinished: vi.fn(),
onPlanUpdate: vi.fn(),
onPlanStepUpdate: vi.fn(),
onPlanComplete: vi.fn(),
onComplete: vi.fn(),
onCancel: vi.fn()
};
@ -156,6 +171,11 @@ describe('AIControllerService - Integration Tests', () => {
onSubmit: vi.fn(),
onStreamingUpdate: vi.fn(),
onThoughtUpdate: vi.fn(),
onPlanningStarted: vi.fn(),
onPlanningFinished: vi.fn(),
onPlanUpdate: vi.fn(),
onPlanStepUpdate: vi.fn(),
onPlanComplete: vi.fn(),
onComplete: vi.fn(),
onCancel: vi.fn()
};

View file

@ -329,6 +329,24 @@ describe('ConversationFileSystemService - Integration Tests', () => {
expect((result as Error).message).toBe('Permission denied');
expect(service.getCurrentConversationPath()).not.toBeNull();
});
it('should prevent subsequent saves after deletion', async () => {
// This tests the fix for the bug where deleting during an active diff
// would resurrect the conversation when the finally block tried to save
const conversation = createTestConversation('Delete During Diff');
await service.saveConversation(conversation);
expect(service.getCurrentConversationPath()).not.toBeNull();
mockFileSystemService.deleteFile.mockResolvedValue(undefined);
await service.deleteCurrentConversation();
// Try to save the same conversation again (simulates what happens in finally block)
const result = await service.saveConversation(conversation);
// Should return empty string (silent skip), not save the file
expect(result).toBe('');
expect(mockFileSystemService.writeObjectToFile).toHaveBeenCalledTimes(1); // Only the initial save
});
});
describe('getAllConversations', () => {
@ -614,6 +632,9 @@ describe('ConversationFileSystemService - Integration Tests', () => {
await service.deleteCurrentConversation();
expect(service.getCurrentConversationPath()).toBeNull();
// Reset for new conversation (matches real workflow)
service.resetCurrentConversation();
// New conversation
const conv2 = createTestConversation('Second');
await service.saveConversation(conv2);

View file

@ -708,7 +708,8 @@ describe('FileSystemService', () => {
{
text: 'This is a test query in the content',
matchIndex: 10,
matchLength: 10
matchLength: 10,
pageNumber: 1
}
]
},
@ -718,7 +719,8 @@ describe('FileSystemService', () => {
{
text: 'Another test query here',
matchIndex: 8,
matchLength: 10
matchLength: 10,
pageNumber: 1
}
]
}

View file

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { SettingsService, IVaultkeeperAISettings } from '../../Services/SettingsService';
import { SettingsService, type IVaultkeeperAISettings } from '../../Services/SettingsService';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
import { AIProvider, AIProviderModel, fromModel } from '../../Enums/ApiProvider';

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { StreamingService, IStreamChunk } from '../../Services/StreamingService';
import { StreamingService, type IStreamChunk } from '../../Services/StreamingService';
import { Exception } from '../../Helpers/Exception';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';

View file

@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TFile, TFolder, TAbstractFile, MetadataCache, FileManager } from 'obsidian';
import { VaultCacheService } from '../../Services/VaultCacheService';
import { VaultService } from '../../Services/VaultService';
import { SettingsService, IVaultkeeperAISettings } from '../../Services/SettingsService';
import { SettingsService, type IVaultkeeperAISettings } from '../../Services/SettingsService';
import { SanitiserService } from '../../Services/SanitiserService';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';

View file

@ -1,11 +1,10 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { VaultService } from '../../Services/VaultService';
import { TFile, TFolder, FileManager } from 'obsidian';
import { Path } from '../../Enums/Path';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
import { SanitiserService } from '../../Services/SanitiserService';
import { SettingsService, IVaultkeeperAISettings } from '../../Services/SettingsService';
import { SettingsService, type IVaultkeeperAISettings } from '../../Services/SettingsService';
import { AIProviderModel } from '../../Enums/ApiProvider';
import { Exception } from '../../Helpers/Exception';
import * as PDFHelper from '../../Helpers/PDFHelper';

View file

@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TFile, TFolder, TAbstractFile, Vault, FileManager } from 'obsidian';
import { VaultService } from '../../Services/VaultService';
import { SettingsService, IVaultkeeperAISettings } from '../../Services/SettingsService';
import { SettingsService, type IVaultkeeperAISettings } from '../../Services/SettingsService';
import { SanitiserService } from '../../Services/SanitiserService';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';

View file

@ -5,7 +5,7 @@ import { Path } from '../../Enums/Path';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
import { SanitiserService } from '../../Services/SanitiserService';
import { SettingsService, IVaultkeeperAISettings } from '../../Services/SettingsService';
import { SettingsService, type IVaultkeeperAISettings } from '../../Services/SettingsService';
import { AIProviderModel } from '../../Enums/ApiProvider';
import { Exception } from '../../Helpers/Exception';