Improve Office canvas setup and dashboard UX

Replace the raw Collabora setup log with a simple Office setup progress state, redesign the Office dashboard around document cards with lightweight previews, and keep backend WOPI sessions aligned with visible Office tabs. Also preserve the restored Office canvas surface across window refreshes and add regression coverage for the new behavior.
This commit is contained in:
Alessandro 2026-04-28 07:17:04 +02:00
parent 9ec070793d
commit df9523433d
7 changed files with 882 additions and 128 deletions

View file

@ -26,6 +26,12 @@ class OfficeSession(ApiHandler):
return {"ok": True, "documents": wopi_store.get_recent_documents()}
if action == "open_documents":
return {"ok": True, "documents": wopi_store.get_open_documents(limit=24)}
if action == "sync_open_sessions":
session_ids = input.get("session_ids")
if not isinstance(session_ids, list):
session_ids = []
closed = wopi_store.sync_open_sessions(session_ids)
return {"ok": True, "closed": closed, "documents": wopi_store.get_open_documents(limit=24)}
if action == "close":
closed = wopi_store.close_session(
session_id=str(input.get("session_id") or ""),
@ -96,6 +102,7 @@ class OfficeSession(ApiHandler):
"extension": doc["extension"],
"path": doc["path"],
"version": wopi_store.item_version(doc),
"preview": wopi_store.build_preview(doc),
}
def _origin(self, request: Request) -> str:

View file

