Reduce Office to document ownership

Keep _office focused on document artifacts, Markdown sessions, LibreOffice-compatible file actions, and document persistence. Route binary document editing through explicit Desktop requests instead of cold-opening the live Desktop surface from artifact results.
This commit is contained in:
Alessandro 2026-05-07 00:15:15 +02:00
parent 76a282ba8f
commit 0c08fa65f3
14 changed files with 610 additions and 2679 deletions

View file

@ -1,7 +1,8 @@
from __future__ import annotations
from helpers.api import ApiHandler, Request
from plugins._office.helpers import document_store, libreoffice, libreoffice_desktop, markdown_sessions
from plugins._desktop.helpers import desktop_session
from plugins._office.helpers import document_store, libreoffice, markdown_sessions
class OfficeSession(ApiHandler):
@ -14,6 +15,7 @@ class OfficeSession(ApiHandler):
if action == "home":
return {"ok": True, "path": document_store.default_open_path(context_id)}
if action == "desktop":
# Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
return self._desktop()
if action == "close":
closed = document_store.close_session(
@ -58,30 +60,47 @@ class OfficeSession(ApiHandler):
if action == "renamed":
return self._renamed(input, context_id)
if action == "desktop_save":
# Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
return self._desktop_save(input)
if action == "desktop_sync":
# Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
return self._desktop_sync(input)
if action == "desktop_state":
# Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
return self._desktop_state(input)
if action == "desktop_shutdown":
# Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
return self._desktop_shutdown(input)
return {"ok": False, "error": f"Unsupported office session action: {action}"}
async def _open_document(self, doc: dict, input: dict, request: Request) -> dict:
mode = "edit" if str(input.get("mode") or "edit").lower() == "edit" else "view"
store_session = document_store.create_session(
doc["file_id"],
user_id=str(input.get("user_id") or "agent-zero-user"),
permission="write" if mode == "edit" else "read",
origin=self._origin(request),
)
if str(doc.get("extension") or "").lower() in libreoffice_desktop.OFFICIAL_EXTENSIONS:
desktop = libreoffice_desktop.get_manager().open(doc, refresh=input.get("refresh") is True)
if str(doc.get("extension") or "").lower() in desktop_session.OFFICIAL_EXTENSIONS:
if input.get("open_in_desktop") is not True:
return {
"ok": True,
"requires_desktop": True,
"file_id": doc["file_id"],
"title": doc["basename"],
"extension": doc["extension"],
"path": doc["path"],
"text": "",
"document": _public_doc(doc),
"version": document_store.item_version(doc),
"mode": mode,
}
store_session = document_store.create_session(
doc["file_id"],
user_id=str(input.get("user_id") or "agent-zero-user"),
permission="write" if mode == "edit" else "read",
origin=self._origin(request),
)
desktop = desktop_session.get_manager().open(doc, refresh=input.get("refresh") is True)
if not desktop.get("available"):
document_store.close_session(session_id=store_session["session_id"])
return {
"ok": False,
"error": desktop.get("error") or desktop.get("reason") or "Official LibreOffice desktop session is unavailable.",
"error": desktop.get("error") or desktop.get("reason") or "Desktop session is unavailable.",
"desktop": desktop,
"libreoffice": libreoffice.collect_status(),
}
@ -100,6 +119,12 @@ class OfficeSession(ApiHandler):
"store_session_id": store_session["session_id"],
"mode": mode,
}
store_session = document_store.create_session(
doc["file_id"],
user_id=str(input.get("user_id") or "agent-zero-user"),
permission="write" if mode == "edit" else "read",
origin=self._origin(request),
)
try:
editor = markdown_sessions.get_manager().open(doc, sid="")
except ValueError as exc:
@ -135,8 +160,8 @@ class OfficeSession(ApiHandler):
except Exception as exc:
return {"ok": False, "error": str(exc)}
desktop = None
if str(updated.get("extension") or "").lower() in libreoffice_desktop.OFFICIAL_EXTENSIONS:
desktop = libreoffice_desktop.get_manager().retarget_document(file_id, updated)
if str(updated.get("extension") or "").lower() in desktop_session.OFFICIAL_EXTENSIONS:
desktop = desktop_session.get_manager().retarget_document(file_id, updated)
return {
"ok": True,
"document": _public_doc(updated),
@ -146,7 +171,7 @@ class OfficeSession(ApiHandler):
}
def _desktop(self) -> dict:
desktop = libreoffice_desktop.get_manager().ensure_system_desktop()
desktop = desktop_session.get_manager().ensure_system_desktop()
if not desktop.get("available"):
return {
"ok": False,
@ -155,7 +180,7 @@ class OfficeSession(ApiHandler):
"libreoffice": libreoffice.collect_status(),
}
document = {
"file_id": libreoffice_desktop.SYSTEM_FILE_ID,
"file_id": desktop_session.SYSTEM_FILE_ID,
"path": desktop["path"],
"basename": desktop["title"],
"title": desktop["title"],
@ -167,7 +192,7 @@ class OfficeSession(ApiHandler):
"ok": True,
"session_id": desktop["session_id"],
"desktop_session_id": desktop["session_id"],
"file_id": libreoffice_desktop.SYSTEM_FILE_ID,
"file_id": desktop_session.SYSTEM_FILE_ID,
"title": desktop["title"],
"extension": "desktop",
"path": desktop["path"],
@ -183,24 +208,24 @@ class OfficeSession(ApiHandler):
session_id = str(input.get("desktop_session_id") or input.get("session_id") or "").strip()
if not session_id:
return {"ok": False, "error": "desktop_session_id is required."}
return libreoffice_desktop.get_manager().save(
return desktop_session.get_manager().save(
session_id,
file_id=str(input.get("file_id") or ""),
)
def _desktop_sync(self, input: dict) -> dict:
return libreoffice_desktop.get_manager().sync(
return desktop_session.get_manager().sync(
session_id=str(input.get("desktop_session_id") or input.get("session_id") or ""),
file_id=str(input.get("file_id") or ""),
)
def _desktop_state(self, input: dict) -> dict:
include_screenshot = bool(input.get("include_screenshot") is True)
return libreoffice_desktop.get_manager().state(include_screenshot=include_screenshot)
return desktop_session.get_manager().state(include_screenshot=include_screenshot)
def _desktop_shutdown(self, input: dict) -> dict:
save_first = input.get("save_first") is not False
return libreoffice_desktop.get_manager().shutdown_system_desktop(
return desktop_session.get_manager().shutdown_system_desktop(
save_first=save_first,
source=str(input.get("source") or "api"),
)

View file

@ -2,6 +2,9 @@ import {
createActionButton,
copyToClipboard,
} from "/components/messages/action-buttons/simple-action-buttons.js";
import { ensureModalOpen } from "/js/modals.js";
import { open as openSurface } from "/js/surfaces.js";
import { store as officeStore } from "/plugins/_office/webui/office-store.js";
function basename(path = "") {
const value = String(path || "").split("?")[0].split("#")[0];
@ -33,10 +36,8 @@ export function documentFromLog(args = {}, result = {}) {
};
}
export async function openOfficeCanvas(kvps = {}) {
const canvas = globalThis.Alpine?.store?.("rightCanvas")
|| (await import("/components/canvas/right-canvas-store.js")).store;
await canvas?.open?.("office", {
export async function openDocumentInDesktop(kvps = {}) {
await openSurface("desktop", {
path: kvps.path || "",
file_id: kvps.file_id || "",
refresh: true,
@ -44,6 +45,33 @@ export async function openOfficeCanvas(kvps = {}) {
});
}
export async function openDocumentArtifact(kvps = {}) {
if (usesDesktop(kvps)) {
await openDocumentInDesktop(kvps);
return;
}
await ensureModalOpen("/plugins/_office/webui/main.html");
await officeStore.openSession?.({
path: kvps.path || "",
file_id: kvps.file_id || "",
refresh: true,
source: "message-action",
});
}
function usesDesktop(doc = {}) {
const format = String(doc.format || doc.extension || "").toLowerCase();
return ["odt", "ods", "odp", "docx", "xlsx", "pptx"].includes(format);
}
function desktopActionLabel(doc = {}) {
const format = String(doc.format || doc.extension || "").toLowerCase();
if (["odt", "docx"].includes(format)) return "Edit in Writer";
if (["ods", "xlsx"].includes(format)) return "Edit in Calc";
if (["odp", "pptx"].includes(format)) return "Edit in Impress";
return "Open Document";
}
export function downloadDocument(doc = {}) {
const path = String(doc.path || "");
if (!path) return;
@ -59,7 +87,8 @@ export function buildDocumentFileActionButtons(document = {}) {
const hasTarget = Boolean(document?.path || document?.file_id);
const buttons = [];
if (hasTarget) {
buttons.push(createActionButton("dock_to_right", "Open in canvas", () => openOfficeCanvas(document)));
const icon = usesDesktop(document) ? "desktop_windows" : "article";
buttons.push(createActionButton(icon, desktopActionLabel(document), () => openDocumentArtifact(document)));
}
if (document?.path) {
buttons.push(

View file

@ -1,14 +1,18 @@
import { store as officeStore } from "/plugins/_office/webui/office-store.js";
import { ensureModalOpen } from "/js/modals.js";
import { open as openSurface } from "/js/surfaces.js";
const SYNC_WINDOW_MS = 10 * 60 * 1000;
const DESKTOP_OFFICE_FORMATS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx"]);
const DESKTOP_DOCUMENT_EXTENSIONS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx"]);
const syncedDocumentResults = new Set();
export default async function syncDocumentResultsIntoOpenCanvas(context) {
export default async function syncDocumentResultsIntoOpenOfficeModal(context) {
if (!context?.results?.length || context.historyEmpty) return;
for (const { args } of context.results) {
const payload = getDocumentPayload(args);
if (getToolName(payload) !== "document_artifact") continue;
if (!shouldSyncOpenOfficeCanvas(args, payload)) continue;
if (!shouldSyncOpenOfficeModal(args, payload)) continue;
const document = payload.document && typeof payload.document === "object" ? payload.document : {};
const path = payload.path || document.path || "";
@ -25,14 +29,16 @@ export default async function syncDocumentResultsIntoOpenCanvas(context) {
if (syncedDocumentResults.has(key)) continue;
syncedDocumentResults.add(key);
if (!isOfficeCanvasOrModalOpen() && shouldColdOpenOfficeCanvas(payload, document)) {
await openOfficeCanvasFromResult({ path, file_id: fileId });
if (shouldOpenDocumentUiFromResult(payload, document)) {
globalThis.setTimeout(() => {
void openDocumentUiFromResult({ path, file_id: fileId }, payload, document);
}, 0);
continue;
}
globalThis.setTimeout(async () => {
if (!isOfficeCanvasOrModalOpen()) return;
const office = globalThis.Alpine?.store?.("office");
if (!isOfficeModalOpen()) return;
const office = officeStore;
if (!office || isDirtySameDocument(office, { path, file_id: fileId })) return;
await office.openSession?.({
path,
@ -63,8 +69,14 @@ function pickPayloadFields(args = {}) {
"tool_name",
"action",
"canvas_surface",
"extension",
"file_id",
"format",
"open_canvas",
"open_document",
"open_desktop",
"open_in_canvas",
"open_in_desktop",
"path",
"version",
"last_modified",
@ -78,54 +90,62 @@ function getToolName(payload = {}) {
return String(payload._tool_name || payload.tool_name || "").trim();
}
function shouldSyncOpenOfficeCanvas(args = {}, payload = {}) {
function shouldSyncOpenOfficeModal(args = {}, payload = {}) {
if (!isFresh(args.timestamp, payload.last_modified || payload.document?.last_modified)) return false;
const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
return ["create", "open", "edit", "restore_version"].includes(action);
}
function shouldColdOpenOfficeCanvas(payload = {}, document = {}) {
const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
if (!["create", "open"].includes(action)) return false;
return DESKTOP_OFFICE_FORMATS.has(documentFormat(payload, document));
function shouldOpenDocumentUiFromResult(payload = {}, document = {}) {
if (!isExplicitDocumentUiRequest(payload)) return false;
return Boolean(documentExtension(payload, document));
}
async function openOfficeCanvasFromResult(document = {}) {
const canvas = globalThis.Alpine?.store?.("rightCanvas")
|| (await import("/components/canvas/right-canvas-store.js")).store;
await canvas?.open?.("office", {
path: document.path || "",
file_id: document.file_id || "",
function isExplicitDocumentUiRequest(payload = {}) {
const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
return action === "open"
|| truthy(payload.open_in_canvas)
|| truthy(payload.open_canvas)
|| truthy(payload.open_document)
|| truthy(payload.open_in_desktop)
|| truthy(payload.open_desktop);
}
async function openDocumentUiFromResult(target = {}, payload = {}, document = {}) {
if (isDesktopDocument(payload, document)) {
await openSurface("desktop", {
path: target.path || "",
file_id: target.file_id || "",
refresh: true,
source: "tool-result-open",
});
return;
}
await ensureModalOpen("/plugins/_office/webui/main.html");
await officeStore.openSession?.({
path: target.path || "",
file_id: target.file_id || "",
refresh: true,
source: "tool-result-sync",
source: "tool-result-open",
});
}
function documentFormat(payload = {}, document = {}) {
function isDesktopDocument(payload = {}, document = {}) {
return DESKTOP_DOCUMENT_EXTENSIONS.has(documentExtension(payload, document));
}
function documentExtension(payload = {}, document = {}) {
return String(
payload.format
|| payload.extension
|| document.extension
|| extensionOf(payload.path || document.path || ""),
).trim().toLowerCase().replace(/^\./, "");
|| document.format
|| "",
).toLowerCase();
}
function extensionOf(path = "") {
const name = String(path || "").split("?")[0].split("#")[0].split("/").filter(Boolean).pop() || "";
const index = name.lastIndexOf(".");
return index >= 0 ? name.slice(index + 1) : "";
}
function isOfficeCanvasAlreadyOpen() {
const canvas = globalThis.Alpine?.store?.("rightCanvas");
return Boolean(canvas?.isOpen && canvas?.activeSurfaceId === "office");
}
function isOfficeCanvasOrModalOpen() {
return Boolean(isOfficeCanvasAlreadyOpen() || isOfficeModalAlreadyOpen());
}
function isOfficeModalAlreadyOpen() {
function isOfficeModalOpen() {
return Boolean(
globalThis.isModalOpen?.("/plugins/_office/webui/main.html")
|| globalThis.isModalOpen?.("plugins/_office/webui/main.html")
@ -143,6 +163,13 @@ function isDirtySameDocument(office, document = {}) {
);
}
function truthy(value) {
if (value === true) return true;
if (value === false || value == null) return false;
if (typeof value === "number") return value !== 0;
return ["1", "true", "yes", "y", "on"].includes(String(value).trim().toLowerCase());
}
function isFresh(...timestamps) {
const now = Date.now();
for (const value of timestamps) {

View file

@ -141,9 +141,9 @@ def _refresh_open_editor_sessions(file_id: str) -> None:
# Direct artifact edits should never fail just because no canvas is open.
pass
try:
from plugins._office.helpers import libreoffice_desktop
from plugins._desktop.helpers import desktop_session
libreoffice_desktop.get_manager().refresh_document(file_id)
desktop_session.get_manager().refresh_document(file_id)
except Exception:
pass

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any
from plugins._office.helpers import desktop_state
from plugins._desktop.helpers import desktop_state
from plugins._office.helpers import document_store
@ -13,7 +13,7 @@ def build_context(max_items: int = 6) -> str:
return desktop_context
lines = [
"These document artifacts have active canvas sessions. Content is omitted; load skill `office-artifacts` for edit workflow, then use document_artifact:read before content-sensitive edits.",
"These document artifacts have active document sessions. Content is omitted; load skill `document-artifacts` for edit workflow, then use document_artifact:read before content-sensitive edits.",
]
for doc in documents:
lines.append(format_document_line(doc))
@ -46,5 +46,5 @@ def build_desktop_context() -> str:
return (
"[DESKTOP STATE]\n"
f"- unavailable={exc}\n"
"- next=Open the Desktop canvas manually, then run plugins/_office/skills/linux-desktop/scripts/desktopctl.sh observe --json."
"- next=Open the Desktop surface manually, then run plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh observe --json."
)

View file

@ -40,7 +40,7 @@ ODF_MIMETYPES = {
"odp": "application/vnd.oasis.opendocument.presentation",
}
STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "documents"))
STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME, "documents"))
DB_PATH = STATE_DIR / "documents.sqlite3"
BACKUP_DIR = STATE_DIR / "backups"
WORKDIR = Path(files.get_abs_path("usr", "workdir"))

View file

@ -36,9 +36,9 @@ def collect_status() -> dict[str, Any]:
"message": "LibreOffice is available." if soffice else "LibreOffice is not installed in this runtime.",
}
try:
from plugins._office.helpers import libreoffice_desktop
from plugins._desktop.helpers import desktop_session
status["desktop"] = libreoffice_desktop.collect_desktop_status()
status["desktop"] = desktop_session.collect_desktop_status()
except Exception as exc:
status["desktop"] = {"ok": False, "healthy": False, "error": str(exc)}
return status

View file

@ -1,6 +1,6 @@
name: _office
title: LibreOffice
description: Markdown writing and ODF-first LibreOffice document artifacts in the right canvas.
description: Markdown writing and ODF-first LibreOffice document artifacts.
version: "0.1"
settings_sections:
- developer

View file

@ -1,15 +1,17 @@
### document_artifact
create/open/read/edit reusable document artifacts in the Agent Zero canvas
create/open/read/edit reusable document artifacts in Agent Zero
formats: md odt ods odp docx xlsx pptx
default format: md
methods: create open read edit inspect export version_history restore_version status
common args: method action kind title format content path file_id
optional UI intent args: open_in_canvas open_in_desktop
`method` is accepted as an alias for action when the tool_name has no suffix
tool results save or update artifacts only; they do not open the canvas automatically
created/updated artifacts are shown with explicit Download and Open in canvas message actions
ODF is first-class for LibreOffice: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress unless the user explicitly requests Microsoft compatibility
create/read/edit results save or update artifacts only; they do not open a surface automatically unless the user explicitly asks to open the document UI
use action `open`, `open_in_canvas: true`, or `open_in_desktop: true` only when the user explicitly asks to open the document/editor/Desktop
created/updated artifacts are shown with explicit Download, Open Document, or Desktop edit message actions
ODF is first-class for LibreOffice: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress unless the user explicitly requests OOXML compatibility
DOCX/XLSX/PPTX are compatibility formats, not defaults
XLSX charts: use edit operation `create_chart` with `chart` object instead of code execution for embedded spreadsheet charts when an embedded chart is required
chart types: line bar column pie area scatter stock ohlc candlestick
ODS/XLSX create/edit tabular content: CSV, TSV, Markdown tables, or rows arrays become real spreadsheet cells
for nontrivial document artifact work, load skill `office-artifacts` or the specific Markdown/Writer/Calc/Impress skill first
for nontrivial document artifact work, load skill `document-artifacts` or the specific Markdown/Writer/Calc/Impress skill first

View file

@ -28,10 +28,22 @@ class DocumentArtifact(Tool):
chart: Any = None,
slides: Any = None,
max_chars: int | str = 12000,
open_in_canvas: bool = False,
open_in_desktop: bool = False,
method: str = "",
**kwargs: Any,
) -> Response:
action = str(action or method or self.method or "status").strip().lower().replace("-", "_")
open_in_canvas = _truthy(
open_in_canvas
or kwargs.get("open_canvas")
or kwargs.get("open_document")
)
open_in_desktop = _truthy(
open_in_desktop
or kwargs.get("open_desktop")
or kwargs.get("desktop")
)
try:
if action == "create":
doc = document_store.create_document(
@ -56,10 +68,22 @@ class DocumentArtifact(Tool):
message=f"document_artifact create failed: {validation.get('error')}",
break_loop=False,
)
return self._document_response("Created document artifact.", doc, action=action)
return self._document_response(
"Created document artifact.",
doc,
action=action,
open_in_canvas=open_in_canvas,
open_in_desktop=open_in_desktop,
)
if action == "open":
doc = self._document_from_input(file_id=file_id, path=path)
return self._document_response("Opened document artifact.", doc, action=action)
return self._document_response(
"Opened document artifact.",
doc,
action=action,
open_in_canvas=open_in_canvas,
open_in_desktop=open_in_desktop,
)
if action in {"read", "extract"}:
doc = self._document_from_input(file_id=file_id, path=path)
payload = {
@ -68,7 +92,13 @@ class DocumentArtifact(Tool):
"document": self._public_doc(doc),
"content": artifact_editor.read_artifact(doc, max_chars=int(max_chars or 12000)),
}
return self._json_response(payload, doc=doc, action="read")
return self._json_response(
payload,
doc=doc,
action="read",
open_in_canvas=open_in_canvas,
open_in_desktop=open_in_desktop,
)
if action in {"edit", "update", "patch"}:
doc = self._document_from_input(file_id=file_id, path=path)
updated_doc, payload = artifact_editor.edit_artifact(
@ -85,20 +115,44 @@ class DocumentArtifact(Tool):
**kwargs,
)
payload["document"] = self._public_doc(updated_doc)
return self._json_response(payload, doc=updated_doc, action="edit")
return self._json_response(
payload,
doc=updated_doc,
action="edit",
open_in_canvas=open_in_canvas,
open_in_desktop=open_in_desktop,
)
if action == "inspect":
doc = self._document_from_input(file_id=file_id, path=path)
return self._json_response({"ok": True, "action": action, "document": self._public_doc(doc)}, doc=doc, action=action)
return self._json_response(
{"ok": True, "action": action, "document": self._public_doc(doc)},
doc=doc,
action=action,
open_in_canvas=open_in_canvas,
open_in_desktop=open_in_desktop,
)
if action == "version_history":
doc = self._document_from_input(file_id=file_id, path=path)
versions = document_store.version_history(doc["file_id"])
return self._json_response({"ok": True, "action": action, "versions": versions}, doc=doc, action=action)
return self._json_response(
{"ok": True, "action": action, "versions": versions},
doc=doc,
action=action,
open_in_canvas=open_in_canvas,
open_in_desktop=open_in_desktop,
)
if action == "restore_version":
if version_id is None or str(version_id).strip() == "":
return Response(message="version_id is required for restore_version.", break_loop=False)
doc = self._document_from_input(file_id=file_id, path=path)
restored = document_store.restore_version(doc["file_id"], int(version_id))
return self._document_response("Restored document artifact version.", restored, action=action)
return self._document_response(
"Restored document artifact version.",
restored,
action=action,
open_in_canvas=open_in_canvas,
open_in_desktop=open_in_desktop,
)
if action == "export":
doc = self._document_from_input(file_id=file_id, path=path)
target_format = str(kwargs.get("target_format") or kwargs.get("export_format") or "").lower().lstrip(".")
@ -111,13 +165,30 @@ class DocumentArtifact(Tool):
"path": document_store.display_path(result["path"]),
"document": self._public_doc(doc),
}
return self._json_response(payload, doc=doc, action=action)
return self._json_response(
payload,
doc=doc,
action=action,
open_in_canvas=open_in_canvas,
open_in_desktop=open_in_desktop,
)
return Response(
message=f"document_artifact export failed: {result.get('error')}",
break_loop=False,
additional=self._additional(doc, action=action),
additional=self._additional(
doc,
action=action,
open_in_canvas=open_in_canvas,
open_in_desktop=open_in_desktop,
),
)
return self._document_response("Document artifact export path is ready.", doc, action=action)
return self._document_response(
"Document artifact export path is ready.",
doc,
action=action,
open_in_canvas=open_in_canvas,
open_in_desktop=open_in_desktop,
)
if action == "status":
return self._json_response({"ok": True, "action": action, "status": libreoffice.collect_status()}, action=action)
return Response(message=f"Unknown document_artifact action: {action}", break_loop=False)
@ -143,28 +214,75 @@ class DocumentArtifact(Tool):
def _context_id(self) -> str:
return self.agent.context.id if self.agent and self.agent.context else ""
def _document_response(self, message: str, doc: dict[str, Any], action: str = "") -> Response:
def _document_response(
self,
message: str,
doc: dict[str, Any],
action: str = "",
*,
open_in_canvas: bool = False,
open_in_desktop: bool = False,
) -> Response:
payload = {"ok": True, "action": action, "message": message, "document": self._public_doc(doc)}
return Response(
message=json.dumps(payload, indent=2, ensure_ascii=False),
break_loop=False,
additional=self._additional(doc, action=action),
additional=self._additional(
doc,
action=action,
open_in_canvas=open_in_canvas,
open_in_desktop=open_in_desktop,
),
)
def _json_response(self, payload: dict[str, Any], doc: dict[str, Any] | None = None, action: str = "") -> Response:
def _json_response(
self,
payload: dict[str, Any],
doc: dict[str, Any] | None = None,
action: str = "",
*,
open_in_canvas: bool = False,
open_in_desktop: bool = False,
) -> Response:
return Response(
message=json.dumps(payload, indent=2, ensure_ascii=False, default=str),
break_loop=False,
additional=self._additional(doc, action=action) if doc else {"_tool_name": self.name, "canvas_surface": "office", "action": action},
additional=self._additional(
doc,
action=action,
open_in_canvas=open_in_canvas,
open_in_desktop=open_in_desktop,
) if doc else {
"_tool_name": self.name,
"canvas_surface": "office",
"action": action,
"open_in_canvas": bool(open_in_canvas),
"open_in_desktop": bool(open_in_desktop),
},
)
def _additional(self, doc: dict[str, Any] | None, action: str = "") -> dict[str, Any]:
def _additional(
self,
doc: dict[str, Any] | None,
action: str = "",
*,
open_in_canvas: bool = False,
open_in_desktop: bool = False,
) -> dict[str, Any]:
if not doc:
return {"_tool_name": self.name, "canvas_surface": "office", "action": action}
return {
"_tool_name": self.name,
"canvas_surface": "office",
"action": action,
"open_in_canvas": bool(open_in_canvas),
"open_in_desktop": bool(open_in_desktop),
}
return {
"_tool_name": self.name,
"canvas_surface": "office",
"action": action,
"open_in_canvas": bool(open_in_canvas),
"open_in_desktop": bool(open_in_desktop),
"file_id": doc["file_id"],
"title": doc["basename"],
"format": doc["extension"],
@ -183,3 +301,13 @@ class DocumentArtifact(Tool):
"last_modified": doc["last_modified"],
"exists": Path(doc["path"]).exists(),
}
def _truthy(value: Any) -> bool:
if isinstance(value, bool):
return value
if value is None:
return False
if isinstance(value, (int, float)):
return value != 0
return str(value).strip().lower() in {"1", "true", "yes", "y", "on"}

View file

@ -1,12 +1,6 @@
<html
class="office-modal modal-no-backdrop"
data-canvas-surface="office"
data-canvas-modal-path="/plugins/_office/webui/main.html"
data-canvas-dock-title="Open Desktop in canvas"
data-canvas-dock-icon="dock_to_right"
>
<html class="office-modal">
<head>
<title>Desktop</title>
<title>Documents</title>
<script type="module">
import { store } from "/plugins/_office/webui/office-store.js";
</script>

View file

@ -5,123 +5,130 @@
</script>
</head>
<body>
<div class="office-panel" x-data x-create="$store.office.onMount($el, xAttrs($el) || {})" x-destroy="$store.office.cleanup()">
<div x-data>
<template x-if="$store.office">
<div class="office-shell">
<div class="office-document-header" x-show="$store.office.hasActiveFile()" style="display: none;">
<div class="office-document-title" :title="$store.office.tabLabel($store.office.session)">
<span class="material-symbols-outlined office-document-icon" aria-hidden="true" x-text="$store.office.tabIcon($store.office.session)"></span>
<span class="office-document-name" x-text="$store.office.tabTitle($store.office.session)"></span>
<span class="office-document-dirty" x-show="$store.office.dirty" aria-hidden="true">*</span>
</div>
<div class="office-panel" x-create="$store.office.onMount($el, xAttrs($el) || {})" x-destroy="$store.office.cleanup()">
<div class="office-shell">
<div class="office-document-header" x-show="$store.office.hasActiveFile()" style="display: none;">
<div class="office-document-title" :title="$store.office.tabLabel($store.office.session)">
<span class="material-symbols-outlined office-document-icon" aria-hidden="true" x-text="$store.office.tabIcon($store.office.session)"></span>
<span class="office-document-name" x-text="$store.office.tabTitle($store.office.session)"></span>
<span class="office-document-dirty" x-show="$store.office.dirty" aria-hidden="true">*</span>
</div>
<button
type="button"
class="office-icon-button office-document-save-button"
title="Save"
aria-label="Save"
:class="{ 'is-primary': $store.office.dirty }"
:disabled="$store.office.saving"
@click="$store.office.save()"
>
<span class="material-symbols-outlined" :class="{ spinning: $store.office.saving }" x-text="$store.office.saving ? 'progress_activity' : 'save'"></span>
</button>
<div class="office-file-actions" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
<button
type="button"
class="office-icon-button office-file-menu-button"
title="File actions"
aria-label="File actions"
aria-haspopup="menu"
:aria-expanded="open.toString()"
class="office-icon-button office-document-save-button"
title="Save"
aria-label="Save"
:class="{ 'is-primary': $store.office.dirty }"
:disabled="$store.office.saving"
@click.stop="open = !open"
@click="$store.office.save()"
>
<span class="material-symbols-outlined">more_vert</span>
<span class="material-symbols-outlined" :class="{ spinning: $store.office.saving }" x-text="$store.office.saving ? 'progress_activity' : 'save'"></span>
</button>
<div class="office-new-menu office-file-menu" role="menu" x-show="open" @click.stop>
<button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.office.saving" @click="open = false; $store.office.renameActiveFile()">
<span class="material-symbols-outlined" aria-hidden="true">edit</span>
<span>Rename</span>
</button>
<button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.office.loading" @click="open = false; $store.office.closeActiveFile()">
<span class="material-symbols-outlined" aria-hidden="true">close</span>
<span>Close File</span>
</button>
</div>
</div>
</div>
<div class="office-toolbar" x-show="$store.office.session && $store.office.isMarkdown()" style="display: none;">
<div class="office-toolbar-row">
<div class="office-tool-group office-editor-tools" x-show="$store.office.session && $store.office.isMarkdown()" style="display: none;">
<button type="button" class="office-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.office.canUndo()" @click="$store.office.undo()">
<span class="material-symbols-outlined">undo</span>
<div class="office-file-actions" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
<button
type="button"
class="office-icon-button office-file-menu-button"
title="File actions"
aria-label="File actions"
aria-haspopup="menu"
:aria-expanded="open.toString()"
:disabled="$store.office.saving"
@click.stop="open = !open"
>
<span class="material-symbols-outlined">more_vert</span>
</button>
<button type="button" class="office-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.office.canRedo()" @click="$store.office.redo()">
<span class="material-symbols-outlined">redo</span>
</button>
<button type="button" class="office-icon-button" title="Bold" aria-label="Bold" @click="$store.office.format('bold')">
<span class="material-symbols-outlined">format_bold</span>
</button>
<button type="button" class="office-icon-button" title="Italic" aria-label="Italic" @click="$store.office.format('italic')">
<span class="material-symbols-outlined">format_italic</span>
</button>
<button type="button" class="office-icon-button" title="List" aria-label="List" @click="$store.office.format('list')">
<span class="material-symbols-outlined">format_list_bulleted</span>
</button>
<button type="button" class="office-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.office.format('numbered')">
<span class="material-symbols-outlined">format_list_numbered</span>
</button>
<button type="button" class="office-icon-button" title="Table" aria-label="Table" @click="$store.office.format('table')">
<span class="material-symbols-outlined">table</span>
</button>
</div>
<span class="office-toolbar-spacer"></span>
</div>
</div>
<div class="office-state-line" x-show="$store.office.message || $store.office.error || $store.office.loading" style="display: none;">
<span class="material-symbols-outlined" :class="{ spinning: $store.office.loading }" x-text="$store.office.loading ? 'progress_activity' : ($store.office.error ? 'error' : 'check_circle')"></span>
<span x-text="$store.office.error || $store.office.message || 'Working'"></span>
</div>
<div class="office-body" :class="{ 'is-source': $store.office.isMarkdown() }">
<div class="office-editor-wrap" x-show="$store.office.session" style="display: none;">
<div class="office-editor-scroll" :class="{ 'is-desktop': $store.office.hasOfficialOffice(), 'is-source': $store.office.isMarkdown() }" @click.self="$store.office.focusEditor()">
<template x-if="$store.office.hasOfficialOffice()">
<div
class="office-desktop-wrap"
data-office-desktop-host
x-init="$nextTick(() => $store.office.mountDesktopFrameHost($el))"
>
</div>
</template>
<textarea
class="office-source-editor"
data-office-source
aria-label="Markdown source"
x-show="$store.office.isMarkdown()"
x-model="$store.office.editorText"
@input="$store.office.onSourceInput()"
@blur="$store.office.flushInput()"
spellcheck="true"
style="display: none;"
></textarea>
<div class="office-new-menu office-file-menu" role="menu" x-show="open" @click.stop>
<button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.office.saving" @click="open = false; $store.office.renameActiveFile()">
<span class="material-symbols-outlined" aria-hidden="true">edit</span>
<span>Rename</span>
</button>
<button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.office.loading" @click="open = false; $store.office.closeActiveFile()">
<span class="material-symbols-outlined" aria-hidden="true">close</span>
<span>Close File</span>
</button>
</div>
</div>
</div>
<div class="office-desktop-empty" x-show="$store.office.shouldShowDesktopEmptyState()" style="display: none;">
<span class="material-symbols-outlined" aria-hidden="true">power_settings_new</span>
<span class="office-desktop-empty-title">Desktop is shut down</span>
<button type="button" class="office-icon-button office-command-button" @click="$store.office.restartDesktopSession()">
<span class="material-symbols-outlined" aria-hidden="true">restart_alt</span>
<span class="office-button-label">Restart Desktop</span>
</button>
<div class="office-toolbar" x-show="$store.office.session && $store.office.isMarkdown()" style="display: none;">
<div class="office-toolbar-row">
<div class="office-tool-group office-editor-tools">
<button type="button" class="office-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.office.canUndo()" @click="$store.office.undo()">
<span class="material-symbols-outlined">undo</span>
</button>
<button type="button" class="office-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.office.canRedo()" @click="$store.office.redo()">
<span class="material-symbols-outlined">redo</span>
</button>
<button type="button" class="office-icon-button" title="Bold" aria-label="Bold" @click="$store.office.format('bold')">
<span class="material-symbols-outlined">format_bold</span>
</button>
<button type="button" class="office-icon-button" title="Italic" aria-label="Italic" @click="$store.office.format('italic')">
<span class="material-symbols-outlined">format_italic</span>
</button>
<button type="button" class="office-icon-button" title="List" aria-label="List" @click="$store.office.format('list')">
<span class="material-symbols-outlined">format_list_bulleted</span>
</button>
<button type="button" class="office-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.office.format('numbered')">
<span class="material-symbols-outlined">format_list_numbered</span>
</button>
<button type="button" class="office-icon-button" title="Table" aria-label="Table" @click="$store.office.format('table')">
<span class="material-symbols-outlined">table</span>
</button>
</div>
<span class="office-toolbar-spacer"></span>
</div>
</div>
<div class="office-state-line" x-show="$store.office.message || $store.office.error || $store.office.loading" style="display: none;">
<span class="material-symbols-outlined" :class="{ spinning: $store.office.loading }" x-text="$store.office.loading ? 'progress_activity' : ($store.office.error ? 'error' : 'check_circle')"></span>
<span x-text="$store.office.error || $store.office.message || 'Working'"></span>
</div>
<div class="office-body" :class="{ 'is-source': $store.office.isMarkdown() }">
<div class="office-editor-wrap" x-show="$store.office.session" style="display: none;">
<div class="office-editor-scroll is-source" @click.self="$store.office.focusEditor()">
<textarea
class="office-source-editor"
data-office-source
aria-label="Markdown source"
x-show="$store.office.isMarkdown()"
x-model="$store.office.editorText"
@input="$store.office.onSourceInput()"
@blur="$store.office.flushInput()"
spellcheck="true"
style="display: none;"
></textarea>
</div>
</div>
<div class="office-empty" x-show="!$store.office.session && !$store.office.loading" style="display: none;">
<div class="office-empty-actions">
<button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('open')">
<span class="material-symbols-outlined" aria-hidden="true">folder_open</span>
<span class="office-button-label">Open</span>
</button>
<button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('markdown')">
<span class="material-symbols-outlined" aria-hidden="true">article</span>
<span class="office-button-label">Markdown</span>
</button>
<button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('writer')">
<span class="material-symbols-outlined" aria-hidden="true">description</span>
<span class="office-button-label">Writer</span>
</button>
<button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('spreadsheet')">
<span class="material-symbols-outlined" aria-hidden="true">table_chart</span>
<span class="office-button-label">Calc</span>
</button>
<button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('presentation')">
<span class="material-symbols-outlined" aria-hidden="true">co_present</span>
<span class="office-button-label">Impress</span>
</button>
</div>
</div>
</div>
</div>
</div>
@ -148,24 +155,13 @@
.modal-inner.office-modal {
box-sizing: border-box;
width: min(1120px, calc(100vw - 32px));
height: min(820px, calc(100vh - 32px));
min-width: min(720px, calc(100vw - 16px));
min-height: min(520px, calc(100vh - 16px));
width: min(1040px, calc(100vw - 32px));
height: min(760px, calc(100vh - 32px));
min-width: min(640px, calc(100vw - 16px));
min-height: min(460px, calc(100vh - 16px));
max-width: none;
max-height: none;
resize: none;
overflow: hidden;
will-change: width, height, left, top;
}
.modal-inner.office-modal.is-resizing,
.modal-inner.office-modal.is-dragging {
user-select: none;
}
.modal-inner.office-modal.is-focus-mode {
border-radius: 6px;
}
.modal-inner.office-modal .modal-scroll {
@ -178,592 +174,211 @@
}
.modal-inner.office-modal .modal-header {
grid-template-columns: minmax(0, 1fr) repeat(5, auto);
grid-template-columns: minmax(0, 1fr) auto auto;
}
.office-modal-input-shield {
position: absolute;
inset: 42px 0 0 0;
z-index: 4;
display: none;
background: transparent;
}
.office-modal-resizer {
position: absolute;
z-index: 5;
display: block;
touch-action: none;
}
.office-modal-resizer.is-right {
top: 42px;
right: -4px;
bottom: 12px;
width: 10px;
cursor: ew-resize;
}
.office-modal-resizer.is-bottom {
right: 12px;
bottom: -4px;
left: 0;
height: 10px;
cursor: ns-resize;
}
.office-modal-resizer.is-corner {
right: 0;
bottom: 0;
width: 22px;
height: 22px;
cursor: nwse-resize;
}
.office-modal-resizer.is-corner::after {
content: "";
position: absolute;
right: 6px;
bottom: 6px;
width: 9px;
height: 9px;
border-right: 2px solid color-mix(in srgb, var(--color-text) 42%, transparent);
border-bottom: 2px solid color-mix(in srgb, var(--color-text) 42%, transparent);
border-radius: 1px;
}
.modal-inner.office-modal.is-focus-mode .office-modal-resizer {
display: none;
}
.modal-inner.office-modal .modal-bd.office-modal-body,
.modal-inner.office-modal .modal-bd.office-modal-body > x-component,
.modal-inner.office-modal .modal-bd.office-modal-body > x-component > .office-panel {
display: flex;
flex: 1 1 auto;
min-height: 0;
height: 100%;
padding: 0;
}
.office-toolbar {
display: flex;
flex-direction: row;
align-items: stretch;
flex-wrap: nowrap;
min-height: 0;
padding: 6px 10px;
overflow: hidden;
border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 20%);
background: color-mix(in srgb, var(--color-background), var(--color-panel) 48%);
}
.office-toolbar-row {
.office-document-header,
.office-toolbar,
.office-state-line {
display: flex;
align-items: center;
flex: 0 0 auto;
flex-wrap: nowrap;
gap: 6px;
width: 100%;
gap: 8px;
border-bottom: 1px solid var(--color-border);
padding: 8px 10px;
min-width: 0;
min-height: 32px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
}
.office-tool-group {
display: flex;
align-items: center;
flex-wrap: nowrap;
flex: 0 0 auto;
gap: 4px;
min-width: 0;
}
.office-editor-tools {
gap: 3px;
}
.office-tool-actions {
justify-content: flex-end;
}
.office-toolbar-spacer {
flex: 1 1 16px;
min-width: 8px;
}
.office-toolbar-divider {
flex: 0 0 auto;
width: 1px;
height: 22px;
margin-inline: 2px;
background: color-mix(in srgb, var(--color-border), transparent 18%);
}
.office-document-header {
display: flex;
align-items: center;
gap: 10px;
min-height: 38px;
padding: 5px 10px 5px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
background: color-mix(in srgb, var(--color-panel), var(--color-background) 30%);
}
.office-document-title {
display: flex;
align-items: center;
gap: 7px;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
color: var(--color-text);
}
.office-document-icon {
flex: 0 0 auto;
font-size: 19px;
line-height: 1;
font-size: 14px;
font-weight: 600;
}
.office-document-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 750;
letter-spacing: 0;
line-height: 1.2;
}
.office-document-dirty {
flex: 0 0 auto;
color: #2ca58d;
font-size: 14px;
font-weight: 800;
line-height: 1;
color: var(--color-accent);
}
.office-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 32px;
min-width: 32px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
}
.office-icon-button:hover:not(:disabled) {
background: var(--color-surface);
}
.office-icon-button:disabled {
cursor: default;
opacity: 0.55;
}
.office-document-save-button.is-primary {
border-color: var(--color-accent);
color: var(--color-accent);
}
.office-file-actions {
position: relative;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
}
.office-document-save-button,
.office-file-menu-button {
width: 30px;
height: 30px;
min-width: 30px;
}
.office-header-actions {
position: relative;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
}
.office-header-new-button {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 34px;
min-height: 34px;
padding: 0 9px 0 8px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--color-text);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 750;
letter-spacing: 0;
line-height: 1;
opacity: 0.82;
white-space: nowrap;
transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
}
.office-header-new-button:hover,
.office-header-actions.is-open .office-header-new-button {
opacity: 1;
border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
}
.office-header-new-button .material-symbols-outlined {
font-size: 18px;
line-height: 1;
}
.office-header-new-button .office-new-chevron {
margin-left: -2px;
font-size: 16px;
opacity: 0.8;
}
.office-new-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 100;
min-width: 184px;
padding: 5px;
border: 1px solid color-mix(in srgb, var(--color-border), transparent 10%);
border-radius: 8px;
background: color-mix(in srgb, var(--color-panel), var(--color-background) 10%);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.34);
}
.office-new-menu[hidden] {
display: none;
z-index: 15;
display: grid;
min-width: 172px;
padding: 6px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.18);
}
.office-new-menu-item {
appearance: none;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 32px;
padding: 0 8px;
border: 1px solid transparent;
border-radius: 6px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--color-text);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 650;
letter-spacing: 0;
line-height: 1;
padding: 8px;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.office-new-menu-item:hover {
border-color: color-mix(in srgb, var(--color-primary) 22%, transparent);
background: color-mix(in srgb, var(--color-background-hover) 76%, transparent);
background: var(--color-surface);
}
.office-new-menu-item:disabled {
cursor: default;
opacity: 0.46;
}
.office-new-menu-item .material-symbols-outlined {
flex: 0 0 auto;
width: 18px;
font-size: 18px;
line-height: 1;
text-align: center;
}
.office-icon-button,
.office-tab,
.office-tab-close {
border: 1px solid color-mix(in srgb, var(--color-border), transparent 12%);
border-radius: 8px;
background: color-mix(in srgb, var(--color-panel), var(--color-background) 16%);
color: inherit;
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
}
.office-icon-button {
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
min-width: 32px;
padding: 0;
}
.office-command-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
width: auto;
max-width: 126px;
padding: 0 8px;
white-space: nowrap;
}
.office-command-button .office-button-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.office-icon-button.is-primary {
border-color: color-mix(in srgb, #2c7be5, var(--color-border) 20%);
background: color-mix(in srgb, #2c7be5, var(--color-panel) 82%);
}
.office-icon-button.is-active {
border-color: color-mix(in srgb, #2ca58d, var(--color-border) 24%);
background: color-mix(in srgb, #2ca58d, var(--color-panel) 84%);
}
.office-icon-button:hover:not(:disabled),
.office-tab:hover,
.office-tab-close:hover {
border-color: color-mix(in srgb, #2c7be5, var(--color-border) 45%);
background: color-mix(in srgb, var(--color-panel), #2c7be5 8%);
}
.office-icon-button:disabled {
cursor: default;
opacity: 0.42;
}
.office-icon-button .material-symbols-outlined,
.office-tab-icon {
font-size: 19px;
line-height: 1;
}
.office-tabs {
display: flex;
gap: 6px;
min-height: 42px;
padding: 7px 10px;
overflow-x: auto;
border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
background: color-mix(in srgb, var(--color-panel), var(--color-background) 28%);
}
.office-tab-shell {
display: grid;
grid-template-columns: minmax(0, 1fr) 28px;
align-items: center;
min-width: 150px;
max-width: 240px;
}
.office-tab-shell.is-system {
grid-template-columns: minmax(0, 1fr);
min-width: 172px;
}
.office-tab,
.office-tab-close {
height: 28px;
min-height: 28px;
border-radius: 7px;
}
.office-tab {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
padding: 0 8px;
text-align: left;
}
.office-tab-shell.is-system .office-tab {
border-radius: 7px;
}
.office-tab-close {
display: grid;
place-items: center;
border-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding: 0;
}
.office-tab-close .material-symbols-outlined {
font-size: 17px;
}
.office-tab-shell.is-active .office-tab,
.office-tab-shell.is-active .office-tab-close {
border-color: color-mix(in srgb, #2c7be5, var(--color-border) 36%);
background: color-mix(in srgb, #2c7be5, var(--color-panel) 88%);
}
.office-tab-shell.is-dirty .office-tab-title::after {
content: " *";
color: #2ca58d;
}
.office-tab-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
line-height: 1;
}
.office-state-line {
.office-toolbar-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 6px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 28%);
color: var(--color-text-secondary);
font-size: 12px;
width: 100%;
min-width: 0;
}
.office-tool-group,
.office-empty-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.office-toolbar-spacer {
flex: 1 1 auto;
}
.office-state-line {
font-size: 13px;
color: var(--color-muted);
}
.office-body {
position: relative;
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
background: var(--color-background);
}
.office-body.is-source {
background: transparent;
}
.office-editor-wrap {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.office-editor-wrap,
.office-editor-scroll {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 30px 24px;
}
.office-editor-scroll.is-source {
display: flex;
overflow: hidden;
padding: var(--spacing-md);
background: transparent;
}
.office-editor-scroll.is-desktop {
display: flex;
overflow: hidden;
padding: 0;
background: #1f2329;
}
.office-desktop-wrap {
display: flex;
flex: 1 1 auto;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
aspect-ratio: auto;
background: #1f2329;
}
.office-desktop-empty {
display: grid;
flex: 1 1 auto;
place-items: center;
align-content: center;
gap: 12px;
min-width: 0;
min-height: 0;
padding: 24px;
color: var(--color-text-secondary);
text-align: center;
}
.office-desktop-empty > .material-symbols-outlined {
font-size: 32px;
color: color-mix(in srgb, var(--color-text) 68%, transparent);
}
.office-desktop-empty-title {
font-size: 13px;
font-weight: 700;
}
.office-desktop-frame {
flex: 1 1 auto;
width: 100%;
height: 100%;
min-height: 0;
aspect-ratio: auto;
border: 0;
background: #20242a;
}
.office-source-editor {
box-sizing: border-box;
flex: 1 1 auto;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
margin: 0;
padding: 0;
resize: none;
border: 0;
outline: none;
background: transparent;
box-shadow: none;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.65;
resize: none;
padding: 16px;
background: var(--color-background);
color: var(--color-text);
font: 14px/1.55 var(--font-mono, monospace);
}
textarea:focus {
background: transparent;
filter: brightness(1) !important;
.office-empty {
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: center;
padding: 24px;
}
.office-panel .spinning {
animation: office-spin 0.8s linear infinite;
.office-command-button {
min-width: 108px;
justify-content: flex-start;
padding: 0 10px;
}
@keyframes office-spin {
to { transform: rotate(360deg); }
.office-header-actions {
position: relative;
display: inline-flex;
align-items: center;
}
@container (max-width: 680px) {
.office-toolbar {
padding-inline: 8px;
.office-header-new-button {
display: inline-flex;
align-items: center;
gap: 4px;
height: 32px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
padding: 0 9px;
cursor: pointer;
}
.office-header-actions .office-new-menu {
right: 0;
}
@container (max-width: 560px) {
.office-document-header,
.office-toolbar,
.office-state-line {
padding: 7px;
}
.office-toolbar-row {
gap: 5px;
.office-button-label,
.office-header-new-button span:not(.material-symbols-outlined) {
display: none;
}
.office-command-button {
width: 32px;
max-width: 32px;
padding-inline: 0;
min-width: 32px;
justify-content: center;
}
.office-command-button .office-button-label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
}
</style>
</body>

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After