feat: add artifact diff viewer and improve button styling

- Add clickable artifact cards with keyboard support
- Create new ArtifactView for displaying file diffs
- Update button styles with solid backgrounds and faster transitions
- Add auto-cleanup of stale diff and plan approval views on startup
- Fix artifact deletion to preserve original file path
This commit is contained in:
Andrew Beal 2026-07-12 15:14:44 +01:00
parent 6b5d51df39
commit 82dab77d74
15 changed files with 422 additions and 146 deletions

View file

@ -7,11 +7,14 @@
import { fade } from "svelte/transition";
import { ArtifactAction, artifactActionToCopy } from "Enums/ArtifactAction";
import { basename } from "path-browserify";
import type { Artifact } from "Conversations/Artifact";
import type { DiffService } from "Services/DiffService";
export let message: ConversationContent;
export let isSubmitting: boolean = false;
let streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
let diffService: DiffService = Resolve<DiffService>(Services.DiffService);
function messageRenderAction(element: HTMLElement, message: ConversationContent) {
streamingMarkdownService.render(message.getDisplayContent(), element);
@ -47,6 +50,10 @@
}
};
}
function handleArtifactCardClick(artifact: Artifact) {
diffService.showArtifactDiff(artifact);
}
</script>
<div class="message-container assistant">
@ -74,11 +81,15 @@
<div
class="artifact-card"
aria-label="{artifact.filePath}"
on:click={() => handleArtifactCardClick(artifact)}
on:keydown={(e) => e.key === 'Enter' && handleArtifactCardClick(artifact)}
role="button"
tabindex="0"
>
<span class="artifact-ellipse artifact-ellipse-{artifact.action}"></span>
<div
class="artifact-icon"
use:setElementIcon={artifact.getIconName()}
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>
@ -253,7 +264,8 @@
align-self: center;
}
.artifact-card:hover .artifact-ellipse {
.artifact-card:hover .artifact-ellipse,
.artifact-card:focus-visible .artifact-ellipse {
width: 12px;
height: 12px;
box-shadow: 0px 0px 4px 1px currentColor;
@ -302,7 +314,7 @@
justify-content: center;
font-size: var(--font-smallest);
font-weight: var(--font-semibold);
border-radius: var(--size-4-2);
border-radius: var(--size-4-1);
padding: var(--size-2-1) var(--size-4-2);
}

View file

@ -862,7 +862,7 @@
padding-left: var(--size-4-2);
padding-right: var(--size-4-2);
align-self: end;
transition-duration: 0.5s;
transition: background-color 0.2s ease-out;
background-color: var(--interactive-accent);
}

View file

@ -45,68 +45,45 @@
<style>
#diff-controls-wrapper {
transition: height 0.2s ease-out;
overflow: hidden;
transition: height 0.2s ease-out;
overflow: hidden;
}
#diff-controls {
display: grid;
grid-template-columns: 1fr var(--size-4-2) 1fr;
grid-template-rows: auto;
display: grid;
grid-template-columns: 1fr var(--size-4-2) 1fr;
grid-template-rows: auto;
}
#diff-accept {
grid-column: 1;
color: var(--color-green);
border: solid;
border-color: var(--color-green);
border-width: var(--size-2-1);
background-color: var(--background-primary);
grid-column: 1;
color: white;
background-color: #38533a;
}
#diff-accept:hover {
background-color: color-mix(
in srgb,
var(--color-green) 25%,
white 10%
);
background-color: #537555;
}
#diff-accept:focus {
background-color: color-mix(
in srgb,
var(--color-green) 25%,
white 10%
);
background-color: #537555;
}
#diff-reject {
grid-column: 3;
color: var(--color-red);
border: solid;
border-color: var(--color-red);
border-width: var(--size-2-1);
background-color: var(--background-primary);
grid-column: 3;
color: white;
background-color: #593030;
}
#diff-reject:hover {
background-color: color-mix(
in srgb,
var(--color-red) 25%,
white 10%
);
background-color: #774545;
}
#diff-reject:focus {
background-color: color-mix(
in srgb,
var(--color-red) 25%,
white 10%
);
background-color: #774545;
}
.diff-button {
border-radius: var(--button-radius);
transition-duration: 0.5s;
transition: background-color 0.2s ease-out;
}
</style>

