Operand

thee, sea, us.

gram: docs

> ./examples/vue/src/App.vue

<template>
<div class="preview-banner" role="status">
<span>This is a <strong>preview deployment</strong>. The released editor lives at</span>
<a href="https://docx-editor.dev" target="_blank" rel="noopener">docx-editor.dev ↗</a>
</div>
<div class="app">
<main class="main">
<DocxEditor
ref="editorRef"
:document-buffer="documentBuffer"
:document="currentDocument"
:show-toolbar="true"
:document-name="fileName"
:fonts="customFonts"
:watermark-presets="['SAMPLE', 'DEMO ONLY', 'PREVIEW', 'NOT FOR DISTRIBUTION']"
:i18n="editorLocale"
:color-mode="colorMode"
@change="handleDocumentChange"
@error="handleError"
@ready="handleReady"
@rename="(n: string) => (fileName = n)"
>
<template #title-bar-left>
<div class="title-bar-left-group">
<span class="switcher" role="tablist" aria-label="Adapter">
<a :href="reactHref" role="tab" :aria-selected="false" class="pill">React</a>
<a :href="vueHref" role="tab" :aria-selected="true" class="pill active">Vue</a>
</span>
<ExampleSwitcher current="Vue" />
</div>
</template>
<template #title-bar-right>
<div
class="theme-toggle"
role="radiogroup"
aria-label="Color theme"
@mousedown.stop
>
<button
type="button"
role="radio"
class="theme-toggle__opt"
:class="{ 'is-selected': colorMode === 'light' }"
:aria-checked="colorMode === 'light'"
title="Light mode"
@click="colorMode = 'light'"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
</button>
<button
type="button"
role="radio"
class="theme-toggle__opt"
:class="{ 'is-selected': colorMode === 'dark' }"
:aria-checked="colorMode === 'dark'"
title="Dark mode"
@click="colorMode = 'dark'"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
</button>
</div>
<label class="btn btn-primary">
<input
type="file"
accept=".docx"
@change="handleFileSelect"
class="file-input"
/>
Open
</label>
<button class="btn" @click="handleNew">New</button>
<button class="btn" @click="handleSave">Save</button>
<span v-if="status" class="status">{{ status }}</span>
</template>
</DocxEditor>
<AgentPanel
v-if="showAgentPanel"
:closed="agentClosed"
@close="agentClosed = true"
>
<div data-testid="agent-panel-content" class="agent-panel-body">
<AgentChatLog
:messages="messages"
:loading="loading"
:humanize-tool-name="getToolDisplayName"
:auto-scroll="true"
/>
<AgentComposer
v-model="input"
:disabled="loading"
@submit="sendMessage"
/>
</div>
</AgentPanel>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onBeforeUnmount, onMounted } from 'vue';
import { DocxEditor, type DocxEditorRef } from '@eigenpal/docx-editor-vue';
import { de as deLocale } from '@eigenpal/docx-editor-i18n';
import ExampleSwitcher from '../../shared/ExampleSwitcher.vue';
import { createEmptyDocument, findStartPosForParaId } from '@eigenpal/docx-editor-core';
import type { Document } from '@eigenpal/docx-editor-core/types/document';
import { setSuggestionMode } from '@eigenpal/docx-editor-core/prosemirror/plugins';
import {
acceptChangeById,
rejectChangeById,
acceptAllChanges,
rejectAllChanges,
addRowBelow,
deleteRow,
} from '@eigenpal/docx-editor-core/prosemirror/commands';
import type { Node as PMNode } from 'prosemirror-model';
const randomAuthorVue = `Docx Editor User ${Math.floor(Math.random() * 900) + 100}`;
import {
AgentPanel,
AgentChatLog,
AgentComposer,
type AgentMessage,
} from '@eigenpal/docx-editor-agents/vue';
import { getToolDisplayName } from '@eigenpal/docx-editor-agents/vue';
function extractDocumentText(value: unknown): string {
if (!value || typeof value !== 'object') return '';
const maybeText = (value as { text?: unknown }).text;
if (typeof maybeText === 'string') return maybeText;
return Object.values(value)
.map((child) =>
Array.isArray(child)
? child.map((item) => extractDocumentText(item)).join('')
: extractDocumentText(child)
)
.join('');
}
// Adapter switcher: parity preview (`build:parity`) serves both demos
// from the same origin under `/react/` + `/vue/`. In dev each adapter
// has its own port (5173 React, 5174 Vue) and `import.meta.env.DEV`
// is true, so we hop ports for the React link.
const reactHref = import.meta.env.DEV ? 'http://localhost:5173/' : '/react/';
const vueHref = import.meta.env.DEV ? 'http://localhost:5174/' : '/vue/';
const editorRef = ref<DocxEditorRef | null>(null);
const documentBuffer = ref<ArrayBuffer | null>(null);
const currentDocument = ref<Document | null>(null);
const fileName = ref('docx-editor-demo.docx');
const status = ref('');
const colorMode = ref<'light' | 'dark'>('light');
// E2E hook: ?customFonts=1 wires a custom-font registration against the
// bundled fixture so the Vue Playwright suite can verify the `fonts` prop.
const customFonts = computed(() => {
if (typeof window === 'undefined') return undefined;
const params = new URLSearchParams(window.location.search);
if (params.get('customFonts') !== '1') return undefined;
return [
{ family: 'E2E Custom Font', src: '/e2e-fixtures/inter-regular.woff2' },
{ family: 'E2E Custom Font', src: '/e2e-fixtures/inter-bold.woff2', weight: 700 },
];
});
// E2E hook: `?locale=de` mounts the editor with the German i18n pack so
// the Playwright suite can assert localized tooltips / context-menu text.
const editorLocale = computed(() => {
if (typeof window === 'undefined') return undefined;
const params = new URLSearchParams(window.location.search);
return params.get('locale') === 'de' ? deLocale : undefined;
});
// Agent panel — opt-in via `?agentPanel=1` like the React demo. Keeps the
// live preview clean and gives Playwright parity tests a stable toggle.
// `?agentTimeline=…` also opens the panel since the timeline only renders
// inside it.
const showAgentPanel = computed(() => {
if (typeof window === 'undefined') return false;
const params = new URLSearchParams(window.location.search);
if (params.get('agentPanel') === '1' || params.has('agentTimeline')) return true;
return import.meta.env.VITE_DOCX_EDITOR_AGENT_PANEL === '1';
});
// AgentTimeline fixture for E2E parity. Mirrors examples/vite App.tsx so a
// single Playwright spec can drive both adapters with the same query string.
const timelineFixture = computed<AgentMessage[] | null>(() => {
if (typeof window === 'undefined') return null;
const mode = new URLSearchParams(window.location.search).get('agentTimeline');
if (!mode) return null;
const isStreaming = mode === 'streaming';
if (mode === 'long') {
const calls: NonNullable<AgentMessage['toolCalls']> = [
{ id: 't1', name: 'read_document', status: 'done', result: '...' },
...Array.from({ length: 7 }, (_, i) => ({
id: `t${i + 2}`,
name: 'add_comment',
status: 'done' as const,
result: `Comment ${i + 1} added.`,
})),
];
return [
{ id: 'u1', role: 'user', text: 'Roast everything.' },
{
id: 'a1',
role: 'assistant',
text: 'Done — 7 comments.',
status: 'done',
toolCalls: calls,
},
];
}
return [
{ id: 'u1', role: 'user', text: 'Roast my doc.' },
{
id: 'a1',
role: 'assistant',
text: isStreaming ? '' : 'Done — left 3 comments.',
status: isStreaming ? 'streaming' : 'done',
toolCalls: [
{ id: 't1', name: 'read_document', status: 'done', result: '...' },
{ id: 't2', name: 'add_comment', status: 'done', result: 'Comment 1 added.' },
{
id: 't3',
name: 'add_comment',
status: isStreaming ? 'running' : 'done',
result: isStreaming ? undefined : 'Comment 2 added.',
},
],
},
];
});
const baseMessages = ref<AgentMessage[]>([]);
const messages = computed<AgentMessage[]>(() => timelineFixture.value ?? baseMessages.value);
const input = ref('');
const loading = ref(false);
const agentClosed = ref(false);
function sendMessage() {
const text = input.value.trim();
if (!text) return;
baseMessages.value.push({ id: `u-${Date.now()}`, role: 'user', text });
input.value = '';
// Stub assistant reply — the demo doesn't call a real model. Replace
// this with `useAgentBridge` + your transport in your own app.
loading.value = true;
setTimeout(() => {
baseMessages.value.push({
id: `a-${Date.now()}`,
role: 'assistant',
text: 'BYO chat goes here. Wire `useAgentBridge` + your transport to make this real.',
status: 'done',
});
loading.value = false;
}, 600);
}
onMounted(async () => {
const params = new URLSearchParams(window.location.search);
const isE2E =
params.get('e2e') === '1' ||
import.meta.env.MODE === 'test' ||
import.meta.env.VITE_DOCX_EDITOR_E2E === '1';
if (isE2E) {
window.__DOCX_EDITOR_E2E__ = {
// Raw body EditorView — lets specs build precise PM states (e.g. a line
// with mixed font sizes) without driving the toolbar UI.
getView: () => (editorRef.value?.getEditorRef() as any)?.getView?.() ?? null,
getPmStartForParaId: (paraId: string) => {
const state = (editorRef.value?.getEditorRef() as any)?.getState?.();
if (!state || !paraId) return null;
return findStartPosForParaId(state.doc, paraId);
},
getSelectionAnchor: () => {
const state = (editorRef.value?.getEditorRef() as any)?.getState?.();
return state?.selection.anchor ?? null;
},
getTextblockEndForParaId: (paraId: string) => {
const state = (editorRef.value?.getEditorRef() as any)?.getState?.();
if (!state || !paraId) return null;
const start = findStartPosForParaId(state.doc, paraId);
if (start == null) return null;
const node = state.doc.nodeAt(start);
return node?.isTextblock === true ? start + 1 + node.content.size : null;
},
getFirstTextblockParaId: () => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return null;
let found: string | null = null;
view.state.doc.descendants((node: any) => {
if (node.isTextblock && node.attrs?.paraId) {
found = String(node.attrs.paraId);
return false;
}
return true;
});
return found;
},
getLastTextblockParaId: () => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return null;
let found: string | null = null;
view.state.doc.descendants((node: any) => {
if (node.isTextblock && node.attrs?.paraId) found = String(node.attrs.paraId);
return true;
});
return found;
},
scrollToParaId: (paraId: string) => editorRef.value?.scrollToParaId(paraId) ?? false,
scrollToPosition: (pmPos: number) => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return;
view.dispatch(view.state.tr.setSelection(view.state.selection.constructor.near(view.state.doc.resolve(pmPos))));
},
getDocSize: () => {
const state = (editorRef.value?.getEditorRef() as any)?.getState?.();
return state?.doc.content.size ?? null;
},
highlightRange: (from: number, to: number) => {
editorRef.value?.highlightRange(from, to);
},
scrollToCommentId: (commentId: number) =>
editorRef.value?.scrollToCommentId(commentId) ?? false,
scrollToChangeId: (revisionId: number) =>
editorRef.value?.scrollToChangeId(revisionId) ?? false,
scrollToPage: (pageNumber: number) => {
document
.querySelector<HTMLElement>(`.paged-editor__page[data-page-number="${pageNumber}"]`)
?.scrollIntoView({ block: 'start' });
},
getTotalPages: () => editorRef.value?.getTotalPages() ?? 0,
getCurrentPage: () => editorRef.value?.getCurrentPage() ?? 0,
// Collect every paragraph paraId from the host-facing Document model
// (getDocument()), regardless of nesting — used to assert getDocument()
// stays in sync with PM paraIds (#746).
getDocumentParaIds: () => {
const ids: (string | null)[] = [];
JSON.stringify(editorRef.value?.getDocument() ?? null, (k, v) => {
if (k === 'paraId') ids.push(v as string | null);
return v;
});
return ids;
},
saveByteLength: async () => {
const buffer = await editorRef.value?.save();
return buffer?.byteLength ?? null;
},
agentAddComment: (opts) =>
editorRef.value?.addComment({
paraId: opts.paraId,
text: opts.text,
author: opts.author ?? 'E2E',
search: opts.search,
}) ?? null,
agentProposeChange: (opts) =>
editorRef.value?.proposeChange({
paraId: opts.paraId,
search: opts.search,
replaceWith: opts.replaceWith,
author: opts.author ?? 'E2E',
}) ?? false,
agentReplyComment: (commentId: number, text: string, author = 'E2E') =>
editorRef.value?.replyToComment(commentId, text, author) ?? null,
agentResolveComment: (commentId: number) => editorRef.value?.resolveComment(commentId),
agentFind: (query: string) => editorRef.value?.findInDocument(query) ?? [],
agentSelection: () => editorRef.value?.getSelectionInfo() ?? null,
agentGetCommentCount: () => editorRef.value?.getComments().length ?? 0,
agentOnContentChangeCount: 0,
agentOnSelectionChangeCount: 0,
agentSubscribeContentChange: () => {
const hook = window.__DOCX_EDITOR_E2E__;
if (!hook) return () => undefined;
return (
editorRef.value?.onContentChange(() => {
hook.agentOnContentChangeCount = (hook.agentOnContentChangeCount ?? 0) + 1;
}) ?? (() => undefined)
);
},
agentSubscribeSelectionChange: () => {
const hook = window.__DOCX_EDITOR_E2E__;
if (!hook) return () => undefined;
return (
editorRef.value?.onSelectionChange(() => {
hook.agentOnSelectionChangeCount = (hook.agentOnSelectionChangeCount ?? 0) + 1;
}) ?? (() => undefined)
);
},
agentApplyFormatting: (opts) => editorRef.value?.applyFormatting(opts) ?? false,
agentSetParagraphStyle: (opts) => editorRef.value?.setParagraphStyle(opts) ?? false,
agentGetPageContent: (pageNumber: number) => editorRef.value?.getPageContent(pageNumber) ?? null,
agentGetDocumentText: () => extractDocumentText(editorRef.value?.getDocument()),
// Tracked structural revisions (#614) — mirror of the React demo hooks
// so the same Playwright spec can run against this adapter.
setSuggestionMode: (active: boolean, authorOverride?: string) => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return false;
setSuggestionMode(active, view.state, view.dispatch, authorOverride ?? randomAuthorVue);
return true;
},
getParagraphRevisionAt: (index: number) => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return null;
let count = 0;
let out: { pPrIns: unknown; pPrDel: unknown } | null = null;
view.state.doc.descendants((node: PMNode) => {
if (out != null) return false;
if (node.type.name !== 'paragraph') return true;
if (count === index) {
out = {
pPrIns: (node.attrs as Record<string, unknown>).pPrIns ?? null,
pPrDel: (node.attrs as Record<string, unknown>).pPrDel ?? null,
};
return false;
}
count += 1;
return true;
});
return out;
},
acceptChangeById: (revisionId: number) => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return false;
return acceptChangeById(revisionId)(view.state, view.dispatch);
},
rejectChangeById: (revisionId: number) => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return false;
return rejectChangeById(revisionId)(view.state, view.dispatch);
},
acceptAllChanges: () => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return false;
return acceptAllChanges()(view.state, view.dispatch);
},
rejectAllChanges: () => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return false;
return rejectAllChanges()(view.state, view.dispatch);
},
getParagraphAttrs: (index: number) => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return null;
let count = 0;
let out: Record<string, unknown> | null = null;
view.state.doc.descendants((node: PMNode) => {
if (out != null) return false;
if (node.type.name !== 'paragraph') return true;
if (count === index) {
out = { ...node.attrs };
return false;
}
count += 1;
return true;
});
return out;
},
plantParagraphPropertyChange: (revisionId: number, prior: unknown) => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return false;
let firstParaPos: number | null = null;
let firstPara: PMNode | null = null;
view.state.doc.descendants((node: PMNode, pos: number) => {
if (firstParaPos != null) return false;
if (node.type.name === 'paragraph') {
firstParaPos = pos;
firstPara = node;
return false;
}
return true;
});
if (firstParaPos == null || firstPara == null) return false;
view.dispatch(
view.state.tr.setNodeMarkup(firstParaPos, undefined, {
...(firstPara as PMNode).attrs,
pPrChange: [
{
type: 'paragraphPropertyChange',
info: { id: revisionId, author: 'Jane', date: new Date().toISOString() },
previousFormatting: prior,
},
],
})
);
return true;
},
plantSimpleTable: () => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return false;
const { schema } = view.state;
const cellPara = schema.node('paragraph', {}, [schema.text('A')]);
const cell = schema.node('tableCell', { colspan: 1, rowspan: 1 }, [cellPara]);
const row = schema.node('tableRow', {}, [cell]);
const table = schema.node('table', {}, [row]);
view.dispatch(view.state.tr.replaceSelectionWith(table));
return true;
},
countTableRows: () => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return 0;
let count = 0;
let inFirstTable = false;
view.state.doc.descendants((node: PMNode) => {
if (node.type.name === 'table') {
if (inFirstTable) return false;
inFirstTable = true;
return true;
}
if (inFirstTable && node.type.name === 'tableRow') count += 1;
return false;
});
return count;
},
focusFirstTableCell: () => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return false;
let target: number | null = null;
view.state.doc.descendants((node: PMNode, pos: number) => {
if (target != null) return false;
if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
target = pos + 2;
return false;
}
return true;
});
if (target == null) return false;
// Use the constructor on the live selection to avoid a direct
// `prosemirror-state` dependency in the demo's package.json.
const SelectionCtor = (view.state.selection as any).constructor;
const tr = view.state.tr.setSelection(SelectionCtor.near(view.state.doc.resolve(target)));
view.dispatch(tr);
view.focus();
return true;
},
plantTableRowInsertion: (revisionId: number) => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return false;
let rowPos: number | null = null;
let rowNode: PMNode | null = null;
view.state.doc.descendants((node: PMNode, pos: number) => {
if (rowPos != null) return false;
if (node.type.name === 'tableRow') {
rowPos = pos;
rowNode = node;
return false;
}
return true;
});
if (rowPos == null || rowNode == null) return false;
view.dispatch(
view.state.tr.setNodeMarkup(rowPos, undefined, {
...(rowNode as PMNode).attrs,
trIns: { revisionId, author: 'Jane', date: new Date().toISOString() },
})
);
return true;
},
getFirstTableRowAttrs: () => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return null;
let out: Record<string, unknown> | null = null;
view.state.doc.descendants((node: PMNode) => {
if (out != null) return false;
if (node.type.name === 'tableRow') {
out = { ...node.attrs };
return false;
}
return true;
});
return out;
},
addRowBelow: () => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return false;
return addRowBelow(view.state, view.dispatch);
},
deleteCurrentRow: () => {
const view = (editorRef.value?.getEditorRef() as any)?.getView?.();
if (!view) return false;
return deleteRow(view.state, view.dispatch);
},
};
}
// Under E2E with ?empty=1, boot empty so tests get a deterministic,
// known starting document instead of racing the async fixture fetch.
// Mirrors the React demo's behavior in examples/vite/src/App.tsx.
if (isE2E && params.get('empty') === '1') {
currentDocument.value = createEmptyDocument();
fileName.value = 'Untitled.docx';
return;
}
try {
const res = await fetch(`${import.meta.env.BASE_URL}docx-editor-demo.docx`);
const buffer = await res.arrayBuffer();
documentBuffer.value = buffer;
fileName.value = 'docx-editor-demo.docx';
} catch {
currentDocument.value = createEmptyDocument();
fileName.value = 'Untitled.docx';
}
});
onBeforeUnmount(() => {
delete window.__DOCX_EDITOR_E2E__;
});
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
status.value = 'Loading...';
file
.arrayBuffer()
.then((buffer) => {
currentDocument.value = null;
documentBuffer.value = buffer;
fileName.value = file.name;
status.value = '';
})
.catch(() => {
status.value = 'Error loading file';
});
}
function handleNew() {
documentBuffer.value = null;
currentDocument.value = createEmptyDocument();
fileName.value = 'Untitled.docx';
status.value = '';
}
async function handleSave() {
if (!editorRef.value) return;
try {
status.value = 'Saving...';
const buffer = await editorRef.value.save();
if (buffer) {
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName.value || 'document.docx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
status.value = 'Saved!';
setTimeout(() => {
status.value = '';
}, 2000);
}
} catch {
status.value = 'Save failed';
}
}
function handleDocumentChange(_doc: Document) {
// no-op — could track dirty state here
}
function handleError(error: Error) {
console.error('Editor error:', error);
status.value = `Error: ${error.message}`;
}
function handleReady() {
console.log('Editor ready');
}
</script>
<style>
.preview-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 6px 16px;
background: #fef3c7;
color: #92400e;
border-bottom: 1px solid #fde68a;
font-size: 13px;
font-weight: 500;
}
.preview-banner a {
color: #92400e;
text-decoration: underline;
text-decoration-color: #fcd34d;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: #f8fafc;
}
.header {
display: flex;
align-items: center;
padding: 8px 16px;
gap: 12px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
}
.title-bar-left-group {
display: flex;
align-items: center;
gap: 8px;
}
.switcher {
display: inline-flex;
background: var(--doc-bg-subtle);
padding: 3px;
border-radius: 8px;
border: 1px solid var(--doc-border);
}
.pill {
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
color: var(--doc-text-muted);
text-decoration: none;
border-radius: 5px;
transition:
background 0.15s,
color 0.15s;
}
.pill.active {
background: var(--doc-surface);
color: var(--doc-text);
box-shadow: 0 1px 2px var(--doc-shadow-subtle);
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.title {
font-size: 14px;
font-weight: 600;
color: #0f172a;
margin: 0;
}
.header-center {
flex: 1;
display: flex;
justify-content: center;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.file-name {
font-size: 13px;
color: #64748b;
padding: 4px 10px;
background: #f1f5f9;
border-radius: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.btn {
padding: 6px 12px;
background: var(--doc-surface);
border: 1px solid var(--doc-border);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--doc-text);
white-space: nowrap;
}
/* Fumadocs-style segmented light/dark toggle (mirrors the React demo). */
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: 9999px;
border: 1px solid var(--doc-border);
background: var(--doc-bg-subtle);
}
.theme-toggle__opt {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 9999px;
cursor: pointer;
background: transparent;
color: var(--doc-text-subtle);
transition:
background 0.15s ease,
color 0.15s ease;
}
.theme-toggle__opt.is-selected {
background: var(--doc-surface);
color: var(--doc-text);
box-shadow: 0 1px 2px var(--doc-shadow-subtle);
}
.btn:hover {
background: var(--doc-bg-hover);
}
.btn-primary {
background: var(--doc-text);
color: var(--doc-on-primary);
border-color: var(--doc-text);
cursor: pointer;
}
.btn-primary:hover {
background: #1e293b;
}
.file-input {
display: none;
}
.status {
font-size: 12px;
color: var(--doc-text-muted);
padding: 4px 8px;
background: var(--doc-bg-subtle);
border-radius: 4px;
}
.main {
flex: 1;
display: flex;
overflow: hidden;
}
.agent-panel-body {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
</style>