andy-stack_vaultkeeper-ai/Components/ChatPlanArea.svelte
Andrew Beal f693933950 refactor: replace Spinner boolean prop with customizable background color
Replace alternateBackground boolean prop with background string prop to allow direct CSS variable specification. Update QuickActionsService to add apply template action with file selection modal, timeout handling, and loading notices. Add helper method for markdown file retrieval.
2026-04-23 15:57:27 +01:00

268 lines
No EOL
8.1 KiB
Svelte

<script lang="ts">
import { setElementIcon } from "Helpers/ElementHelper";
import { Copy } from "Enums/Copy";
import Spinner from "./Spinner.svelte";
import { tick, onDestroy } 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 busyPlanning = false;
let expanded = false;
let expandedHeight = 0;
let collapsedHeight = 0;
let wrapperDiv: HTMLDivElement;
let contentDiv: HTMLDivElement;
let stepElements: (HTMLDivElement | null)[] = [];
let isTransitioning = false;
let resizeObserver: ResizeObserver | null = null;
$: steps = $executionPlanState.plan?.executionSteps;
$: activeStepIndex = $executionPlanState.currentStepIndex;
$: if (steps) {
tick().then(() => {
requestAnimationFrame(() => {
updateHeight();
});
});
}
$: if (contentDiv) {
setupResizeObserver();
}
$: 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);
}
function setupResizeObserver() {
if (resizeObserver) {
resizeObserver.disconnect();
}
resizeObserver = new ResizeObserver(() => {
updateHeight();
});
resizeObserver.observe(contentDiv);
}
async function updateHeight() {
const firstStep = stepElements[0];
if (!contentDiv || !firstStep || !steps) {
return;
}
expandedHeight = contentDiv.scrollHeight;
// Calculate heights fresh each time to ensure accurate measurements
const stepHeight = firstStep.offsetHeight;
const separatorHeight = firstStep.nextElementSibling?.clientHeight ?? 0;
const stepsToShow = Math.min(3, steps.length);
collapsedHeight = (stepsToShow * stepHeight) + (Math.max(0, stepsToShow - 1) * separatorHeight);
}
onDestroy(() => {
if (resizeObserver) {
resizeObserver.disconnect();
}
});
async function scrollToActiveStep() {
if (!wrapperDiv || !$executionPlanState.plan || activeStepIndex === -1) {
return;
}
await tick();
const stepElement = stepElements[activeStepIndex];
if (!stepElement) return;
// Calculate position relative to the scroll container (wrapperDiv)
const stepTop = stepElement.offsetTop - contentDiv.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;
requestAnimationFrame(() => {
updateHeight();
});
}
</script>
{#if busyPlanning}
<div id="chat-planning-in-progress" transition:slide>
<Spinner background=var(--background-secondary-alt)/>
<span id="chat-planning-in-progress-text">{Copy.PlanningInProgress}</span>
</div>
{/if}
{#if steps && steps?.length > 0}
<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" bind:this={wrapperDiv}>
<div id="chat-plan-steps" bind:this={contentDiv}>
{#each steps as step, index }
<div class="chat-plan-step" bind:this={stepElements[index]}>
{#if index < activeStepIndex}
<div class="chat-plan-step-icon" use:setElementIcon={"circle-check"} style:color="var(--color-green)"></div>
{/if}
{#if index === activeStepIndex}
<div class="chat-plan-step-icon">
<Spinner background=var(--background-secondary-alt)/>
</div>
{/if}
{#if index > activeStepIndex}
<div class="chat-plan-step-icon" use:setElementIcon={"circle"} style:opacity={0.5}></div>
{/if}
<span class="chat-plan-step-span">
{`${index + 1}. ${step.description}`}
</span>
</div>
{#if index < steps.length - 1}
<div class="chat-plan-step-icon"
use:setElementIcon={"ellipsis-vertical"}
style:opacity={index < activeStepIndex ? 1 : 0.25}>
</div>
{/if}
{/each}
</div>
</div>
{#if steps.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;
overflow-y: auto;
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-1);
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>