View file

@ -46,68 +46,45 @@
<style>
#plan-approval-controls-wrapper {
transition: height 0.2s ease-out;
overflow: hidden;
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;
display: grid;
grid-template-columns: 1fr var(--size-4-2) 1fr;
grid-template-rows: auto;
}
#plan-approve {
grid-column: 1;
color: var(--color-green);
border: solid;
border-color: var(--color-green);
border-width: var(--size-2-1);
background-color: var(--background-primary);
grid-column: 1;
color: white;
background-color: #38533a;
}
#plan-approve:hover {
background-color: color-mix(
in srgb,
var(--color-green) 25%,
white 10%
);
background-color: #537555;
}
#plan-approve:focus {
background-color: color-mix(
in srgb,
var(--color-green) 25%,
white 10%
);
background-color: #537555;
}
#plan-reject {
grid-column: 3;
color: var(--color-red);
border: solid;
border-color: var(--color-red);
border-width: var(--size-2-1);
background-color: var(--background-primary);
grid-column: 3;
color: white;
background-color: #593030;
}
#plan-reject:hover {
background-color: color-mix(
in srgb,
var(--color-red) 25%,
white 10%
);
background-color: #774545;
}
#plan-reject:focus {
background-color: color-mix(
in srgb,
var(--color-red) 25%,
white 10%
);
background-color: #774545;
}
.plan-approval-button {
border-radius: var(--button-radius);
transition-duration: 0.5s;
transition: background-color 0.2s ease-out;
}
</style>

View file

