diff --git a/Components/AssistantMessage.svelte b/Components/AssistantMessage.svelte index 09a0081..e11eeda 100644 --- a/Components/AssistantMessage.svelte +++ b/Components/AssistantMessage.svelte @@ -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(Services.StreamingMarkdownService); + let diffService: DiffService = Resolve(Services.DiffService); function messageRenderAction(element: HTMLElement, message: ConversationContent) { streamingMarkdownService.render(message.getDisplayContent(), element); @@ -47,6 +50,10 @@ } }; } + + function handleArtifactCardClick(artifact: Artifact) { + diffService.showArtifactDiff(artifact); + }
@@ -74,11 +81,15 @@
handleArtifactCardClick(artifact)} + on:keydown={(e) => e.key === 'Enter' && handleArtifactCardClick(artifact)} + role="button" + tabindex="0" >
{basename(artifact.filePath)} {artifactActionToCopy(artifact.action)} @@ -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); } diff --git a/Components/ChatInput.svelte b/Components/ChatInput.svelte index 2f3847f..72f8629 100644 --- a/Components/ChatInput.svelte +++ b/Components/ChatInput.svelte @@ -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); } diff --git a/Components/DiffControls.svelte b/Components/DiffControls.svelte index 653c358..4fd96a2 100644 --- a/Components/DiffControls.svelte +++ b/Components/DiffControls.svelte @@ -45,68 +45,45 @@ \ No newline at end of file diff --git a/Components/PlanApprovalControls.svelte b/Components/PlanApprovalControls.svelte index 4b741b3..b37720c 100644 --- a/Components/PlanApprovalControls.svelte +++ b/Components/PlanApprovalControls.svelte @@ -46,68 +46,45 @@ diff --git a/Enums/Copy.ts b/Enums/Copy.ts index be45e7d..982b842 100644 --- a/Enums/Copy.ts +++ b/Enums/Copy.ts @@ -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.`, diff --git a/Services/AIServices/AIToolService.ts b/Services/AIServices/AIToolService.ts index 6e823a4..d56eb9b 100644 --- a/Services/AIServices/AIToolService.ts +++ b/Services/AIServices/AIToolService.ts @@ -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); diff --git a/Services/ConversationFileSystemService.ts b/Services/ConversationFileSystemService.ts index 281ef2f..8e7587f 100644 --- a/Services/ConversationFileSystemService.ts +++ b/Services/ConversationFileSystemService.ts @@ -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 { diff --git a/Services/DiffService.ts b/Services/DiffService.ts index 02299f5..3be2634 100644 --- a/Services/DiffService.ts +++ b/Services/DiffService.ts @@ -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 { const diffString = this.createDiffString(oldFileName, newFileName, oldContent, newContent); diff --git a/Styles/custom_styles.css b/Styles/custom_styles.css index 9457d11..363cb7e 100644 --- a/Styles/custom_styles.css +++ b/Styles/custom_styles.css @@ -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 */ /* ============================== */ diff --git a/Styles/diff2html_styles.css b/Styles/diff2html_styles.css index 6ddb3e7..22ce888 100644 --- a/Styles/diff2html_styles.css +++ b/Styles/diff2html_styles.css @@ -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); + } } \ No newline at end of file diff --git a/Styles/plan_approval_styles.css b/Styles/plan_approval_styles.css index 5d51975..31ca73d 100644 --- a/Styles/plan_approval_styles.css +++ b/Styles/plan_approval_styles.css @@ -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 */ diff --git a/Views/ArtifactView.ts b/Views/ArtifactView.ts new file mode 100644 index 0000000..f242a1e --- /dev/null +++ b/Views/ArtifactView.ts @@ -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(Services.FileSystemService); + this.workSpaceService = Resolve(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 { + this.artifact = state.artifact; + this.diffString = state.diffString; + this.config = state.config; + + this.renderDiff(); + + return super.setState(state, result); + } + + public getState(): Record { + 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 { + 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 { + if (!this.artifact) { + return; + } + + if (!isDocumentMimeType(toMimeType(this.artifact.mimeType))) { + await this.workSpaceService.openNoteByPath(this.artifact.filePath); + } + this.leaf.detach(); + } +} \ No newline at end of file diff --git a/Views/DiffView.ts b/Views/DiffView.ts index 4423ac6..3bedadd 100644 --- a/Views/DiffView.ts +++ b/Views/DiffView.ts @@ -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 { diff --git a/Views/PlanApprovalView.ts b/Views/PlanApprovalView.ts index af20e4e..ca7f013 100644 --- a/Views/PlanApprovalView.ts +++ b/Views/PlanApprovalView.ts @@ -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 { diff --git a/main.ts b/main.ts index 976b4b7..896b9ed 100644 --- a/main.ts +++ b/main.ts @@ -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(Services.SettingsService);