diff --git a/ui/src/styles/skill-workshop.css b/ui/src/styles/skill-workshop.css index 9bd9936d953..6b2c3b47284 100644 --- a/ui/src/styles/skill-workshop.css +++ b/ui/src/styles/skill-workshop.css @@ -525,297 +525,6 @@ color: rgba(255, 255, 255, 0.85); } -/* ── File preview modal (Spotlight / Option D vibe) ────────────────── */ -.sw-modal-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(6px); - z-index: 50; - animation: sw-fade 140ms ease-out; -} - -@keyframes sw-fade { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes sw-pop { - from { - transform: translate(-50%, -48%) scale(0.97); - opacity: 0; - } - to { - transform: translate(-50%, -50%) scale(1); - opacity: 1; - } -} - -.sw-modal { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: min(1100px, 92vw); - height: min(780px, 86vh); - background: var(--bg); - border: 1px solid var(--border-strong); - border-radius: var(--radius-lg); - box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6); - z-index: 51; - display: flex; - flex-direction: column; - overflow: hidden; - animation: sw-pop 160ms ease-out; -} - -.sw-modal__head { - display: flex; - align-items: center; - gap: 12px; - padding: 16px 20px; - border-bottom: 1px solid var(--border); - background: var(--bg); -} - -.sw-modal__search-icon { - color: var(--muted); - font-size: 18px; -} - -.sw-modal__search { - flex: 1; - background: transparent; - border: none; - outline: none; - color: var(--text-strong); - font: inherit; - font-size: 18px; - font-weight: 400; - padding: 4px 0; -} - -.sw-modal__search:focus, -.sw-modal__search:focus-visible { - outline: none; - border: none; - box-shadow: none; -} - -.sw-modal__search::placeholder { - color: var(--muted); -} - -.sw-modal__state { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 12px; - color: var(--muted); - padding: 5px 10px; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--bg-elevated); -} - -.sw-modal__esc { - font-family: var(--mono); - font-size: 10px; - padding: 1px 5px; - border-radius: 3px; - background: var(--bg); - border: 1px solid var(--border); - color: var(--muted); -} - -.sw-modal__body { - flex: 1; - display: grid; - grid-template-columns: 360px 1fr; - min-height: 0; -} - -.sw-modal__list { - border-right: 1px solid var(--border); - padding: 14px 10px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 2px; -} - -.sw-modal__list-section { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--muted); - padding: 4px 12px 8px; -} - -.sw-modal__item { - display: grid; - grid-template-columns: 10px 1fr auto; - gap: 12px; - align-items: center; - padding: 12px 14px; - border-radius: var(--radius-md); - border: none; - background: transparent; - color: var(--text); - cursor: pointer; - font: inherit; - text-align: left; -} - -.sw-modal__item:hover { - background: var(--bg-elevated); -} - -.sw-modal__item.is-active { - background: var(--accent-subtle); -} - -.sw-modal__item.is-active .sw-modal__item-name { - color: var(--text-strong); -} - -.sw-modal__item-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 6px color-mix(in srgb, var(--accent) 40%, transparent); -} - -.sw-modal__item-name { - font-family: var(--mono); - font-size: 14px; - color: var(--text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.sw-modal__item-meta { - color: var(--muted); - font-size: 12px; -} - -.sw-modal__empty { - color: var(--muted); - font-size: 13px; - padding: 12px; -} - -.sw-modal__detail { - display: flex; - flex-direction: column; - min-width: 0; - min-height: 0; -} - -.sw-modal__detail--empty { - align-items: center; - justify-content: center; - text-align: center; - padding: 24px; -} - -.sw-modal__detail-head { - padding: 20px 24px 14px; - border-bottom: 1px solid var(--border); -} - -.sw-modal__detail-title { - margin: 0 0 10px; - font-family: var(--mono); - font-size: 22px; - color: var(--text-strong); - font-weight: 700; - letter-spacing: -0.01em; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.sw-modal__chips { - display: flex; - gap: 6px; - flex-wrap: wrap; -} - -.sw-modal__chip { - display: inline-flex; - align-items: center; - padding: 3px 10px; - border-radius: 999px; - font-size: 11.5px; - background: var(--bg-elevated); - border: 1px solid var(--border); - color: var(--muted); -} - -.sw-modal__chip--accent { - background: var(--accent-subtle); - border-color: color-mix(in srgb, var(--accent) 30%, transparent); - color: var(--accent); -} - -.sw-modal__chip--ok { - background: color-mix(in srgb, var(--ok) 12%, transparent); - border-color: color-mix(in srgb, var(--ok) 30%, transparent); - color: var(--ok); -} - -.sw-modal__detail-body { - flex: 1; - overflow: auto; - padding: 20px 24px 24px; -} - -.sw-modal__pre { - margin: 0; - font-family: var(--mono); - font-size: 13px; - line-height: 1.7; - color: var(--text); - background: transparent; - border: none; - white-space: pre-wrap; - word-break: break-word; -} - -.sw-modal__foot { - display: flex; - align-items: center; - gap: 18px; - padding: 12px 20px; - border-top: 1px solid var(--border); - background: var(--bg); - font-size: 12px; - color: var(--muted); -} - -.sw-modal__foot-group { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.sw-modal__kbd { - font-family: var(--mono); - font-size: 10.5px; - padding: 2px 6px; - border-radius: 4px; - background: var(--bg-elevated); - border: 1px solid var(--border); - color: var(--text); -} - .sw-detail__meta-link { background: none; border: none; diff --git a/ui/src/ui/components/file-preview-modal.test.ts b/ui/src/ui/components/file-preview-modal.test.ts new file mode 100644 index 00000000000..9f1c3a71c90 --- /dev/null +++ b/ui/src/ui/components/file-preview-modal.test.ts @@ -0,0 +1,96 @@ +/* @vitest-environment jsdom */ + +import { html, nothing, render } from "lit"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawFilePreviewModal } from "./file-preview-modal.ts"; +import "./file-preview-modal.ts"; + +let container: HTMLDivElement; + +const files = [ + { + path: "templates/digest.md", + size: "2.1 KB", + contents: "Morning digest template", + }, + { + path: "filters/auto-senders.txt", + size: "418 B", + contents: "noreply@example.com", + }, +]; + +async function renderPreview(query = "") { + render( + html` + + `, + container, + ); + + const modal = container.querySelector("openclaw-file-preview-modal"); + expect(modal).toBeInstanceOf(HTMLElement); + if (!modal) { + throw new Error("expected file preview modal"); + } + await modal.updateComplete; + return modal; +} + +function shadowText(modal: OpenClawFilePreviewModal): string { + return modal.shadowRoot?.textContent ?? ""; +} + +describe("openclaw-file-preview-modal", () => { + beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + }); + + afterEach(() => { + render(nothing, container); + container.remove(); + vi.restoreAllMocks(); + }); + + it("filters files by path or contents", async () => { + const modal = await renderPreview("sender"); + + expect(shadowText(modal)).toContain("1/2 files"); + expect(shadowText(modal)).toContain("filters/auto-senders.txt"); + expect(shadowText(modal)).not.toContain("templates/digest.md"); + expect(shadowText(modal)).toContain("noreply@example.com"); + }); + + it("emits controlled query, select, and close events", async () => { + const modal = await renderPreview(); + const onQuery = vi.fn(); + const onSelect = vi.fn(); + const onClose = vi.fn(); + modal.addEventListener("file-preview-query-change", onQuery); + modal.addEventListener("file-preview-select", onSelect); + modal.addEventListener("file-preview-close", onClose); + + const input = modal.shadowRoot?.querySelector(".search"); + expect(input).toBeInstanceOf(HTMLInputElement); + input!.value = "digest"; + input!.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + + const secondFile = modal.shadowRoot?.querySelectorAll(".item")[1]; + expect(secondFile).toBeInstanceOf(HTMLButtonElement); + secondFile!.click(); + + modal.shadowRoot + ?.querySelector(".modal") + ?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + + expect(onQuery.mock.lastCall?.[0].detail).toBe("digest"); + expect(onSelect.mock.lastCall?.[0].detail).toBe("filters/auto-senders.txt"); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/src/ui/components/file-preview-modal.ts b/ui/src/ui/components/file-preview-modal.ts new file mode 100644 index 00000000000..2aef1309cdd --- /dev/null +++ b/ui/src/ui/components/file-preview-modal.ts @@ -0,0 +1,524 @@ +import { LitElement, css, html } from "lit"; +import { property } from "lit/decorators.js"; + +export type FilePreviewModalFile = { + path: string; + size: string; + contents: string; +}; + +export class OpenClawFilePreviewModal extends LitElement { + @property({ attribute: false }) files: FilePreviewModalFile[] = []; + @property() activePath = ""; + @property() query = ""; + @property() label = "Support files"; + @property() listLabel = "Files"; + @property() searchPlaceholder = "Search files..."; + @property() contextLabel = ""; + @property() readOnlyLabel = "read-only"; + @property() emptyTitle = "No files match"; + @property() emptySubtitle = "Try another file name or content search."; + + static override styles = css` + :host { + position: fixed; + inset: 0; + z-index: 50; + display: block; + } + + .backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(6px); + animation: fade 140ms ease-out; + } + + @keyframes fade { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes pop { + from { + transform: translate(-50%, -48%) scale(0.97); + opacity: 0; + } + to { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + } + } + + .modal { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(1100px, 92vw); + height: min(780px, 86vh); + background: var(--bg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6); + display: flex; + flex-direction: column; + overflow: hidden; + animation: pop 160ms ease-out; + } + + .head { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid var(--border); + background: var(--bg); + } + + .search-icon { + color: var(--muted); + font-size: 18px; + } + + .search { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--text-strong); + font: inherit; + font-size: 18px; + font-weight: 400; + padding: 4px 0; + } + + .search:focus, + .search:focus-visible { + outline: none; + border: none; + box-shadow: none; + } + + .search::placeholder { + color: var(--muted); + } + + .state { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--muted); + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + } + + .esc, + .kbd { + font-family: var(--mono); + border: 1px solid var(--border); + color: var(--muted); + } + + .esc { + font-size: 10px; + padding: 1px 5px; + border-radius: 3px; + background: var(--bg); + } + + .body { + flex: 1; + display: grid; + grid-template-columns: 360px 1fr; + min-height: 0; + } + + .list { + border-right: 1px solid var(--border); + padding: 14px 10px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; + } + + .list-section { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + padding: 4px 12px 8px; + } + + .item { + display: grid; + grid-template-columns: 10px 1fr auto; + gap: 12px; + align-items: center; + padding: 12px 14px; + border-radius: var(--radius-md); + border: none; + background: transparent; + color: var(--text); + cursor: pointer; + font: inherit; + text-align: left; + } + + .item:hover { + background: var(--bg-elevated); + } + + .item.is-active { + background: var(--accent-subtle); + } + + .item.is-active .item-name { + color: var(--text-strong); + } + + .item-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 6px color-mix(in srgb, var(--accent) 40%, transparent); + } + + .item-name { + font-family: var(--mono); + font-size: 14px; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .item-meta { + color: var(--muted); + font-size: 12px; + } + + .empty-list { + color: var(--muted); + font-size: 13px; + padding: 12px; + } + + .detail { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + } + + .detail.empty { + align-items: center; + justify-content: center; + text-align: center; + padding: 24px; + } + + .detail-head { + padding: 20px 24px 14px; + border-bottom: 1px solid var(--border); + } + + .title { + margin: 0 0 10px; + font-family: var(--mono); + font-size: 22px; + color: var(--text-strong); + font-weight: 700; + letter-spacing: -0.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .chips { + display: flex; + gap: 6px; + flex-wrap: wrap; + } + + .chip { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 11.5px; + background: var(--bg-elevated); + border: 1px solid var(--border); + color: var(--muted); + } + + .chip.accent { + background: var(--accent-subtle); + border-color: color-mix(in srgb, var(--accent) 30%, transparent); + color: var(--accent); + } + + .chip.ok { + background: color-mix(in srgb, var(--ok) 12%, transparent); + border-color: color-mix(in srgb, var(--ok) 30%, transparent); + color: var(--ok); + } + + .detail-body { + flex: 1; + overflow: auto; + padding: 20px 24px 24px; + } + + .pre { + margin: 0; + font-family: var(--mono); + font-size: 13px; + line-height: 1.7; + color: var(--text); + background: transparent; + border: none; + white-space: pre-wrap; + word-break: break-word; + } + + .foot { + display: flex; + align-items: center; + gap: 18px; + padding: 12px 20px; + border-top: 1px solid var(--border); + background: var(--bg); + font-size: 12px; + color: var(--muted); + } + + .foot-group { + display: inline-flex; + align-items: center; + gap: 6px; + } + + .kbd { + font-size: 10.5px; + padding: 2px 6px; + border-radius: 4px; + background: var(--bg-elevated); + color: var(--text); + } + + .spacer { + flex: 1; + } + + .button { + height: 36px; + padding: 0 14px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: var(--bg-elevated); + color: var(--text); + font-weight: 600; + cursor: pointer; + } + + .button:hover { + border-color: var(--border-strong); + color: var(--text-strong); + } + + .empty-title { + font-size: 16px; + font-weight: 600; + color: var(--text-strong); + margin: 0 0 8px; + } + + .empty-subtitle { + margin: 0; + font-size: 13px; + color: var(--muted); + max-width: 380px; + } + `; + + override render() { + const filteredFiles = this.filterFiles(); + const activeFile = this.resolveActiveFile(filteredFiles); + const fileCount = + filteredFiles.length === this.files.length + ? `${this.files.length} files` + : `${filteredFiles.length}/${this.files.length} files`; + + return html` +
+ + `; + } + + private renderFile(file: FilePreviewModalFile) { + return html` +
+
+

${file.path}

+
+ ${fileKind(file.path)} + ${file.size} + ${this.readOnlyLabel} + ${this.contextLabel ? html`${this.contextLabel}` : ""} +
+
+
+
${file.contents}
+
+
+ `; + } + + private renderEmpty() { + return html` +
+

${this.emptyTitle}

+

${this.emptySubtitle}

+
+ `; + } + + private filterFiles(): FilePreviewModalFile[] { + const query = this.query.trim().toLowerCase(); + if (!query) { + return this.files; + } + return this.files.filter((file) => { + const haystack = `${file.path}\n${file.contents}`.toLowerCase(); + return haystack.includes(query); + }); + } + + private resolveActiveFile(files: FilePreviewModalFile[]): FilePreviewModalFile | undefined { + return files.find((file) => file.path === this.activePath) ?? files[0]; + } + + private handleQueryInput = (event: Event) => { + const query = (event.target as HTMLInputElement).value ?? ""; + this.dispatchEvent( + new CustomEvent("file-preview-query-change", { + bubbles: true, + composed: true, + detail: query, + }), + ); + }; + + private handleKeydown = (event: KeyboardEvent) => { + if (event.key !== "Escape") { + return; + } + event.preventDefault(); + event.stopPropagation(); + this.emitClose(); + }; + + private emitSelect(path: string) { + this.dispatchEvent( + new CustomEvent("file-preview-select", { + bubbles: true, + composed: true, + detail: path, + }), + ); + } + + private emitClose = () => { + this.dispatchEvent( + new CustomEvent("file-preview-close", { + bubbles: true, + composed: true, + }), + ); + }; +} + +function fileKind(path: string): string { + const ext = path.split(".").pop()?.toLowerCase() ?? ""; + const map: Record = { + md: "Markdown", + txt: "Text", + json: "JSON", + yaml: "YAML", + yml: "YAML", + ts: "TypeScript", + js: "JavaScript", + py: "Python", + sh: "Shell", + }; + return map[ext] ?? (ext ? ext.toUpperCase() : "File"); +} + +if (!customElements.get("openclaw-file-preview-modal")) { + customElements.define("openclaw-file-preview-modal", OpenClawFilePreviewModal); +} + +declare global { + interface HTMLElementTagNameMap { + "openclaw-file-preview-modal": OpenClawFilePreviewModal; + } +} diff --git a/ui/src/ui/views/skill-workshop.ts b/ui/src/ui/views/skill-workshop.ts index c5667b0bb5a..4015c422638 100644 --- a/ui/src/ui/views/skill-workshop.ts +++ b/ui/src/ui/views/skill-workshop.ts @@ -1,4 +1,5 @@ import { html, nothing } from "lit"; +import "../components/file-preview-modal.ts"; export type SkillWorkshopProposalStatus = | "pending" @@ -96,14 +97,19 @@ export function renderSkillWorkshop(props: SkillWorkshopProps) { ${preview && selected - ? renderFilePreview( - selected, - preview, - props.filePreviewQuery, - props.onFilePreviewQueryChange, - props.onClosePreview, - (path) => props.onPreviewFile(selected.key, path), - ) + ? html` + ) => + props.onFilePreviewQueryChange(event.detail)} + @file-preview-select=${(event: CustomEvent) => + props.onPreviewFile(selected.key, event.detail)} + @file-preview-close=${props.onClosePreview} + > + ` : nothing} `; } @@ -284,122 +290,6 @@ function renderEmpty() { `; } -function renderFilePreview( - proposal: SkillWorkshopProposal, - file: SkillWorkshopFile, - query: string, - onQueryChange: (query: string) => void, - onClose: () => void, - onSelect: (path: string) => void, -) { - const filteredFiles = filterSupportFiles(proposal.supportFiles, query); - const activeFile = filteredFiles.some((f) => f.path === file.path) ? file : filteredFiles[0]; - - return html` -
- - `; -} - -function filterSupportFiles(files: SkillWorkshopFile[], query: string): SkillWorkshopFile[] { - const q = query.trim().toLowerCase(); - if (!q) { - return files; - } - return files.filter((file) => { - const haystack = `${file.path}\n${file.contents}`.toLowerCase(); - return haystack.includes(q); - }); -} - -function fileKind(path: string): string { - const ext = path.split(".").pop()?.toLowerCase() ?? ""; - const map: Record = { - md: "Markdown", - txt: "Text", - json: "JSON", - yaml: "YAML", - yml: "YAML", - ts: "TypeScript", - js: "JavaScript", - py: "Python", - sh: "Shell", - }; - return map[ext] ?? (ext ? ext.toUpperCase() : "File"); -} - function renderProposalBody(body: string) { const lines = body.split("\n"); const out: unknown[] = [];