@ -143,14 +143,15 @@ export enum Copy {
ButtonWebSearchUnavailable = "Web search unavailable",
ButtonUserInstruction = "User Instruction",
ButtonAttachFiles = "Attach Files",
// Plan Approval View
PlanApprovalViewTitle = "Vaultkeeper AI plan",
ButtonApprove = "Approve",
ButtonReject = "Reject",
ButtonDiscuss = "Discuss",
ButtonRestore = "Restore this version",
ButtonRestorePrevious = "Restore previous version",
ButtonConfirm = "Confirm?",
// 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.`,

View file

@ -481,7 +481,7 @@ export class AIToolService {
if (isBinaryFile(fileType) || isDocumentMimeType(FileTypeToMimeType[fileType])) {
const result = await this.fileSystemService.readBinaryFile(filePath);
if (result instanceof ArrayBuffer) {
artifacts.push(new Artifact(filePath, FileTypeToMimeType[fileType], ArtifactAction.Delete, "", "", arrayBufferToBase64(result)));
artifacts.push(new Artifact(filePath, FileTypeToMimeType[fileType], ArtifactAction.Delete, filePath, "", arrayBufferToBase64(result)));
}
} else {
const result = await this.fileSystemService.readFilePath(filePath);

View file

@ -297,7 +297,7 @@ export class ConversationFileSystemService {
}
}
file.setStoragePath(filePath.replace(`${Path.Conversations}/`, ''));
file.setStoragePath(filePath.replace(`${Path.Conversations}/`, ""));
}
private async loadBinaryFile(storagePath: string): Promise<string> {

View file

@ -8,6 +8,7 @@ import type { Diff2HtmlUIConfig } from 'diff2html/lib/ui/js/diff2html-ui-base';
import { ColorSchemeType, OutputFormatType } from 'diff2html/lib/types';
import { Component } from 'obsidian';
import { AbortService } from './AbortService';
import type { Artifact } from 'Conversations/Artifact';
interface DiffResult {
accepted: boolean;
@ -35,6 +36,26 @@ export class DiffService extends Component {
}));
}
public showArtifactDiff(artifact: Artifact): void {
const diffString = this.createDiffString(artifact.filePath,
artifact.filePath, artifact.originalContent, artifact.updatedContent);
const outputFormat: OutputFormatType = "line-by-line";
const config: Diff2HtmlUIConfig = {
drawFileList: false,
matching: "words",
outputFormat: outputFormat,
highlight: false,
fileListToggle: false,
fileContentToggle: false,
synchronisedScroll: true,
colorScheme: ColorSchemeType.AUTO
};
void this.plugin.activateArtifactView(artifact, diffString, config);
}
public async requestDiff(oldFileName: string, newFileName: string, oldContent: string, newContent: string): Promise<DiffResult> {
const diffString = this.createDiffString(oldFileName, newFileName, oldContent, newContent);

View file

@ -93,6 +93,16 @@ body:not(.is-tablet) .workspace-drawer.mod-right {
overflow: hidden;
}
/* ============================== */
/* Artifact View Customization */
/* ============================== */
.workspace-leaf-content[data-type="vaultkeeper-ai-artifact-view"] .view-content {
container-type: size;
container-name: artifact-container;
overflow: hidden;
}
/* ============================== */
/* Settings Styles */
/* ============================== */

View file

@ -131,51 +131,35 @@
.diff-mobile-controls .diff-mobile-button {
min-height: 44px;
font-size: var(--font-ui-medium);
font-weight: var(--font-semibold);
border-radius: var(--button-radius);
cursor: pointer;
border-width: var(--size-2-1);
border-style: solid;
background-color: var(--background-primary);
transition: background-color 0.2s ease-out;
}
.diff-mobile-controls .diff-mobile-accept {
color: var(--color-green);
border-color: var(--color-green);
color: white;
background-color: #38533a;
}
.diff-mobile-controls .diff-mobile-accept:hover,
.diff-mobile-controls .diff-mobile-accept:focus,
.diff-mobile-controls .diff-mobile-accept:active {
background-color: color-mix(
in srgb,
var(--color-green) 25%,
white 10%
);
background-color: #537555;
}
.diff-mobile-controls .diff-mobile-discuss {
color: var(--interactive-accent);
border-color: var(--interactive-accent);
}
.diff-mobile-controls .diff-mobile-discuss:active {
background-color: color-mix(
in srgb,
var(--interactive-accent) 25%,
white 10%
);
color: white;
}
.diff-mobile-controls .diff-mobile-reject {
color: var(--color-red);
border-color: var(--color-red);
color: white;
background-color: #593030;
}
.diff-mobile-controls .diff-mobile-reject:hover,
.diff-mobile-controls .diff-mobile-reject:focus,
.diff-mobile-controls .diff-mobile-reject:active {
background-color: color-mix(
in srgb,
var(--color-red) 25%,
white 10%
);
background-color: #774545;
}
/* Adjust diff height on mobile to account for buttons */
@ -188,4 +172,74 @@
.diff-view-mobile .d2h-file-diff {
max-height: calc(100cqh - 37px - 120px);
}
}
/* ============================== */
/* Artifact View Controls */
/* (shown on desktop AND mobile) */
/* ============================== */
.artifact-view-controls {
display: flex;
justify-content: flex-end;
gap: var(--size-4-2);
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: var(--size-4-3);
z-index: 10;
background-color: var(--background-primary);
border-top: 1px solid var(--background-modifier-border);
}
.artifact-view-controls .artifact-view-button {
flex: 0 1 auto;
}
/* Avoid the floating obsidian controls on mobile */
@media (max-width: 600px) {
.artifact-view-controls {
margin-bottom: 85px;
}
.artifact-view-controls .artifact-view-button {
flex: 1 1 0;
min-width: 0;
}
}
.artifact-view-controls .artifact-view-button {
min-height: 44px;
font-size: var(--font-ui-medium);
border-radius: var(--button-radius);
cursor: pointer;
transition: background-color 0.2s ease-out;
}
.artifact-view-controls .artifact-view-button-confirming {
min-width: var(--artifact-view-button-width);
color: white;
background-color: #38533a;
}
.artifact-view-controls .artifact-view-button-confirming:hover,
.artifact-view-controls .artifact-view-button-confirming:focus-visible,
.artifact-view-controls .artifact-view-button-confirming:active {
background-color: #537555;
}
.artifact-view-controls .artifact-view-close {
color: var(--interactive-accent);
border-color: var(--interactive-accent);
}
.workspace-leaf-content[data-type="vaultkeeper-ai-artifact-view"] .d2h-file-diff {
max-height: calc(100cqh - 37px - 40px);
}
@media (max-width: 600px) {
.workspace-leaf-content[data-type="vaultkeeper-ai-artifact-view"] .d2h-file-diff {
max-height: calc(100cqh - 37px - 120px);
}
}