@ -12,6 +12,7 @@ import sqlite3
import time
import uuid
import zipfile
import xml.etree.ElementTree as ET
from contextlib import contextmanager
from pathlib import Path
from typing import Any
@ -27,6 +28,14 @@ DEFAULT_LOCK_SECONDS = 30 * 60
MAX_LOCK_SECONDS = 3600
MIN_LOCK_SECONDS = 60
MAX_SAVE_BYTES = 512 * 1024 * 1024
PREVIEW_LINE_LIMIT = 5
PREVIEW_ROW_LIMIT = 5
PREVIEW_COLUMN_LIMIT = 4
PREVIEW_SLIDE_LIMIT = 2
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
X_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "collabora"))
DB_PATH = STATE_DIR / "documents.sqlite3"
@ -210,13 +219,16 @@ def get_document(file_id: str, conn: sqlite3.Connection | None = None) -> dict[s
return _fetch(active)
def get_recent_documents(limit: int = 12) -> list[dict[str, Any]]:
def get_recent_documents(limit: int = 12, include_preview: bool = True) -> list[dict[str, Any]]:
with connect() as conn:
rows = conn.execute(
"SELECT * FROM documents ORDER BY updated_at DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(row) for row in rows]
documents = [dict(row) for row in rows]
if include_preview:
return [with_preview(document) for document in documents]
return documents
def get_open_documents(limit: int = 6) -> list[dict[str, Any]]:
@ -273,6 +285,38 @@ def close_session(session_id: str = "", file_id: str = "") -> int:
return len(rows)
def sync_open_sessions(active_session_ids: list[str] | tuple[str, ...] | set[str]) -> int:
active_ids = {str(session_id).strip() for session_id in active_session_ids if str(session_id).strip()}
with connect() as conn:
_clear_expired_sessions(conn)
if active_ids:
placeholders = ",".join("?" for _ in active_ids)
rows = conn.execute(
f"SELECT session_id, file_id FROM sessions WHERE session_id NOT IN ({placeholders})",
tuple(active_ids),
).fetchall()
conn.execute(f"DELETE FROM tokens WHERE session_id NOT IN ({placeholders})", tuple(active_ids))
conn.execute(f"DELETE FROM sessions WHERE session_id NOT IN ({placeholders})", tuple(active_ids))
conn.execute(f"DELETE FROM locks WHERE session_id NOT IN ({placeholders})", tuple(active_ids))
else:
rows = conn.execute("SELECT session_id, file_id FROM sessions").fetchall()
conn.execute("DELETE FROM tokens")
conn.execute("DELETE FROM sessions")
conn.execute("DELETE FROM locks")
for row in rows:
conn.execute(
"INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)",
(
row["file_id"],
"close_orphan_session",
json.dumps({"session_id": row["session_id"]}),
now(),
),
)
return len(rows)
def create_session(file_id: str, user_id: str, permission: str, origin: str, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> dict[str, Any]:
permission = "write" if permission == "write" else "read"
token = secrets.token_urlsafe(32)
@ -304,6 +348,157 @@ def create_session(file_id: str, user_id: str, permission: str, origin: str, ttl
}
def with_preview(document: dict[str, Any]) -> dict[str, Any]:
return {**document, "preview": build_preview(document)}
def build_preview(document: dict[str, Any]) -> dict[str, Any]:
ext = str(document.get("extension") or "").lower()
path = Path(str(document.get("path") or ""))
preview = {
"available": False,
"kind": _preview_kind(ext),
"lines": [],
"rows": [],
"slides": [],
}
if not path.exists():
return preview
try:
if ext == "docx":
lines = _preview_docx(path)
return {**preview, "available": bool(lines), "lines": lines}
if ext == "xlsx":
rows = _preview_xlsx(path)
return {**preview, "available": bool(rows), "rows": rows}
if ext == "pptx":
slides = _preview_pptx(path)
return {**preview, "available": bool(slides), "slides": slides}
if ext in {"odt", "ods", "odp"}:
lines = _preview_odf(path)
return {**preview, "available": bool(lines), "lines": lines}
except Exception:
return preview
return preview
def _preview_kind(ext: str) -> str:
if ext in {"xlsx", "ods"}:
return "spreadsheet"
if ext in {"pptx", "odp"}:
return "presentation"
if ext in {"docx", "odt"}:
return "document"
return "file"
def _qn(namespace: str, tag: str) -> str:
return f"{{{namespace}}}{tag}"
def _clean_preview_text(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()
def _preview_docx(path: Path) -> list[str]:
with zipfile.ZipFile(path) as archive:
root = ET.fromstring(archive.read("word/document.xml"))
lines = []
for paragraph in root.iter(_qn(W_NS, "p")):
text = _clean_preview_text("".join(node.text or "" for node in paragraph.iter(_qn(W_NS, "t"))))
if text:
lines.append(text)
if len(lines) >= PREVIEW_LINE_LIMIT:
break
return lines
def _preview_xlsx(path: Path) -> list[list[str]]:
with zipfile.ZipFile(path) as archive:
shared_strings = _xlsx_shared_strings(archive)
sheet_names = sorted(
(name for name in archive.namelist() if re.fullmatch(r"xl/worksheets/sheet\d+\.xml", name)),
key=_natural_name_key,
)
if not sheet_names:
return []
root = ET.fromstring(archive.read(sheet_names[0]))
rows = []
for row in root.iter(_qn(X_NS, "row")):
cells = []
for cell in list(row)[:PREVIEW_COLUMN_LIMIT]:
cells.append(_xlsx_cell_preview(cell, shared_strings))
if any(cells):
rows.append(cells)
if len(rows) >= PREVIEW_ROW_LIMIT:
break
return rows
def _xlsx_shared_strings(archive: zipfile.ZipFile) -> list[str]:
try:
root = ET.fromstring(archive.read("xl/sharedStrings.xml"))
except KeyError:
return []
strings = []
for item in root.iter(_qn(X_NS, "si")):
strings.append(_clean_preview_text("".join(node.text or "" for node in item.iter(_qn(X_NS, "t")))))
return strings
def _xlsx_cell_preview(cell: ET.Element, shared_strings: list[str]) -> str:
cell_type = cell.attrib.get("t", "")
if cell_type == "inlineStr":
return _clean_preview_text("".join(node.text or "" for node in cell.iter(_qn(X_NS, "t"))))
value_node = cell.find(_qn(X_NS, "v"))
value = _clean_preview_text(value_node.text if value_node is not None else "")
if cell_type == "s":
try:
return shared_strings[int(value)]
except (ValueError, IndexError):
return value
if cell_type == "b":
return "TRUE" if value == "1" else "FALSE"
return value
def _preview_pptx(path: Path) -> list[dict[str, Any]]:
with zipfile.ZipFile(path) as archive:
names = sorted(
(name for name in archive.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", name)),
key=_natural_name_key,
)
slides = []
for name in names[:PREVIEW_SLIDE_LIMIT]:
root = ET.fromstring(archive.read(name))
lines = []
for paragraph in root.iter(_qn(A_NS, "p")):
text = _clean_preview_text("".join(node.text or "" for node in paragraph.iter(_qn(A_NS, "t"))))
if text:
lines.append(text)
if lines:
slides.append({"title": lines[0], "lines": lines[1:PREVIEW_LINE_LIMIT]})
return slides
def _preview_odf(path: Path) -> list[str]:
with zipfile.ZipFile(path) as archive:
root = ET.fromstring(archive.read("content.xml"))
lines = []
for node in root.iter():
text = _clean_preview_text(node.text)
if text:
lines.append(text)
if len(lines) >= PREVIEW_LINE_LIMIT:
break
return lines
def _natural_name_key(value: str) -> list[int | str]:
return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", value)]
def replace_document_bytes(
file_id: str,
data: bytes,

View file

@ -29,11 +29,11 @@
<span
class="office-health-pill"
:class="`is-${$store.office.status?.state || 'unknown'}`"
:title="$store.office.status?.message || 'Office status'"
:aria-label="$store.office.status?.message || 'Office status'"
:title="$store.office.healthTitle()"
:aria-label="$store.office.healthTitle()"
>
<span class="office-health-dot"></span>
<span x-show="$store.office.status?.state !== 'healthy'" x-text="$store.office.status?.state || 'status'"></span>
<span x-show="$store.office.status?.state !== 'healthy'" x-text="$store.office.healthText()"></span>
</span>
<button type="button" class="office-icon-button" title="Save" @click="$store.office.save()" :disabled="!$store.office.session">
<span class="material-symbols-outlined">save</span>
@ -87,66 +87,143 @@
</div>
<div class="office-body">
<div class="office-bootstrap" x-show="!$store.office.session && (!$store.office.status || !$store.office.status.healthy)">
<div class="office-bootstrap-header">
<span class="material-symbols-outlined">description</span>
<div>
<strong x-text="$store.office.status?.state || 'Preparing Office'"></strong>
<span x-text="$store.office.status?.message || 'Collabora Online is being prepared in the background.'"></span>
</div>
<div class="office-bootstrap" x-show="!$store.office.session && (!$store.office.status || !$store.office.status.healthy)" style="display: none;">
<div class="office-setup-mark" :class="{ 'is-busy': $store.office.isSetupBusy(), 'is-alert': $store.office.isSetupBlocked() }">
<span class="material-symbols-outlined" :class="{ spinning: $store.office.isSetupBusy() }" x-text="$store.office.setupIcon()"></span>
</div>
<div class="office-bootstrap-actions">
<button type="button" class="office-button" @click="$store.office.retry()">
<div class="office-setup-copy">
<span>Agent Zero Office</span>
<strong x-text="$store.office.setupTitle()"></strong>
<p x-text="$store.office.setupMessage()"></p>
</div>
<div class="office-setup-progress" :class="{ 'is-paused': !$store.office.isSetupBusy() }" aria-hidden="true">
<span></span>
</div>
<div class="office-bootstrap-actions" x-show="$store.office.showSetupActions()" style="display: none;">
<button type="button" class="office-button" @click="$store.office.retry()" x-show="$store.office.isSetupBlocked()" style="display: none;">
<span class="material-symbols-outlined">restart_alt</span>
<span>Retry</span>
</button>
<button type="button" class="office-button" @click="$store.office.refresh()">
<span class="material-symbols-outlined">sync</span>
<span>Status</span>
<span>Refresh</span>
</button>
</div>
<pre class="office-log" x-text="$store.office.logs?.bootstrap || $store.office.logs?.wrapper || ''"></pre>
</div>
<div class="office-start" x-show="!$store.office.session && $store.office.status?.healthy" style="display: none;">
<div class="office-start-actions">
<button type="button" class="office-create-tile" @click="$store.office.create('document')">
<span class="material-symbols-outlined">article</span>
<span>Document</span>
</button>
<button type="button" class="office-create-tile" @click="$store.office.create('spreadsheet')">
<span class="material-symbols-outlined">table_chart</span>
<span>Spreadsheet</span>
</button>
<button type="button" class="office-create-tile" @click="$store.office.create('presentation')">
<span class="material-symbols-outlined">co_present</span>
<span>Presentation</span>
</button>
</div>
<div class="office-recent" x-show="$store.office.openDocuments.length">
<div class="office-list-label">Open files</div>
<template x-for="doc in $store.office.openDocuments" :key="doc.file_id">
<button type="button" class="office-recent-row" :title="doc.path" @click="$store.office.openPath(doc.path)">
<span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span>
<span class="office-recent-text">
<span x-text="$store.office.openDocumentLabel(doc)"></span>
<small x-text="$store.office.openDocumentMeta(doc)"></small>
</span>
<section class="office-dashboard-section" aria-label="Create Office file">
<div class="office-dashboard-heading">Create</div>
<div class="office-template-grid">
<button type="button" class="office-create-tile" @click="$store.office.create('document')">
<span class="material-symbols-outlined">article</span>
<span>Document</span>
</button>
</template>
</div>
<div class="office-recent" x-show="$store.office.recent.length">
<div class="office-list-label">Recent files</div>
<template x-for="doc in $store.office.recent" :key="doc.file_id">
<button type="button" class="office-recent-row" :title="doc.path" @click="$store.office.openPath(doc.path)">
<span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span>
<span class="office-recent-text">
<span x-text="doc.basename"></span>
<small x-text="String(doc.extension || '').toUpperCase()"></small>
</span>
<button type="button" class="office-create-tile" @click="$store.office.create('spreadsheet')">
<span class="material-symbols-outlined">table_chart</span>
<span>Spreadsheet</span>
</button>
</template>
</div>
<button type="button" class="office-create-tile" @click="$store.office.create('presentation')">
<span class="material-symbols-outlined">co_present</span>
<span>Presentation</span>
</button>
</div>
</section>
<section class="office-dashboard-section" x-show="$store.office.openCards().length" aria-label="Open Office files" style="display: none;">
<div class="office-dashboard-heading">Open files</div>
<div class="office-card-grid">
<template x-for="doc in $store.office.openCards()" :key="doc.tab_id">
<button type="button" class="office-document-card is-open" :title="doc.path" @click="$store.office.selectTab(doc.tab_id)">
<span class="office-card-badge">Open</span>
<div class="office-card-preview" :class="`is-${$store.office.previewKind(doc)}`">
<template x-if="$store.office.previewKind(doc) === 'spreadsheet' && $store.office.hasPreview(doc)">
<div class="office-sheet-preview">
<template x-for="(row, rowIndex) in $store.office.previewRows(doc)" :key="rowIndex">
<div class="office-sheet-row">
<template x-for="(cell, cellIndex) in row" :key="cellIndex">
<span x-text="cell"></span>
</template>
</div>
</template>
</div>
</template>
<template x-if="$store.office.previewKind(doc) === 'presentation' && $store.office.hasPreview(doc)">
<div class="office-slide-preview">
<template x-for="(slide, index) in $store.office.previewSlides(doc)" :key="index">
<div class="office-slide-line">
<strong x-text="slide.title"></strong>
<span x-text="(slide.lines || []).join(' / ')"></span>
</div>
</template>
</div>
</template>
<template x-if="$store.office.previewKind(doc) === 'document' && $store.office.hasPreview(doc)">
<div class="office-page-preview">
<template x-for="(line, index) in $store.office.previewLines(doc)" :key="index">
<span x-text="line"></span>
</template>
</div>
</template>
<template x-if="!$store.office.hasPreview(doc)">
<div class="office-preview-fallback">
<span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span>
</div>
</template>
</div>
<span class="office-card-title" x-text="$store.office.dashboardTitle(doc)"></span>
<small x-text="$store.office.dashboardMeta(doc)"></small>
</button>
</template>
</div>
</section>
<section class="office-dashboard-section" x-show="$store.office.recentCards().length" aria-label="Recent Office files" style="display: none;">
<div class="office-dashboard-heading">Recent files</div>
<div class="office-card-grid">
<template x-for="doc in $store.office.recentCards()" :key="doc.file_id">
<button type="button" class="office-document-card" :title="doc.path" @click="$store.office.openPath(doc.path)">
<div class="office-card-preview" :class="`is-${$store.office.previewKind(doc)}`">
<template x-if="$store.office.previewKind(doc) === 'spreadsheet' && $store.office.hasPreview(doc)">
<div class="office-sheet-preview">
<template x-for="(row, rowIndex) in $store.office.previewRows(doc)" :key="rowIndex">
<div class="office-sheet-row">
<template x-for="(cell, cellIndex) in row" :key="cellIndex">
<span x-text="cell"></span>
</template>
</div>
</template>
</div>
</template>
<template x-if="$store.office.previewKind(doc) === 'presentation' && $store.office.hasPreview(doc)">
<div class="office-slide-preview">
<template x-for="(slide, index) in $store.office.previewSlides(doc)" :key="index">
<div class="office-slide-line">
<strong x-text="slide.title"></strong>
<span x-text="(slide.lines || []).join(' / ')"></span>
</div>
</template>
</div>
</template>
<template x-if="$store.office.previewKind(doc) === 'document' && $store.office.hasPreview(doc)">
<div class="office-page-preview">
<template x-for="(line, index) in $store.office.previewLines(doc)" :key="index">
<span x-text="line"></span>
</template>
</div>
</template>
<template x-if="!$store.office.hasPreview(doc)">
<div class="office-preview-fallback">
<span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span>
</div>
</template>
</div>
<span class="office-card-title" x-text="$store.office.dashboardTitle(doc)"></span>
<small x-text="$store.office.dashboardMeta(doc)"></small>
</button>
</template>
</div>
</section>
</div>
<div class="office-frame-wrap" x-show="$store.office.session" style="display: none;">
@ -401,7 +478,7 @@
.office-icon-button,
.office-health-pill,
.office-create-tile,
.office-recent-row {
.office-document-card {
display: inline-flex;
align-items: center;
justify-content: center;
@ -469,7 +546,7 @@
.office-button:hover:not(:disabled),
.office-icon-button:hover:not(:disabled),
.office-create-tile:hover,
.office-recent-row:hover {
.office-document-card:hover {
background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
}
@ -505,64 +582,144 @@
overflow: hidden;
}
.office-bootstrap,
.office-start {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 14px;
gap: 22px;
padding: 18px;
overflow: auto;
}
.office-bootstrap-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: start;
max-width: 720px;
}
.office-bootstrap-header > .material-symbols-outlined {
font-size: 24px;
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-text));
}
.office-bootstrap-header div {
.office-bootstrap {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: clamp(24px, 7cqi, 56px);
overflow: auto;
text-align: center;
}
.office-setup-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 58px;
height: 58px;
border: 1px solid color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
border-radius: 7px;
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-text));
background: color-mix(in srgb, var(--color-panel) 78%, transparent);
}
.office-setup-mark.is-busy {
border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
}
.office-setup-mark.is-alert {
border-color: color-mix(in srgb, #f05252 48%, var(--color-border));
color: #f05252;
}
.office-setup-mark .material-symbols-outlined {
font-size: 30px;
line-height: 1;
}
.office-setup-copy {
display: flex;
align-items: center;
min-width: 0;
flex-direction: column;
gap: 3px;
font-size: 0.9rem;
gap: 6px;
max-width: 460px;
line-height: 1.35;
}
.office-bootstrap-header span {
.office-setup-copy > span {
color: var(--color-text-muted);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.office-setup-copy > strong {
color: var(--color-text);
font-size: clamp(1.05rem, 4cqi, 1.35rem);
font-weight: 760;
}
.office-setup-copy > p {
margin: 0;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.office-setup-progress {
position: relative;
width: min(260px, 72cqi);
height: 5px;
overflow: hidden;
border-radius: 999px;
background: color-mix(in srgb, var(--color-border) 45%, transparent);
}
.office-setup-progress > span {
position: absolute;
inset: 0 auto 0 0;
width: 42%;
border-radius: inherit;
background: color-mix(in srgb, var(--color-primary) 72%, var(--color-text) 28%);
animation: office-setup-progress 1.45s ease-in-out infinite;
}
.office-setup-progress.is-paused > span {
width: 100%;
opacity: 0.42;
animation: none;
}
.office-bootstrap-actions,
.office-start-actions {
.office-template-grid {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 8px;
}
.office-log {
min-height: 140px;
max-height: 260px;
overflow: auto;
margin: 0;
padding: 10px;
border: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--color-panel) 78%, transparent);
.office-dashboard-section {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
max-width: 1180px;
}
.office-dashboard-heading {
color: var(--color-text-muted);
font-family: var(--font-family-code);
font-size: 0.72rem;
white-space: pre-wrap;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.office-template-grid {
justify-content: flex-start;
}
.office-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(190px, 100%), 1fr));
gap: 10px;
width: 100%;
}
.office-create-tile {
@ -577,45 +734,182 @@
font-size: 28px;
}
.office-recent {
.office-document-card {
position: relative;
display: grid;
grid-template-rows: auto auto auto;
align-content: start;
justify-content: stretch;
gap: 8px;
min-width: 0;
min-height: 196px;
padding: 10px;
text-align: left;
overflow: hidden;
}
.office-document-card.is-open {
border-color: color-mix(in srgb, var(--color-primary) 36%, var(--color-border));
}
.office-card-badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
max-width: calc(100% - 16px);
overflow: hidden;
padding: 2px 6px;
border: 1px solid color-mix(in srgb, var(--color-primary) 42%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--color-background) 82%, transparent);
color: var(--color-text);
font-size: 0.68rem;
font-weight: 700;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.office-card-preview {
position: relative;
display: grid;
align-items: stretch;
width: 100%;
aspect-ratio: 16 / 10;
min-height: 112px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
border-radius: 6px;
background: color-mix(in srgb, var(--color-background) 76%, #fff 4%);
}
.office-page-preview,
.office-sheet-preview,
.office-slide-preview,
.office-preview-fallback {
min-width: 0;
min-height: 0;
}
.office-page-preview {
display: flex;
flex-direction: column;
gap: 6px;
max-width: 720px;
gap: 5px;
padding: 12px 13px;
background:
linear-gradient(to bottom, transparent 0, transparent 21px, color-mix(in srgb, var(--color-border) 30%, transparent) 22px),
color-mix(in srgb, var(--color-panel) 72%, transparent);
background-size: 100% 22px;
color: var(--color-text);
}
.office-list-label {
margin-top: 2px;
color: var(--color-text-muted);
font-size: 0.76rem;
font-weight: 650;
text-transform: uppercase;
}
.office-recent-row {
justify-content: flex-start;
min-height: 36px;
padding: 7px 9px;
text-align: left;
}
.office-recent-text {
display: grid;
.office-page-preview span,
.office-slide-line span,
.office-slide-line strong,
.office-sheet-row span {
min-width: 0;
gap: 1px;
line-height: 1.2;
}
.office-recent-text > span,
.office-recent-text > small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.office-recent-text > small {
.office-page-preview span {
font-size: 0.7rem;
line-height: 1.25;
}
.office-sheet-preview {
min-width: 0;
padding: 8px;
background: color-mix(in srgb, var(--color-panel) 74%, transparent);
}
.office-sheet-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
min-height: 20px;
}
.office-sheet-row + .office-sheet-row {
border-top: 1px solid color-mix(in srgb, var(--color-border) 34%, transparent);
}
.office-sheet-row span {
padding: 4px 5px;
border-right: 1px solid color-mix(in srgb, var(--color-border) 34%, transparent);
color: var(--color-text-muted);
font-size: 0.72rem;
font-size: 0.66rem;
line-height: 1.1;
}
.office-sheet-row span:last-child {
border-right: 0;
}
.office-slide-preview {
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
padding: 14px;
background:
linear-gradient(135deg, color-mix(in srgb, var(--color-panel) 80%, transparent), color-mix(in srgb, var(--color-background) 84%, var(--color-primary) 10%));
}
.office-slide-line {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.office-slide-line strong {
color: var(--color-text);
font-size: 0.78rem;
font-weight: 760;
line-height: 1.15;
}
.office-slide-line span {
color: var(--color-text-muted);
font-size: 0.68rem;
line-height: 1.15;
}
.office-preview-fallback {
display: flex;
align-items: center;
justify-content: center;
color: color-mix(in srgb, var(--color-primary) 64%, var(--color-text));
background: color-mix(in srgb, var(--color-panel) 76%, transparent);
}
.office-preview-fallback .material-symbols-outlined {
font-size: 36px;
}
.office-card-title {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
font-size: 0.86rem;
font-weight: 720;
line-height: 1.2;
}
.office-document-card small {
display: block;
min-width: 0;
overflow: hidden;
color: var(--color-text-muted);
font-size: 0.7rem;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.office-frame-wrap {
@ -649,6 +943,19 @@
to { transform: rotate(360deg); }
}
@keyframes office-setup-progress {
0% { transform: translateX(-110%); }
55% { transform: translateX(85%); }
100% { transform: translateX(250%); }
}
@media (prefers-reduced-motion: reduce) {
.office-panel .spinning,
.office-setup-progress > span {
animation: none;
}
}
@media (max-width: 520px) {
.office-button span:last-child {
display: none;

View file

@ -5,6 +5,7 @@ const FRAME_NAME_PREFIX = "a0-office-frame";
const COLLABORA_STATE_VERSION = "2026-04-26.1";
const COLLABORA_STATE_MARKER = "a0.office.collaboraStateVersion";
const SERVICE_WORKER_CLEANUP_MARKER = "a0.office.serviceWorkerCleanupReloaded";
const SETUP_POLL_INTERVAL_MS = 4000;
function makeFrameName() {
const id = globalThis.crypto?.randomUUID?.()
@ -49,9 +50,22 @@ function sameDocument(left = {}, right = {}) {
return Boolean(leftPath && rightPath && leftPath === rightPath);
}
function formatBytes(value) {
const size = Number(value || 0);
if (!Number.isFinite(size) || size <= 0) return "";
const units = ["B", "KB", "MB", "GB"];
let amount = size;
let index = 0;
while (amount >= 1024 && index < units.length - 1) {
amount /= 1024;
index += 1;
}
const digits = amount >= 10 || index === 0 ? 0 : 1;
return `${amount.toFixed(digits)} ${units[index]}`;
}
const model = {
status: null,
logs: null,
recent: [],
openDocuments: [],
tabs: [],
@ -72,6 +86,7 @@ const model = {
_mode: "canvas",
_floatingCleanup: null,
_saveWaiters: [],
_statusPollTimer: null,
async init(element = null) {
return await this.onMount(element, { mode: "canvas" });
@ -115,6 +130,7 @@ const model = {
cleanup() {
this._floatingCleanup?.();
this._floatingCleanup = null;
this.clearStatusPoll();
if (this._mode === "modal") {
this._root = null;
}
@ -125,20 +141,103 @@ const model = {
this.status = await callJsonApi("/plugins/_office/office_session", { action: "status" });
const recent = await callJsonApi("/plugins/_office/office_session", { action: "recent" });
this.recent = recent?.documents || [];
const openDocuments = await callJsonApi("/plugins/_office/office_session", { action: "open_documents" });
this.openDocuments = openDocuments?.documents || [];
if (!this.status?.healthy) {
const logs = await callJsonApi("/plugins/_office/collabora_logs", {});
this.logs = logs;
if (this.status?.healthy) {
await this.syncOpenSessions();
} else {
this.openDocuments = [];
}
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
} finally {
this.scheduleStatusPoll();
}
},
async syncOpenSessions() {
const sessionIds = this.tabs
.map((tab) => normalizeTabId(tab?.session_id))
.filter(Boolean);
const response = await callJsonApi("/plugins/_office/office_session", {
action: "sync_open_sessions",
session_ids: sessionIds,
});
this.openDocuments = response?.documents || [];
return response;
},
async retry() {
this.message = "Retrying Collabora setup...";
this.message = "Retrying Office setup...";
this.status = await callJsonApi("/plugins/_office/office_session", { action: "retry" });
this.scheduleStatusPoll();
},
clearStatusPoll() {
if (!this._statusPollTimer) return;
globalThis.clearTimeout(this._statusPollTimer);
this._statusPollTimer = null;
},
scheduleStatusPoll() {
this.clearStatusPoll();
if (!this.shouldPollSetup()) return;
this._statusPollTimer = globalThis.setTimeout(() => {
this._statusPollTimer = null;
void this.refresh();
}, SETUP_POLL_INTERVAL_MS);
},
shouldPollSetup() {
if (this.session || this.status?.healthy) return false;
if (!this.status) return true;
const state = String(this.status.state || "").toLowerCase();
return Boolean(this.status.installing || state === "installing" || state === "idle");
},
setupState() {
return String(this.status?.state || "installing").toLowerCase();
},
isSetupBusy() {
const state = this.setupState();
return !this.status || Boolean(this.status.installing) || state === "installing" || state === "idle";
},
isSetupBlocked() {
const state = this.setupState();
return state === "failed" || state === "degraded";
},
showSetupActions() {
return this.isSetupBlocked() || (!this.isSetupBusy() && !this.status?.healthy);
},
setupIcon() {
return this.isSetupBlocked() ? "error" : "progress_activity";
},
setupTitle() {
if (this.isSetupBlocked()) return "Setup needs attention";
return "Setup in progress";
},
setupMessage() {
if (this.isSetupBlocked()) {
return "Office could not finish setup. Retry when you are ready.";
}
return "Please wait while Office is prepared. This can take a few minutes the first time.";
},
healthTitle() {
if (this.status?.healthy) return "Office is ready";
if (this.isSetupBlocked()) return "Office setup needs attention";
if (this.isSetupBusy()) return "Office setup is in progress";
return "Office status";
},
healthText() {
if (this.isSetupBlocked()) return "attention";
if (this.isSetupBusy()) return "setup";
return String(this.status?.state || "status");
},
async create(kind = "document") {
@ -491,13 +590,71 @@ const model = {
return basename || path.split("/").filter(Boolean).pop() || "Office file";
},
openDocumentMeta(doc) {
const sessions = Number(doc?.open_sessions || 0);
openCards() {
return this.tabs.map((tab) => ({ ...tab, dashboard_open: true }));
},
recentCards() {
const openFileIds = new Set(this.tabs.map((tab) => normalizeTabId(tab?.file_id)).filter(Boolean));
return (this.recent || []).filter((doc) => !openFileIds.has(normalizeTabId(doc?.file_id)));
},
dashboardTitle(doc) {
return this.openDocumentLabel(doc);
},
dashboardMeta(doc) {
const extension = String(doc?.extension || "").trim().toUpperCase();
return [
extension,
sessions ? `${sessions} session${sessions === 1 ? "" : "s"}` : "",
].filter(Boolean).join(" / ");
const size = formatBytes(doc?.size);
return [extension, size].filter(Boolean).join(" / ");
},
previewKind(doc) {
const kind = String(doc?.preview?.kind || "").trim();
if (kind === "spreadsheet" && !doc?.preview?.rows?.length && doc?.preview?.lines?.length) return "document";
if (kind === "presentation" && !doc?.preview?.slides?.length && doc?.preview?.lines?.length) return "document";
if (kind) return kind;
const extension = String(doc?.extension || "").toLowerCase();
if (["xlsx", "ods"].includes(extension)) return "spreadsheet";
if (["pptx", "odp"].includes(extension)) return "presentation";
if (["docx", "odt"].includes(extension)) return "document";
return "file";
},
hasPreview(doc) {
const preview = doc?.preview || {};
return Boolean(
preview.available
&& (
preview.lines?.length
|| preview.rows?.length
|| preview.slides?.length
)
);
},
previewLines(doc) {
const lines = doc?.preview?.lines || [];
if (lines.length) return lines.slice(0, 5).map((line) => String(line || ""));
const slides = doc?.preview?.slides || [];
if (slides.length) {
return [slides[0]?.title, ...(slides[0]?.lines || [])].filter(Boolean).slice(0, 5);
}
return [];
},
previewRows(doc) {
return (doc?.preview?.rows || [])
.slice(0, 5)
.map((row) => {
const cells = (Array.isArray(row) ? row : []).slice(0, 4).map((cell) => String(cell ?? ""));
while (cells.length < 4) cells.push("");
return cells;
});
},
previewSlides(doc) {
return (doc?.preview?.slides || []).slice(0, 2);
},
onPostMessage(event) {

View file

@ -0,0 +1,53 @@
from __future__ import annotations
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def test_office_setup_canvas_uses_simple_progress_instead_of_install_logs():
panel = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-panel.html").read_text(
encoding="utf-8",
)
store = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-store.js").read_text(
encoding="utf-8",
)
assert "Agent Zero Office" in panel
assert "setupTitle()" in panel
assert "Setup in progress" in store
assert "office-log" not in panel
assert "collabora_logs" not in store
def test_office_dashboard_uses_cards_and_visible_tabs_for_open_files():
panel = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-panel.html").read_text(
encoding="utf-8",
)
store = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-store.js").read_text(
encoding="utf-8",
)
assert "office-card-grid" in panel
assert "office-document-card" in panel
assert "openCards()" in panel
assert "recentCards()" in panel
assert "office-recent-row" not in panel
assert "sync_open_sessions" in store
def test_right_canvas_keeps_restored_office_surface_until_registration_finishes():
canvas_store = (
PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas-store.js"
).read_text(encoding="utf-8")
init_registration = canvas_store.index('await callJsExtensions("right_canvas_register_surfaces", this);')
init_ensure = canvas_store.index("this.ensureActiveSurface();", init_registration)
register_surface = canvas_store.index("registerSurface(surface)")
register_guard = canvas_store.index("if (!this._registering)", register_surface)
guarded_ensure = canvas_store.index("this.ensureActiveSurface();", register_guard)
open_surface = canvas_store.index("async open", register_surface)
assert init_registration < init_ensure
assert register_surface < register_guard < guarded_ensure < open_surface

View file

@ -106,6 +106,39 @@ def test_close_session_revokes_token_lock_and_open_document_metadata(office_stat
assert wopi_store.close_session(session_id=session["session_id"]) == 0
def test_sync_open_sessions_closes_sessions_without_visible_tabs(office_state):
first = wopi_store.create_document("document", "Visible", "docx", "shown")
second = wopi_store.create_document("document", "Orphan", "docx", "hidden")
visible = wopi_store.create_session(first["file_id"], "user-a", "write", "http://localhost:32080")
orphan = wopi_store.create_session(second["file_id"], "user-a", "write", "http://localhost:32080")
ok, _ = wopi_store.lock(second["file_id"], "orphan-lock", orphan["session_id"], 120)
assert ok is True
assert wopi_store.sync_open_sessions([visible["session_id"]]) == 1
open_docs = wopi_store.get_open_documents()
assert len(open_docs) == 1
assert open_docs[0]["file_id"] == first["file_id"]
assert wopi_store.get_lock(second["file_id"]) == ""
with pytest.raises(PermissionError):
wopi_store.validate_token(orphan["access_token"], second["file_id"])
def test_recent_documents_include_lightweight_previews(office_state):
doc = wopi_store.create_document("document", "Preview Memo", "docx", "A calm dashboard.")
sheet = wopi_store.create_document("spreadsheet", "Preview Sheet", "xlsx", "Name,Value\nOffice,1")
deck = wopi_store.create_document("presentation", "Preview Deck", "pptx", "First slide")
previews = {
item["file_id"]: item["preview"]
for item in wopi_store.get_recent_documents(limit=3)
}
assert previews[doc["file_id"]]["lines"][0] == "Preview Memo"
assert previews[sheet["file_id"]]["rows"][0] == ["Name", "Value"]
assert previews[deck["file_id"]]["slides"][0]["title"] == "Preview Deck"
def test_put_file_requires_lock_and_updates_version_history(office_state):
doc = wopi_store.create_document("document", "Save Test", "docx", "before")
session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")

View file

@ -81,7 +81,9 @@ const model = {
this.surfaces.push(normalized);
}
this.surfaces.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
this.ensureActiveSurface();
if (!this._registering) {
this.ensureActiveSurface();
}
},
ensureActiveSurface() {