andy-stack_vaultkeeper-ai/Components/ChatArea.svelte
Andrew Beal f4c3b5b826 feat: add file change summary cards to assistant messages
Display artifact changes in chat with visual indicators for create/modify/delete actions, including file counts, color-coded status badges, and scrollable file list
2026-07-11 13:49:44 +01:00

673 lines
No EOL
18 KiB
Svelte

<script lang="ts">
import { Resolve } from "Services/DependencyService";
import { Services } from "Services/Services";
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
import ThoughtIndicator from "./ThoughtIndicator.svelte";
import StreamingIndicator from "./StreamingIndicator.svelte";
import { Greeting } from "Enums/Greeting";
import { Role } from "Enums/Role";
import type { ConversationContent } from "Conversations/ConversationContent";
import { tick } from "svelte";
import { setElementIcon } from "Helpers/ElementHelper";
import { setIcon } from "obsidian";
import { fade } from "svelte/transition";
import GraphAnimation from "./GraphAnimation.svelte";
import { ArtifactAction, artifactActionToCopy } from "Enums/ArtifactAction";
import { basename } from "path-browserify";
export let messages: ConversationContent[] = [];
export let currentThought: string | null = null;
export let isSubmitting: boolean = false;
export let chatContainer: HTMLDivElement;
export function resetChatArea() {
chatContainer.scroll({ top: 0, behavior: "instant" });
tick().then(updateScrolledState);
}
export async function updateChatAreaLayout(behavior: ScrollBehavior | undefined = undefined) {
await tick();
requestAnimationFrame(() => {
if (behavior) {
scrollToLatestTurn(behavior);
}
tick().then(updateScrolledState);
});
}
function scrollToLatestTurn(behavior: ScrollBehavior) {
const latestTurn = chatContainer.querySelector<HTMLElement>(".message-group.latest");
if (!latestTurn) {
return;
}
const paddingTop = parseFloat(getComputedStyle(chatContainer).paddingTop) || 0;
chatContainer.scroll({ top: latestTurn.offsetTop - paddingTop, behavior });
}
function scrollToBottom(behavior: ScrollBehavior) {
chatContainer.scroll({ top: chatContainer.scrollHeight, behavior });
}
function updateScrolledState() {
if (!chatContainer) {
return;
}
const isScrollable = chatContainer.scrollHeight > chatContainer.clientHeight;
scrolledToBottom = !isScrollable || Math.abs(chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight) < 100;
}
let scrolledToBottom: boolean = true;
let scrollToBottomButton: HTMLButtonElement;
let streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
type Turn = { id: number, messages: ConversationContent[] };
$: turns = groupTurns(messages);
// A turn starts at each visible user message; hidden contents (tool
// responses, planning notices, attachment stubs) stay attached to the
// turn they belong to instead of starting phantom turns
function groupTurns(messages: ConversationContent[]): Turn[] {
const turns: Turn[] = [];
for (const message of messages) {
if ((message.role === Role.User && message.shouldDisplayContent) || turns.length === 0) {
turns.push({ id: message.id, messages: [] });
}
turns[turns.length - 1].messages.push(message);
}
return turns;
}
function getGreetingByTime(): string {
const hour = new Date().getHours();
// Morning: 5am - 11:59am
if (hour >= 5 && hour < 12) {
return Greeting.Morning;
}
// Midday: 12pm - 4:59pm
else if (hour >= 12 && hour < 17) {
return Greeting.Midday;
}
// Evening: 5pm - 8:59pm
else if (hour >= 17 && hour < 21) {
return Greeting.Evening;
}
// Night: 9pm - 4:59am
else {
return Greeting.Night;
}
}
function messageRenderAction(element: HTMLElement, message: ConversationContent) {
streamingMarkdownService.render(message.getDisplayContent(), element);
return {
update(newMessage: ConversationContent) {
streamingMarkdownService.render(newMessage.getDisplayContent(), element, !isSubmitting);
}
};
}
function tallyArtifactsByAction(artifacts: { action: ArtifactAction }[]): Partial<Record<ArtifactAction, number>> {
const tally: Partial<Record<ArtifactAction, number>> = {};
for (const artifact of artifacts) {
tally[artifact.action] = (tally[artifact.action] ?? 0) + 1;
}
return tally;
}
$: if (scrollToBottomButton) {
setIcon(scrollToBottomButton, "arrow-down");
}
</script>
<div class="chat-area-wrapper">
{#if messages.length > 0}
<div class="top-fade"></div>
{/if}
<div class="chat-area" bind:this={chatContainer} on:scroll={updateScrolledState}>
{#each turns as turn, turnIndex (turn.id)}
{@const isLatestTurn = turnIndex === turns.length - 1}
<div class="message-group" class:latest={isLatestTurn}>
{#each turn.messages as message (message.id)}
{@const content = message.getDisplayContent()}
{#if message.shouldDisplayContent && content.trim() !== ""}
{#if message.role === Role.User}
<div class="message-container {Role.User}">
<div class="message-bubble {Role.User}">
<div class="message-text-user-container" contenteditable="false">
<div class="message-text-user">
{@html content}
</div>
</div>
{#if message.references.length > 0}
<hr class="message-attachment-break"/>
<div class="message-attachments-container">
{#each message.references as reference}
<div class="message-attachmanet" aria-label="{reference.fileName}">
<div
class="message-attachment-icon"
use:setElementIcon={reference.getIconName()}
></div>
<div class="message-attachment-info">
<div class="message-attachment-name">{reference.fileName}</div>
<div class="message-attachment-size">{reference.size}MB</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{:else}
<div class="message-container {Role.Assistant}">
<div class="message-bubble {Role.Assistant}">
<div class="markdown-content">
<div use:messageRenderAction={message} class="streaming-content"></div>
</div>
{#if message.artifacts.length > 0}
{@const artifactTally = tallyArtifactsByAction(message.artifacts)}
<div class="artifacts-container" in:fade={{ duration: 300 }}>
<span class="artifacts-container-title">{message.artifacts.length} FILES CHANGED</span>
<div class="artifacts-tally">
{#each Object.values(ArtifactAction) as action}
{#if artifactTally[action]}
<div class="artifact-tally-container">
<span class="artifact-tally-ellipse artifact-{action}"></span>
<span class="artifact-tally-count">{artifactTally[action]}</span>
</div>
{/if}
{/each}
</div>
<div class="artifacts-list-container">
{#each message.artifacts as artifact}
<div class="artifact-card" aria-label="{artifact.filePath}">
<span class="artifact-ellipse artifact-{artifact.action}"></span>
<div
class="artifact-icon"
use:setElementIcon={artifact.getIconName()}
></div>
<span class="artifact-name">{basename(artifact.filePath)}</span>
<span class="artifact-action artifact-action-{artifact.action}">{artifactActionToCopy(artifact.action)}</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
{/if}
{/if}
{/each}
{#if isLatestTurn}
<ThoughtIndicator thought={currentThought}/>
{#if isSubmitting}
<div transition:fade={{ duration: 300 }}>
<StreamingIndicator/>
</div>
{/if}
{/if}
</div>
{/each}
{#if messages.length === 0}
<div class="conversation-empty-container">
<div class="conversation-empty-animation">
<GraphAnimation/>
</div>
<div class="conversation-empty-greeting">
<div class="conversation-empty-greeting-backdrop"></div>
<div class="conversation-empty-greeting-label">{getGreetingByTime()}</div>
</div>
</div>
{/if}
</div>
{#if messages.length > 0}
<div class="bottom-fade"></div>
{/if}
{#if !scrolledToBottom}
<div class="scroll-to-bottom-container" transition:fade>
<button
id="scroll-to-bottom-button"
bind:this={scrollToBottomButton}
on:click={() => scrollToBottom("smooth")}
aria-label="Scroll to bottom">
</button>
</div>
{/if}
</div>
<style>
.scroll-to-bottom-container {
background-color: color-mix(in srgb, var(--background-primary) 70%, transparent);
border-radius: var(--radius-l);
padding: var(--size-4-2);
position: absolute;
bottom: var(--size-4-2);
right: 0px;
}
#scroll-to-bottom-button {
background-color: var(--interactive-accent);
}
.chat-area-wrapper {
position: relative;
height: 100%;
width: 100%;
}
.top-fade {
position: absolute;
top: 0;
left: 0;
right: 0;
height: var(--size-4-4);
background-image: linear-gradient(to bottom, var(--background-secondary), transparent);
z-index: 10;
pointer-events: none;
}
.bottom-fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: var(--size-4-4);
background-image: linear-gradient(to top, var(--background-secondary), transparent);
z-index: 10;
pointer-events: none;
}
.chat-area {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
overflow: auto;
padding: var(--size-4-4) var(--size-4-3) var(--size-4-3) var(--size-4-3);
gap: var(--size-4-2);
scroll-behavior: smooth;
}
.chat-area::-webkit-scrollbar {
display: none;
}
.message-group {
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: var(--size-4-2);
}
.message-group.latest {
margin-top: var(--size-4-2);
min-height: 100%;
}
.message-container {
display: flex;
text-align: left;
margin: 0;
}
.message-container.user {
justify-content: flex-end;
}
.message-container.assistant {
justify-content: flex-start;
}
.message-container {
animation: fadeIn 0.5s ease-out forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message-bubble {
word-wrap: break-word;
}
.message-bubble.user {
word-wrap: break-word;
max-width: 70%;
border: var(--border-width) solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: 0px var(--size-4-2);
}
.message-bubble.assistant {
word-wrap: break-word;
width: 100%;
}
.message-text-user-container {
max-height: 15vh;
overflow: scroll;
padding-top: var(--size-4-2);
white-space: pre-wrap;
}
.message-text-user-container::-webkit-scrollbar {
display: none;
}
.message-text-user-container {
padding-bottom: var(--size-4-2);
}
.artifacts-container {
display: grid;
grid-template-rows: auto auto;
grid-template-columns: auto auto;
width: 100%;
margin-top: var(--size-4-3);
gap: var(--size-4-4);
}
.artifacts-container-title {
grid-row: 1;
grid-column: 1;
font-size: var(--font-smallest);
}
.artifacts-tally {
grid-row: 1;
grid-column: 2;
display: flex;
flex-direction: row;
}
.artifact-tally-container {
display: flex;
flex-direction: row;
margin-left: auto;
align-items: center;
}
.artifact-tally-ellipse {
flex-shrink: 0;
width: 6px;
height: 6px;
border-radius: 50%;
margin-left: var(--size-4-2);
}
.artifact-create {
background: var(--color-green);
}
.artifact-modify {
background: var(--color-blue);
}
.artifact-delete {
background: var(--color-red);
}
.artifact-tally-count {
font-size: var(--font-smallest);
margin-left: var(--size-4-1);
}
.artifacts-list-container {
grid-row: 2;
grid-column: 1 / 3;
display: flex;
flex-direction: column;
overflow: scroll;
width: 100%;
max-height: 200px;
gap: var(--size-4-1);
}
.artifacts-list-container::-webkit-scrollbar {
display: none;
}
.artifact-card {
display: grid;
grid-template-rows: auto;
grid-template-columns: auto auto 1fr auto;
gap: var(--size-4-3);
align-items: center;
height: 30px;
flex-shrink: 0;
background-color: var(--background-secondary-alt);
border-radius: var(--size-4-2);
padding: 0 var(--size-4-1) 0 var(--size-4-2);
}
.artifact-ellipse {
grid-row: 1;
grid-column: 1;
flex-shrink: 0;
width: 10px;
height: 10px;
border-radius: 50%;
align-self: center;
}
.artifact-ellipse-create {
background: var(--color-green);
}
.artifact-ellipse-modify {
background: var(--color-blue);
}
.artifact-ellipse-delete {
background: var(--color-red);
}
.artifact-icon {
grid-row: 1;
grid-column: 2;
display: flex;
align-items: center;
justify-content: center;
}
.artifact-name {
grid-row: 1;
grid-column: 3;
display: flex;
align-items: center;
justify-content: start;
font-size: var(--font-smaller);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.artifact-action {
grid-row: 1;
grid-column: 4;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-smallest);
font-weight: var(--font-semibold);
border-radius: var(--size-4-2);
padding: var(--size-2-1) var(--size-4-2);
}
.artifact-action-create {
background-color: color-mix(
in srgb,
var(--color-green) 25%,
black 20%
);
color: color-mix(
in srgb,
var(--color-green) 100%,
white 10%
);
}
.artifact-action-modify {
background-color: color-mix(
in srgb,
var(--color-blue) 25%,
black 20%
);
color: color-mix(
in srgb,
var(--color-blue) 100%,
white 10%
);
}
.artifact-action-delete {
background-color: color-mix(
in srgb,
var(--color-red) 25%,
black 20%
);
color: color-mix(
in srgb,
var(--color-red) 100%,
white 10%
);
}
.conversation-empty-container {
display: grid;
height: 100%;
width: 100%;
}
.conversation-empty-animation {
grid-row: 1;
grid-column: 1;
height: 100%;
width: 100%;
}
.conversation-empty-greeting-backdrop {
position: absolute;
inset: -48px;
background: radial-gradient(
ellipse closest-side,
var(--background-secondary) 0%,
color-mix(in srgb, var(--background-secondary) 80%, transparent) 65%,
transparent 100%
);
}
.conversation-empty-greeting {
grid-row: 1;
grid-column: 1;
position: relative;
margin: auto;
font-style: italic;
font-size: var(--font-ui-medium);
color: var(--text-muted);
pointer-events: none;
}
.conversation-empty-greeting-label {
position: relative;
overflow: hidden;
white-space: nowrap;
padding: var(--size-2-2);
}
.streaming-content {
justify-content: left;
min-height: 1em; /* Ensure the element exists for binding */
}
/* Streaming message styles */
.content-fade-in {
animation: reveal-fade 0.5s ease-in-out forwards;
}
@keyframes reveal-fade {
0% {
opacity: 0;
transform: translateY(10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* Message attachments styles */
.message-attachment-break {
color: var(--background-secondary-alt);
margin: 0 0 var(--size-4-2) 0;
opacity: 0.5;
}
.message-attachments-container {
display: flex;
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
gap: var(--size-4-2);
margin-bottom: var(--size-4-2);
}
.message-attachments-container::-webkit-scrollbar {
display: none;
}
.message-attachmanet {
display: grid;
grid-template-rows: var(--size-4-2) auto var(--size-4-2);
grid-template-columns: var(--size-4-2) auto var(--size-4-2) auto var(--size-4-2);
background-color: var(--background-secondary-alt);
border: var(--border-width) solid var(--background-modifier-border);
border-radius: var(--radius-m);
flex-shrink: 0;
}
.message-attachment-icon {
grid-row: 2;
grid-column: 2;
display: flex;
align-items: center;
justify-content: center;
}
.message-attachment-info {
grid-row: 2;
grid-column: 4;
min-width: 40px;
overflow: hidden;
}
.message-attachment-name {
display: inline-block;
white-space: nowrap;
width: 100%;
padding: 0;
font-size: var(--font-smaller);
}
.message-attachment-size {
padding: 0;
font-size: var(--font-smallest);
color: var(--text-muted);
}
</style>