View file

@ -40,51 +40,35 @@
.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-width: var(--size-2-1);
border-style: solid;
background-color: var(--background-primary);
transition: background-color 0.2s ease-out;
}
.plan-approval-mobile-controls .plan-approval-mobile-approve {
color: var(--color-green);
border-color: var(--color-green);
color: white;
background-color: #38533a;
}
.plan-approval-mobile-controls .plan-approval-mobile-approve:hover,
.plan-approval-mobile-controls .plan-approval-mobile-approve:focus,
.plan-approval-mobile-controls .plan-approval-mobile-approve:active {
background-color: color-mix(
in srgb,
var(--color-green) 25%,
white 10%
);
background-color: #537555;
}
.plan-approval-mobile-controls .plan-approval-mobile-discuss {
color: var(--interactive-accent);
border-color: var(--interactive-accent);
}
.plan-approval-mobile-controls .plan-approval-mobile-discuss:active {
background-color: color-mix(
in srgb,
var(--interactive-accent) 25%,
white 10%
);
color: white;
}
.plan-approval-mobile-controls .plan-approval-mobile-reject {
color: var(--color-red);
border-color: var(--color-red);
color: white;
background-color: #593030;
}
.plan-approval-mobile-controls .plan-approval-mobile-reject:hover,
.plan-approval-mobile-controls .plan-approval-mobile-reject:focus,
.plan-approval-mobile-controls .plan-approval-mobile-reject:active {
background-color: color-mix(
in srgb,
var(--color-red) 25%,
white 10%
);
background-color: #774545;
}
/* Adjust plan height on mobile to account for buttons */

209
Views/ArtifactView.ts Normal file
View file

@ -0,0 +1,209 @@
import { Diff2HtmlUI, type Diff2HtmlUIConfig } from "diff2html/lib/ui/js/diff2html-ui-base";
import { base64ToArrayBuffer, ItemView, WorkspaceLeaf, type ViewStateResult } from "obsidian";
import type { Artifact } from "Conversations/Artifact";
import { Copy } from "Enums/Copy";
import { ArtifactAction } from "Enums/ArtifactAction";
import type { FileSystemService } from "Services/FileSystemService";
import { Resolve } from "Services/DependencyService";
import { Services } from "Services/Services";
import type { WorkSpaceService } from "Services/WorkSpaceService";
import { isDocumentMimeType, toMimeType } from "Enums/MimeType";
export const VIEW_TYPE_ARTIFACT = 'vaultkeeper-ai-artifact-view';
interface ArtifactViewState {
artifact: Artifact;
diffString: string;
config: Diff2HtmlUIConfig;
}
const CONFIRM_TIMEOUT_MS = 3000;
export class ArtifactView extends ItemView {
private readonly fileSystemService: FileSystemService;
private readonly workSpaceService: WorkSpaceService;
private artifact: Artifact | undefined;
private diffString: string = "";
private config: Diff2HtmlUIConfig = {};
private diffContainer: HTMLElement | null = null;
private buttonsContainer: HTMLElement | null = null;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
this.fileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
this.workSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
}
public getViewType(): string {
return VIEW_TYPE_ARTIFACT;
}
public getDisplayText(): string {
return "Vaultkeeper AI artifact viewer";
}
public async setState(state: ArtifactViewState, result: ViewStateResult): Promise<void> {
this.artifact = state.artifact;
this.diffString = state.diffString;
this.config = state.config;
this.renderDiff();
return super.setState(state, result);
}
public getState(): Record<string, unknown> {
return {
artifact: this.artifact,
diffString: this.diffString,
config: this.config
};
}
private renderDiff() {
if (!this.artifact) {
return;
}
const container = this.resetContainer();
this.diffContainer = container.createDiv({ cls: 'd2h-wrapper' });
const diff2htmlUi = new Diff2HtmlUI(this.diffContainer, this.diffString, this.config);
diff2htmlUi.draw();
const buttonsContainer = this.createButtons();
if (buttonsContainer) {
this.buttonsContainer = buttonsContainer;
}
window.requestAnimationFrame(() => {
const firstChange = this.diffContainer?.querySelector('.d2h-change, .d2h-ins, .d2h-del');
firstChange?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
private resetContainer(): HTMLElement {
const container = this.contentEl;
container.empty();
if (this.diffContainer) {
this.diffContainer.remove();
this.diffContainer = null;
}
if (this.buttonsContainer) {
this.buttonsContainer.remove();
this.buttonsContainer = null;
}
return container;
}
private createButtons(): HTMLElement | undefined {
if (!this.artifact) {
return;
}
const container = this.contentEl.createDiv({ cls: 'artifact-view-controls' });
// If a file was modified then we have the option of restoring original content or the updated content
// If the operation was create / delete then we only restore updated or original respectively
// Binary / office file formats cannot be modified or created so we only ever restore the original for these
// We only need the restore previous button if the action was a modify
if (this.artifact.action === ArtifactAction.Modify) {
const restorePreviousButton = container.createEl('button', {
cls: 'artifact-view-button',
text: Copy.ButtonRestorePrevious
});
this.registerConfirmButton(restorePreviousButton, Copy.ButtonRestorePrevious, async () => {
if (!this.artifact) {
return;
}
await this.fileSystemService.writeToFilePath(this.artifact.filePath, this.artifact.originalContent, false, false);
await this.closeArtifactView();
});
}
const restoreButton = container.createEl('button', {
cls: 'artifact-view-button',
text: Copy.ButtonRestore
});
this.registerConfirmButton(restoreButton, Copy.ButtonRestore, async () => {
if (!this.artifact) {
return;
}
if (this.artifact.base64) {
const arrayBuffer = base64ToArrayBuffer(this.artifact.base64);
await this.fileSystemService.writeBinaryFile(this.artifact.filePath, arrayBuffer, false);
await this.closeArtifactView();
return;
}
// If the action is delete then we restore original otherwise restore updated
const restoreOriginal = this.artifact.action === ArtifactAction.Delete;
await this.fileSystemService.writeToFilePath(this.artifact.filePath, restoreOriginal
? this.artifact.originalContent : this.artifact.updatedContent, false, false);
await this.closeArtifactView();
});
return container;
}
private registerConfirmButton(button: HTMLButtonElement, defaultLabel: string, onConfirm: () => Promise<void>): void {
button.setAttribute('aria-label', defaultLabel);
let resetTimeoutId: number | undefined;
let awaitingConfirmation = false;
const reset = () => {
awaitingConfirmation = false;
button.setText(defaultLabel);
button.setAttribute('aria-label', defaultLabel);
button.removeClass('artifact-view-button-confirming');
if (resetTimeoutId !== undefined) {
window.clearTimeout(resetTimeoutId);
resetTimeoutId = undefined;
}
};
this.registerDomEvent(button, 'click', async () => {
if (!awaitingConfirmation) {
awaitingConfirmation = true;
button.setCssProps({ '--artifact-view-button-width': `${button.offsetWidth}px` });
button.addClass('artifact-view-button-confirming');
button.setText(Copy.ButtonConfirm);
button.setAttribute('aria-label', Copy.ButtonConfirm);
button.blur();
resetTimeoutId = window.setTimeout(reset, CONFIRM_TIMEOUT_MS);
return;
}
reset();
await onConfirm();
});
this.register(reset);
}
private async closeArtifactView(): Promise<void> {
if (!this.artifact) {
return;
}
if (!isDocumentMimeType(toMimeType(this.artifact.mimeType))) {
await this.workSpaceService.openNoteByPath(this.artifact.filePath);
}
this.leaf.detach();
}
}

View file

@ -49,7 +49,7 @@ export class DiffView extends ItemView {
}
public getDisplayText(): string {
return "Vaultkeeper AI diff";
return "Vaultkeeper AI diff viewer";
}
public async setState(state: DiffViewState, result: ViewStateResult): Promise<void> {

View file

@ -56,7 +56,7 @@ export class PlanApprovalView extends ItemView {
}
public getDisplayText(): string {
return Copy.PlanApprovalViewTitle;
return "Vaultkeeper AI plan";
}
public async setState(state: PlanApprovalViewState, result: ViewStateResult): Promise<void> {

31
main.ts
View file

@ -17,6 +17,8 @@ import "katex/dist/katex.min.css";
import 'highlight.js/styles/monokai.min.css';
import 'diff2html/bundles/css/diff2html.min.css';
import type { AssetsService } from "Services/AssetsService";
import type { Artifact } from "Conversations/Artifact";
import { ArtifactView, VIEW_TYPE_ARTIFACT } from "Views/ArtifactView";
export default class VaultkeeperAIPlugin extends Plugin {
@ -32,6 +34,10 @@ export default class VaultkeeperAIPlugin extends Plugin {
VIEW_TYPE_DIFF,
(leaf) => new DiffView(leaf)
);
this.registerView(
VIEW_TYPE_ARTIFACT,
(leaf) => new ArtifactView(leaf)
);
this.registerView(
VIEW_TYPE_PLAN_APPROVAL,
(leaf) => new PlanApprovalView(leaf)
@ -53,6 +59,7 @@ export default class VaultkeeperAIPlugin extends Plugin {
this.addSettingTab(new VaultkeeperAISettingTab());
this.app.workspace.onLayoutReady(async () => {
this.closeStaleViews();
await this.setup();
});
}
@ -113,6 +120,30 @@ export default class VaultkeeperAIPlugin extends Plugin {
}
}
public async activateArtifactView(artifact: Artifact, diffString: string, config: Diff2HtmlUIConfig) {
const { workspace } = this.app;
const leaves = workspace.getLeavesOfType(VIEW_TYPE_ARTIFACT);
const leaf = leaves.length > 0 ? leaves[0] : workspace.getLeaf("tab");
await leaf?.setViewState({
type: VIEW_TYPE_ARTIFACT,
active: true,
state: { artifact, diffString, config }
});
if (leaf != null) {
await workspace.revealLeaf(leaf);
}
}
private closeStaleViews() {
const { workspace } = this.app;
workspace.getLeavesOfType(VIEW_TYPE_DIFF).forEach(leaf => leaf.detach());
workspace.getLeavesOfType(VIEW_TYPE_PLAN_APPROVAL).forEach(leaf => leaf.detach());
}
// create example user instruction (on first launch only)
private async setup() {
const settingsService = Resolve<SettingsService>(Services.SettingsService);