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.

Alessandro committed Apr 28, 2026 at 07:17 UTC df9523433dd8074e80523fecc54d4b466ba93ae8
7 files changed +870 -116
plugins/_office/api/office_session.py
+7
@@ -26,6 +26,12 @@ class OfficeSession(ApiHandler):
26 return {"ok": True, "documents": wopi_store.get_recent_documents()}
27 if action == "open_documents":
28 return {"ok": True, "documents": wopi_store.get_open_documents(limit=24)}
29 + if action == "sync_open_sessions":
30 + session_ids = input.get("session_ids")
31 + if not isinstance(session_ids, list):
32 + session_ids = []
33 + closed = wopi_store.sync_open_sessions(session_ids)
34 + return {"ok": True, "closed": closed, "documents": wopi_store.get_open_documents(limit=24)}
35 if action == "close":
36 closed = wopi_store.close_session(
37 session_id=str(input.get("session_id") or ""),
@@ -96,6 +102,7 @@ class OfficeSession(ApiHandler):
102 "extension": doc["extension"],
103 "path": doc["path"],
104 "version": wopi_store.item_version(doc),
105 + "preview": wopi_store.build_preview(doc),
106 }
107
108 def _origin(self, request: Request) -> str:
plugins/_office/helpers/wopi_store.py
+197 -2
@@ -12,6 +12,7 @@ import sqlite3
12 import time
13 import uuid
14 import zipfile
15 +import xml.etree.ElementTree as ET
16 from contextlib import contextmanager
17 from pathlib import Path
18 from typing import Any
@@ -27,6 +28,14 @@ DEFAULT_LOCK_SECONDS = 30 * 60
28 MAX_LOCK_SECONDS = 3600
29 MIN_LOCK_SECONDS = 60
30 MAX_SAVE_BYTES = 512 * 1024 * 1024
31 +PREVIEW_LINE_LIMIT = 5
32 +PREVIEW_ROW_LIMIT = 5
33 +PREVIEW_COLUMN_LIMIT = 4
34 +PREVIEW_SLIDE_LIMIT = 2
35 +
36 +W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
37 +A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
38 +X_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
39
40 STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "collabora"))
41 DB_PATH = STATE_DIR / "documents.sqlite3"
@@ -210,13 +219,16 @@ def get_document(file_id: str, conn: sqlite3.Connection | None = None) -> dict[s
219 return _fetch(active)
220
221
213 -def get_recent_documents(limit: int = 12) -> list[dict[str, Any]]:
222 +def get_recent_documents(limit: int = 12, include_preview: bool = True) -> list[dict[str, Any]]:
223 with connect() as conn:
224 rows = conn.execute(
225 "SELECT * FROM documents ORDER BY updated_at DESC LIMIT ?",
226 (limit,),
227 ).fetchall()
219 - return [dict(row) for row in rows]
228 + documents = [dict(row) for row in rows]
229 + if include_preview:
230 + return [with_preview(document) for document in documents]
231 + return documents
232
233
234 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:
285 return len(rows)
286
287
288 +def sync_open_sessions(active_session_ids: list[str] | tuple[str, ...] | set[str]) -> int:
289 + active_ids = {str(session_id).strip() for session_id in active_session_ids if str(session_id).strip()}
290 + with connect() as conn:
291 + _clear_expired_sessions(conn)
292 + if active_ids:
293 + placeholders = ",".join("?" for _ in active_ids)
294 + rows = conn.execute(
295 + f"SELECT session_id, file_id FROM sessions WHERE session_id NOT IN ({placeholders})",
296 + tuple(active_ids),
297 + ).fetchall()
298 + conn.execute(f"DELETE FROM tokens WHERE session_id NOT IN ({placeholders})", tuple(active_ids))
299 + conn.execute(f"DELETE FROM sessions WHERE session_id NOT IN ({placeholders})", tuple(active_ids))
300 + conn.execute(f"DELETE FROM locks WHERE session_id NOT IN ({placeholders})", tuple(active_ids))
301 + else:
302 + rows = conn.execute("SELECT session_id, file_id FROM sessions").fetchall()
303 + conn.execute("DELETE FROM tokens")
304 + conn.execute("DELETE FROM sessions")
305 + conn.execute("DELETE FROM locks")
306 +
307 + for row in rows:
308 + conn.execute(
309 + "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)",
310 + (
311 + row["file_id"],
312 + "close_orphan_session",
313 + json.dumps({"session_id": row["session_id"]}),
314 + now(),
315 + ),
316 + )
317 + return len(rows)
318 +
319 +
320 def create_session(file_id: str, user_id: str, permission: str, origin: str, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> dict[str, Any]:
321 permission = "write" if permission == "write" else "read"
322 token = secrets.token_urlsafe(32)
@@ -304,6 +348,157 @@ def create_session(file_id: str, user_id: str, permission: str, origin: str, ttl
348 }
349
350
351 +def with_preview(document: dict[str, Any]) -> dict[str, Any]:
352 + return {**document, "preview": build_preview(document)}
353 +
354 +
355 +def build_preview(document: dict[str, Any]) -> dict[str, Any]:
356 + ext = str(document.get("extension") or "").lower()
357 + path = Path(str(document.get("path") or ""))
358 + preview = {
359 + "available": False,
360 + "kind": _preview_kind(ext),
361 + "lines": [],
362 + "rows": [],
363 + "slides": [],
364 + }
365 + if not path.exists():
366 + return preview
367 + try:
368 + if ext == "docx":
369 + lines = _preview_docx(path)
370 + return {**preview, "available": bool(lines), "lines": lines}
371 + if ext == "xlsx":
372 + rows = _preview_xlsx(path)
373 + return {**preview, "available": bool(rows), "rows": rows}
374 + if ext == "pptx":
375 + slides = _preview_pptx(path)
376 + return {**preview, "available": bool(slides), "slides": slides}
377 + if ext in {"odt", "ods", "odp"}:
378 + lines = _preview_odf(path)
379 + return {**preview, "available": bool(lines), "lines": lines}
380 + except Exception:
381 + return preview
382 + return preview
383 +
384 +
385 +def _preview_kind(ext: str) -> str:
386 + if ext in {"xlsx", "ods"}:
387 + return "spreadsheet"
388 + if ext in {"pptx", "odp"}:
389 + return "presentation"
390 + if ext in {"docx", "odt"}:
391 + return "document"
392 + return "file"
393 +
394 +
395 +def _qn(namespace: str, tag: str) -> str:
396 + return f"{{{namespace}}}{tag}"
397 +
398 +
399 +def _clean_preview_text(value: Any) -> str:
400 + return re.sub(r"\s+", " ", str(value or "")).strip()
401 +
402 +
403 +def _preview_docx(path: Path) -> list[str]:
404 + with zipfile.ZipFile(path) as archive:
405 + root = ET.fromstring(archive.read("word/document.xml"))
406 + lines = []
407 + for paragraph in root.iter(_qn(W_NS, "p")):
408 + text = _clean_preview_text("".join(node.text or "" for node in paragraph.iter(_qn(W_NS, "t"))))
409 + if text:
410 + lines.append(text)
411 + if len(lines) >= PREVIEW_LINE_LIMIT:
412 + break
413 + return lines
414 +
415 +
416 +def _preview_xlsx(path: Path) -> list[list[str]]:
417 + with zipfile.ZipFile(path) as archive:
418 + shared_strings = _xlsx_shared_strings(archive)
419 + sheet_names = sorted(
420 + (name for name in archive.namelist() if re.fullmatch(r"xl/worksheets/sheet\d+\.xml", name)),
421 + key=_natural_name_key,
422 + )
423 + if not sheet_names:
424 + return []
425 + root = ET.fromstring(archive.read(sheet_names[0]))
426 +
427 + rows = []
428 + for row in root.iter(_qn(X_NS, "row")):
429 + cells = []
430 + for cell in list(row)[:PREVIEW_COLUMN_LIMIT]:
431 + cells.append(_xlsx_cell_preview(cell, shared_strings))
432 + if any(cells):
433 + rows.append(cells)
434 + if len(rows) >= PREVIEW_ROW_LIMIT:
435 + break
436 + return rows
437 +
438 +
439 +def _xlsx_shared_strings(archive: zipfile.ZipFile) -> list[str]:
440 + try:
441 + root = ET.fromstring(archive.read("xl/sharedStrings.xml"))
442 + except KeyError:
443 + return []
444 + strings = []
445 + for item in root.iter(_qn(X_NS, "si")):
446 + strings.append(_clean_preview_text("".join(node.text or "" for node in item.iter(_qn(X_NS, "t")))))
447 + return strings
448 +
449 +
450 +def _xlsx_cell_preview(cell: ET.Element, shared_strings: list[str]) -> str:
451 + cell_type = cell.attrib.get("t", "")
452 + if cell_type == "inlineStr":
453 + return _clean_preview_text("".join(node.text or "" for node in cell.iter(_qn(X_NS, "t"))))
454 + value_node = cell.find(_qn(X_NS, "v"))
455 + value = _clean_preview_text(value_node.text if value_node is not None else "")
456 + if cell_type == "s":
457 + try:
458 + return shared_strings[int(value)]
459 + except (ValueError, IndexError):
460 + return value
461 + if cell_type == "b":
462 + return "TRUE" if value == "1" else "FALSE"
463 + return value
464 +
465 +
466 +def _preview_pptx(path: Path) -> list[dict[str, Any]]:
467 + with zipfile.ZipFile(path) as archive:
468 + names = sorted(
469 + (name for name in archive.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", name)),
470 + key=_natural_name_key,
471 + )
472 + slides = []
473 + for name in names[:PREVIEW_SLIDE_LIMIT]:
474 + root = ET.fromstring(archive.read(name))
475 + lines = []
476 + for paragraph in root.iter(_qn(A_NS, "p")):
477 + text = _clean_preview_text("".join(node.text or "" for node in paragraph.iter(_qn(A_NS, "t"))))
478 + if text:
479 + lines.append(text)
480 + if lines:
481 + slides.append({"title": lines[0], "lines": lines[1:PREVIEW_LINE_LIMIT]})
482 + return slides
483 +
484 +
485 +def _preview_odf(path: Path) -> list[str]:
486 + with zipfile.ZipFile(path) as archive:
487 + root = ET.fromstring(archive.read("content.xml"))
488 + lines = []
489 + for node in root.iter():
490 + text = _clean_preview_text(node.text)
491 + if text:
492 + lines.append(text)
493 + if len(lines) >= PREVIEW_LINE_LIMIT:
494 + break
495 + return lines
496 +
497 +
498 +def _natural_name_key(value: str) -> list[int | str]:
499 + return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", value)]
500 +
501 +
502 def replace_document_bytes(
503 file_id: str,
504 data: bytes,
plugins/_office/webui/office-panel.html
+407 -100
@@ -29,11 +29,11 @@
29 <span
30 class="office-health-pill"
31 :class="`is-${$store.office.status?.state || 'unknown'}`"
32 - :title="$store.office.status?.message || 'Office status'"
33 - :aria-label="$store.office.status?.message || 'Office status'"
32 + :title="$store.office.healthTitle()"
33 + :aria-label="$store.office.healthTitle()"
34 >
35 <span class="office-health-dot"></span>
36 - <span x-show="$store.office.status?.state !== 'healthy'" x-text="$store.office.status?.state || 'status'"></span>
36 + <span x-show="$store.office.status?.state !== 'healthy'" x-text="$store.office.healthText()"></span>
37 </span>
38 <button type="button" class="office-icon-button" title="Save" @click="$store.office.save()" :disabled="!$store.office.session">
39 <span class="material-symbols-outlined">save</span>
@@ -87,66 +87,143 @@
87 </div>
88
89 <div class="office-body">
90 - <div class="office-bootstrap" x-show="!$store.office.session && (!$store.office.status || !$store.office.status.healthy)">
91 - <div class="office-bootstrap-header">
92 - <span class="material-symbols-outlined">description</span>
93 - <div>
94 - <strong x-text="$store.office.status?.state || 'Preparing Office'"></strong>
95 - <span x-text="$store.office.status?.message || 'Collabora Online is being prepared in the background.'"></span>
96 - </div>
90 + <div class="office-bootstrap" x-show="!$store.office.session && (!$store.office.status || !$store.office.status.healthy)" style="display: none;">
91 + <div class="office-setup-mark" :class="{ 'is-busy': $store.office.isSetupBusy(), 'is-alert': $store.office.isSetupBlocked() }">
92 + <span class="material-symbols-outlined" :class="{ spinning: $store.office.isSetupBusy() }" x-text="$store.office.setupIcon()"></span>
93 + </div>
94 + <div class="office-setup-copy">
95 + <span>Agent Zero Office</span>
96 + <strong x-text="$store.office.setupTitle()"></strong>
97 + <p x-text="$store.office.setupMessage()"></p>
98 + </div>
99 + <div class="office-setup-progress" :class="{ 'is-paused': !$store.office.isSetupBusy() }" aria-hidden="true">
100 + <span></span>
101 </div>
98 - <div class="office-bootstrap-actions">
99 - <button type="button" class="office-button" @click="$store.office.retry()">
102 + <div class="office-bootstrap-actions" x-show="$store.office.showSetupActions()" style="display: none;">
103 + <button type="button" class="office-button" @click="$store.office.retry()" x-show="$store.office.isSetupBlocked()" style="display: none;">
104 <span class="material-symbols-outlined">restart_alt</span>
105 <span>Retry</span>
106 </button>
107 <button type="button" class="office-button" @click="$store.office.refresh()">
108 <span class="material-symbols-outlined">sync</span>
105 - <span>Status</span>
109 + <span>Refresh</span>
110 </button>
111 </div>
108 - <pre class="office-log" x-text="$store.office.logs?.bootstrap || $store.office.logs?.wrapper || ''"></pre>
112 </div>
113
114 <div class="office-start" x-show="!$store.office.session && $store.office.status?.healthy" style="display: none;">
112 - <div class="office-start-actions">
113 - <button type="button" class="office-create-tile" @click="$store.office.create('document')">
114 - <span class="material-symbols-outlined">article</span>
115 - <span>Document</span>
116 - </button>
117 - <button type="button" class="office-create-tile" @click="$store.office.create('spreadsheet')">
118 - <span class="material-symbols-outlined">table_chart</span>
119 - <span>Spreadsheet</span>
120 - </button>
121 - <button type="button" class="office-create-tile" @click="$store.office.create('presentation')">
122 - <span class="material-symbols-outlined">co_present</span>
123 - <span>Presentation</span>
124 - </button>
125 - </div>
126 - <div class="office-recent" x-show="$store.office.openDocuments.length">
127 - <div class="office-list-label">Open files</div>
128 - <template x-for="doc in $store.office.openDocuments" :key="doc.file_id">
129 - <button type="button" class="office-recent-row" :title="doc.path" @click="$store.office.openPath(doc.path)">
130 - <span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span>
131 - <span class="office-recent-text">
132 - <span x-text="$store.office.openDocumentLabel(doc)"></span>
133 - <small x-text="$store.office.openDocumentMeta(doc)"></small>
134 - </span>
115 + <section class="office-dashboard-section" aria-label="Create Office file">
116 + <div class="office-dashboard-heading">Create</div>
117 + <div class="office-template-grid">
118 + <button type="button" class="office-create-tile" @click="$store.office.create('document')">
119 + <span class="material-symbols-outlined">article</span>
120 + <span>Document</span>
121 </button>
136 - </template>
137 - </div>
138 - <div class="office-recent" x-show="$store.office.recent.length">
139 - <div class="office-list-label">Recent files</div>
140 - <template x-for="doc in $store.office.recent" :key="doc.file_id">
141 - <button type="button" class="office-recent-row" :title="doc.path" @click="$store.office.openPath(doc.path)">
142 - <span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span>
143 - <span class="office-recent-text">
144 - <span x-text="doc.basename"></span>
145 - <small x-text="String(doc.extension || '').toUpperCase()"></small>
146 - </span>
122 + <button type="button" class="office-create-tile" @click="$store.office.create('spreadsheet')">
123 + <span class="material-symbols-outlined">table_chart</span>
124 + <span>Spreadsheet</span>
125 </button>
148 - </template>
149 - </div>
126 + <button type="button" class="office-create-tile" @click="$store.office.create('presentation')">
127 + <span class="material-symbols-outlined">co_present</span>
128 + <span>Presentation</span>
129 + </button>
130 + </div>
131 + </section>
132 +
133 + <section class="office-dashboard-section" x-show="$store.office.openCards().length" aria-label="Open Office files" style="display: none;">
134 + <div class="office-dashboard-heading">Open files</div>
135 + <div class="office-card-grid">
136 + <template x-for="doc in $store.office.openCards()" :key="doc.tab_id">
137 + <button type="button" class="office-document-card is-open" :title="doc.path" @click="$store.office.selectTab(doc.tab_id)">
138 + <span class="office-card-badge">Open</span>
139 + <div class="office-card-preview" :class="`is-${$store.office.previewKind(doc)}`">
140 + <template x-if="$store.office.previewKind(doc) === 'spreadsheet' && $store.office.hasPreview(doc)">
141 + <div class="office-sheet-preview">
142 + <template x-for="(row, rowIndex) in $store.office.previewRows(doc)" :key="rowIndex">
143 + <div class="office-sheet-row">
144 + <template x-for="(cell, cellIndex) in row" :key="cellIndex">
145 + <span x-text="cell"></span>
146 + </template>
147 + </div>
148 + </template>
149 + </div>
150 + </template>
151 + <template x-if="$store.office.previewKind(doc) === 'presentation' && $store.office.hasPreview(doc)">
152 + <div class="office-slide-preview">
153 + <template x-for="(slide, index) in $store.office.previewSlides(doc)" :key="index">
154 + <div class="office-slide-line">
155 + <strong x-text="slide.title"></strong>
156 + <span x-text="(slide.lines || []).join(' / ')"></span>
157 + </div>
158 + </template>
159 + </div>
160 + </template>
161 + <template x-if="$store.office.previewKind(doc) === 'document' && $store.office.hasPreview(doc)">
162 + <div class="office-page-preview">
163 + <template x-for="(line, index) in $store.office.previewLines(doc)" :key="index">
164 + <span x-text="line"></span>
165 + </template>
166 + </div>
167 + </template>
168 + <template x-if="!$store.office.hasPreview(doc)">
169 + <div class="office-preview-fallback">
170 + <span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span>
171 + </div>
172 + </template>
173 + </div>
174 + <span class="office-card-title" x-text="$store.office.dashboardTitle(doc)"></span>
175 + <small x-text="$store.office.dashboardMeta(doc)"></small>
176 + </button>
177 + </template>
178 + </div>
179 + </section>
180 +
181 + <section class="office-dashboard-section" x-show="$store.office.recentCards().length" aria-label="Recent Office files" style="display: none;">
182 + <div class="office-dashboard-heading">Recent files</div>
183 + <div class="office-card-grid">
184 + <template x-for="doc in $store.office.recentCards()" :key="doc.file_id">
185 + <button type="button" class="office-document-card" :title="doc.path" @click="$store.office.openPath(doc.path)">
186 + <div class="office-card-preview" :class="`is-${$store.office.previewKind(doc)}`">
187 + <template x-if="$store.office.previewKind(doc) === 'spreadsheet' && $store.office.hasPreview(doc)">
188 + <div class="office-sheet-preview">
189 + <template x-for="(row, rowIndex) in $store.office.previewRows(doc)" :key="rowIndex">
190 + <div class="office-sheet-row">
191 + <template x-for="(cell, cellIndex) in row" :key="cellIndex">
192 + <span x-text="cell"></span>
193 + </template>
194 + </div>
195 + </template>
196 + </div>
197 + </template>
198 + <template x-if="$store.office.previewKind(doc) === 'presentation' && $store.office.hasPreview(doc)">
199 + <div class="office-slide-preview">
200 + <template x-for="(slide, index) in $store.office.previewSlides(doc)" :key="index">
201 + <div class="office-slide-line">
202 + <strong x-text="slide.title"></strong>
203 + <span x-text="(slide.lines || []).join(' / ')"></span>
204 + </div>
205 + </template>
206 + </div>
207 + </template>
208 + <template x-if="$store.office.previewKind(doc) === 'document' && $store.office.hasPreview(doc)">
209 + <div class="office-page-preview">
210 + <template x-for="(line, index) in $store.office.previewLines(doc)" :key="index">
211 + <span x-text="line"></span>
212 + </template>
213 + </div>
214 + </template>
215 + <template x-if="!$store.office.hasPreview(doc)">
216 + <div class="office-preview-fallback">
217 + <span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span>
218 + </div>
219 + </template>
220 + </div>
221 + <span class="office-card-title" x-text="$store.office.dashboardTitle(doc)"></span>
222 + <small x-text="$store.office.dashboardMeta(doc)"></small>
223 + </button>
224 + </template>
225 + </div>
226 + </section>
227 </div>
228
229 <div class="office-frame-wrap" x-show="$store.office.session" style="display: none;">
@@ -401,7 +478,7 @@
478 .office-icon-button,
479 .office-health-pill,
480 .office-create-tile,
404 - .office-recent-row {
481 + .office-document-card {
482 display: inline-flex;
483 align-items: center;
484 justify-content: center;
@@ -469,7 +546,7 @@
546 .office-button:hover:not(:disabled),
547 .office-icon-button:hover:not(:disabled),
548 .office-create-tile:hover,
472 - .office-recent-row:hover {
549 + .office-document-card:hover {
550 background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
551 border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
552 }
@@ -505,64 +582,144 @@
582 overflow: hidden;
583 }
584
508 - .office-bootstrap,
585 .office-start {
586 display: flex;
587 flex: 1 1 auto;
588 min-width: 0;
589 min-height: 0;
590 flex-direction: column;
515 - gap: 14px;
591 + gap: 22px;
592 padding: 18px;
593 overflow: auto;
594 }
595
520 - .office-bootstrap-header {
521 - display: grid;
522 - grid-template-columns: auto minmax(0, 1fr);
523 - gap: 10px;
524 - align-items: start;
525 - max-width: 720px;
596 + .office-bootstrap {
597 + display: flex;
598 + flex: 1 1 auto;
599 + min-width: 0;
600 + min-height: 0;
601 + flex-direction: column;
602 + align-items: center;
603 + justify-content: center;
604 + gap: 16px;
605 + padding: clamp(24px, 7cqi, 56px);
606 + overflow: auto;
607 + text-align: center;
608 }
609
528 - .office-bootstrap-header > .material-symbols-outlined {
529 - font-size: 24px;
610 + .office-setup-mark {
611 + display: inline-flex;
612 + align-items: center;
613 + justify-content: center;
614 + width: 58px;
615 + height: 58px;
616 + border: 1px solid color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
617 + border-radius: 7px;
618 color: color-mix(in srgb, var(--color-primary) 70%, var(--color-text));
619 + background: color-mix(in srgb, var(--color-panel) 78%, transparent);
620 + }
621 +
622 + .office-setup-mark.is-busy {
623 + border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
624 + }
625 +
626 + .office-setup-mark.is-alert {
627 + border-color: color-mix(in srgb, #f05252 48%, var(--color-border));
628 + color: #f05252;
629 + }
630 +
631 + .office-setup-mark .material-symbols-outlined {
632 + font-size: 30px;
633 + line-height: 1;
634 }
635
533 - .office-bootstrap-header div {
636 + .office-setup-copy {
637 display: flex;
638 + align-items: center;
639 min-width: 0;
640 flex-direction: column;
537 - gap: 3px;
538 - font-size: 0.9rem;
641 + gap: 6px;
642 + max-width: 460px;
643 line-height: 1.35;
644 }
645
542 - .office-bootstrap-header span {
646 + .office-setup-copy > span {
647 + color: var(--color-text-muted);
648 + font-size: 0.74rem;
649 + font-weight: 700;
650 + letter-spacing: 0;
651 + text-transform: uppercase;
652 + }
653 +
654 + .office-setup-copy > strong {
655 + color: var(--color-text);
656 + font-size: clamp(1.05rem, 4cqi, 1.35rem);
657 + font-weight: 760;
658 + }
659 +
660 + .office-setup-copy > p {
661 + margin: 0;
662 color: var(--color-text-muted);
663 + font-size: 0.9rem;
664 + }
665 +
666 + .office-setup-progress {
667 + position: relative;
668 + width: min(260px, 72cqi);
669 + height: 5px;
670 + overflow: hidden;
671 + border-radius: 999px;
672 + background: color-mix(in srgb, var(--color-border) 45%, transparent);
673 + }
674 +
675 + .office-setup-progress > span {
676 + position: absolute;
677 + inset: 0 auto 0 0;
678 + width: 42%;
679 + border-radius: inherit;
680 + background: color-mix(in srgb, var(--color-primary) 72%, var(--color-text) 28%);
681 + animation: office-setup-progress 1.45s ease-in-out infinite;
682 + }
683 +
684 + .office-setup-progress.is-paused > span {
685 + width: 100%;
686 + opacity: 0.42;
687 + animation: none;
688 }
689
690 .office-bootstrap-actions,
547 - .office-start-actions {
691 + .office-template-grid {
692 display: flex;
693 + justify-content: center;
694 flex-wrap: wrap;
695 gap: 8px;
696 }
697
553 - .office-log {
554 - min-height: 140px;
555 - max-height: 260px;
556 - overflow: auto;
557 - margin: 0;
558 - padding: 10px;
559 - border: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
560 - border-radius: 7px;
561 - background: color-mix(in srgb, var(--color-panel) 78%, transparent);
698 + .office-dashboard-section {
699 + display: flex;
700 + flex-direction: column;
701 + gap: 10px;
702 + width: 100%;
703 + max-width: 1180px;
704 + }
705 +
706 + .office-dashboard-heading {
707 color: var(--color-text-muted);
563 - font-family: var(--font-family-code);
564 - font-size: 0.72rem;
565 - white-space: pre-wrap;
708 + font-size: 0.76rem;
709 + font-weight: 700;
710 + letter-spacing: 0;
711 + text-transform: uppercase;
712 + }
713 +
714 + .office-template-grid {
715 + justify-content: flex-start;
716 + }
717 +
718 + .office-card-grid {
719 + display: grid;
720 + grid-template-columns: repeat(auto-fill, minmax(min(190px, 100%), 1fr));
721 + gap: 10px;
722 + width: 100%;
723 }
724
725 .office-create-tile {
@@ -577,45 +734,182 @@
734 font-size: 28px;
735 }
736
580 - .office-recent {
737 + .office-document-card {
738 + position: relative;
739 + display: grid;
740 + grid-template-rows: auto auto auto;
741 + align-content: start;
742 + justify-content: stretch;
743 + gap: 8px;
744 + min-width: 0;
745 + min-height: 196px;
746 + padding: 10px;
747 + text-align: left;
748 + overflow: hidden;
749 + }
750 +
751 + .office-document-card.is-open {
752 + border-color: color-mix(in srgb, var(--color-primary) 36%, var(--color-border));
753 + }
754 +
755 + .office-card-badge {
756 + position: absolute;
757 + top: 8px;
758 + right: 8px;
759 + z-index: 2;
760 + max-width: calc(100% - 16px);
761 + overflow: hidden;
762 + padding: 2px 6px;
763 + border: 1px solid color-mix(in srgb, var(--color-primary) 42%, transparent);
764 + border-radius: 999px;
765 + background: color-mix(in srgb, var(--color-background) 82%, transparent);
766 + color: var(--color-text);
767 + font-size: 0.68rem;
768 + font-weight: 700;
769 + line-height: 1.2;
770 + text-overflow: ellipsis;
771 + white-space: nowrap;
772 + }
773 +
774 + .office-card-preview {
775 + position: relative;
776 + display: grid;
777 + align-items: stretch;
778 + width: 100%;
779 + aspect-ratio: 16 / 10;
780 + min-height: 112px;
781 + overflow: hidden;
782 + border: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
783 + border-radius: 6px;
784 + background: color-mix(in srgb, var(--color-background) 76%, #fff 4%);
785 + }
786 +
787 + .office-page-preview,
788 + .office-sheet-preview,
789 + .office-slide-preview,
790 + .office-preview-fallback {
791 + min-width: 0;
792 + min-height: 0;
793 + }
794 +
795 + .office-page-preview {
796 display: flex;
797 flex-direction: column;
583 - gap: 6px;
584 - max-width: 720px;
798 + gap: 5px;
799 + padding: 12px 13px;
800 + background:
801 + linear-gradient(to bottom, transparent 0, transparent 21px, color-mix(in srgb, var(--color-border) 30%, transparent) 22px),
802 + color-mix(in srgb, var(--color-panel) 72%, transparent);
803 + background-size: 100% 22px;
804 + color: var(--color-text);
805 }
806
587 - .office-list-label {
588 - margin-top: 2px;
589 - color: var(--color-text-muted);
590 - font-size: 0.76rem;
591 - font-weight: 650;
592 - text-transform: uppercase;
807 + .office-page-preview span,
808 + .office-slide-line span,
809 + .office-slide-line strong,
810 + .office-sheet-row span {
811 + min-width: 0;
812 + overflow: hidden;
813 + text-overflow: ellipsis;
814 + white-space: nowrap;
815 }
816
595 - .office-recent-row {
596 - justify-content: flex-start;
597 - min-height: 36px;
598 - padding: 7px 9px;
599 - text-align: left;
817 + .office-page-preview span {
818 + font-size: 0.7rem;
819 + line-height: 1.25;
820 }
821
602 - .office-recent-text {
822 + .office-sheet-preview {
823 + min-width: 0;
824 + padding: 8px;
825 + background: color-mix(in srgb, var(--color-panel) 74%, transparent);
826 + }
827 +
828 + .office-sheet-row {
829 display: grid;
830 + grid-template-columns: repeat(4, minmax(0, 1fr));
831 + min-height: 20px;
832 + }
833 +
834 + .office-sheet-row + .office-sheet-row {
835 + border-top: 1px solid color-mix(in srgb, var(--color-border) 34%, transparent);
836 + }
837 +
838 + .office-sheet-row span {
839 + padding: 4px 5px;
840 + border-right: 1px solid color-mix(in srgb, var(--color-border) 34%, transparent);
841 + color: var(--color-text-muted);
842 + font-size: 0.66rem;
843 + line-height: 1.1;
844 + }
845 +
846 + .office-sheet-row span:last-child {
847 + border-right: 0;
848 + }
849 +
850 + .office-slide-preview {
851 + display: flex;
852 + flex-direction: column;
853 + justify-content: center;
854 + gap: 10px;
855 + padding: 14px;
856 + background:
857 + linear-gradient(135deg, color-mix(in srgb, var(--color-panel) 80%, transparent), color-mix(in srgb, var(--color-background) 84%, var(--color-primary) 10%));
858 + }
859 +
860 + .office-slide-line {
861 + display: flex;
862 min-width: 0;
605 - gap: 1px;
606 - line-height: 1.2;
863 + flex-direction: column;
864 + gap: 3px;
865 }
866
609 - .office-recent-text > span,
610 - .office-recent-text > small {
867 + .office-slide-line strong {
868 + color: var(--color-text);
869 + font-size: 0.78rem;
870 + font-weight: 760;
871 + line-height: 1.15;
872 + }
873 +
874 + .office-slide-line span {
875 + color: var(--color-text-muted);
876 + font-size: 0.68rem;
877 + line-height: 1.15;
878 + }
879 +
880 + .office-preview-fallback {
881 + display: flex;
882 + align-items: center;
883 + justify-content: center;
884 + color: color-mix(in srgb, var(--color-primary) 64%, var(--color-text));
885 + background: color-mix(in srgb, var(--color-panel) 76%, transparent);
886 + }
887 +
888 + .office-preview-fallback .material-symbols-outlined {
889 + font-size: 36px;
890 + }
891 +
892 + .office-card-title {
893 + display: block;
894 + min-width: 0;
895 overflow: hidden;
896 text-overflow: ellipsis;
897 white-space: nowrap;
898 + color: var(--color-text);
899 + font-size: 0.86rem;
900 + font-weight: 720;
901 + line-height: 1.2;
902 }
903
616 - .office-recent-text > small {
904 + .office-document-card small {
905 + display: block;
906 + min-width: 0;
907 + overflow: hidden;
908 color: var(--color-text-muted);
618 - font-size: 0.72rem;
909 + font-size: 0.7rem;
910 + line-height: 1.2;
911 + text-overflow: ellipsis;
912 + white-space: nowrap;
913 }
914
915 .office-frame-wrap {
@@ -649,6 +943,19 @@
943 to { transform: rotate(360deg); }
944 }
945
946 + @keyframes office-setup-progress {
947 + 0% { transform: translateX(-110%); }
948 + 55% { transform: translateX(85%); }
949 + 100% { transform: translateX(250%); }
950 + }
951 +
952 + @media (prefers-reduced-motion: reduce) {
953 + .office-panel .spinning,
954 + .office-setup-progress > span {
955 + animation: none;
956 + }
957 + }
958 +
959 @media (max-width: 520px) {
960 .office-button span:last-child {
961 display: none;
plugins/_office/webui/office-store.js
+170 -13
@@ -5,6 +5,7 @@ const FRAME_NAME_PREFIX = "a0-office-frame";
5 const COLLABORA_STATE_VERSION = "2026-04-26.1";
6 const COLLABORA_STATE_MARKER = "a0.office.collaboraStateVersion";
7 const SERVICE_WORKER_CLEANUP_MARKER = "a0.office.serviceWorkerCleanupReloaded";
8 +const SETUP_POLL_INTERVAL_MS = 4000;
9
10 function makeFrameName() {
11 const id = globalThis.crypto?.randomUUID?.()
@@ -49,9 +50,22 @@ function sameDocument(left = {}, right = {}) {
50 return Boolean(leftPath && rightPath && leftPath === rightPath);
51 }
52
53 +function formatBytes(value) {
54 + const size = Number(value || 0);
55 + if (!Number.isFinite(size) || size <= 0) return "";
56 + const units = ["B", "KB", "MB", "GB"];
57 + let amount = size;
58 + let index = 0;
59 + while (amount >= 1024 && index < units.length - 1) {
60 + amount /= 1024;
61 + index += 1;
62 + }
63 + const digits = amount >= 10 || index === 0 ? 0 : 1;
64 + return `${amount.toFixed(digits)} ${units[index]}`;
65 +}
66 +
67 const model = {
68 status: null,
54 - logs: null,
69 recent: [],
70 openDocuments: [],
71 tabs: [],
@@ -72,6 +86,7 @@ const model = {
86 _mode: "canvas",
87 _floatingCleanup: null,
88 _saveWaiters: [],
89 + _statusPollTimer: null,
90
91 async init(element = null) {
92 return await this.onMount(element, { mode: "canvas" });
@@ -115,6 +130,7 @@ const model = {
130 cleanup() {
131 this._floatingCleanup?.();
132 this._floatingCleanup = null;
133 + this.clearStatusPoll();
134 if (this._mode === "modal") {
135 this._root = null;
136 }
@@ -125,20 +141,103 @@ const model = {
141 this.status = await callJsonApi("/plugins/_office/office_session", { action: "status" });
142 const recent = await callJsonApi("/plugins/_office/office_session", { action: "recent" });
143 this.recent = recent?.documents || [];
128 - const openDocuments = await callJsonApi("/plugins/_office/office_session", { action: "open_documents" });
129 - this.openDocuments = openDocuments?.documents || [];
130 - if (!this.status?.healthy) {
131 - const logs = await callJsonApi("/plugins/_office/collabora_logs", {});
132 - this.logs = logs;
144 + if (this.status?.healthy) {
145 + await this.syncOpenSessions();
146 + } else {
147 + this.openDocuments = [];
148 }
149 } catch (error) {
150 this.error = error instanceof Error ? error.message : String(error);
151 + } finally {
152 + this.scheduleStatusPoll();
153 }
154 },
155
156 + async syncOpenSessions() {
157 + const sessionIds = this.tabs
158 + .map((tab) => normalizeTabId(tab?.session_id))
159 + .filter(Boolean);
160 + const response = await callJsonApi("/plugins/_office/office_session", {
161 + action: "sync_open_sessions",
162 + session_ids: sessionIds,
163 + });
164 + this.openDocuments = response?.documents || [];
165 + return response;
166 + },
167 +
168 async retry() {
140 - this.message = "Retrying Collabora setup...";
169 + this.message = "Retrying Office setup...";
170 this.status = await callJsonApi("/plugins/_office/office_session", { action: "retry" });
171 + this.scheduleStatusPoll();
172 + },
173 +
174 + clearStatusPoll() {
175 + if (!this._statusPollTimer) return;
176 + globalThis.clearTimeout(this._statusPollTimer);
177 + this._statusPollTimer = null;
178 + },
179 +
180 + scheduleStatusPoll() {
181 + this.clearStatusPoll();
182 + if (!this.shouldPollSetup()) return;
183 + this._statusPollTimer = globalThis.setTimeout(() => {
184 + this._statusPollTimer = null;
185 + void this.refresh();
186 + }, SETUP_POLL_INTERVAL_MS);
187 + },
188 +
189 + shouldPollSetup() {
190 + if (this.session || this.status?.healthy) return false;
191 + if (!this.status) return true;
192 + const state = String(this.status.state || "").toLowerCase();
193 + return Boolean(this.status.installing || state === "installing" || state === "idle");
194 + },
195 +
196 + setupState() {
197 + return String(this.status?.state || "installing").toLowerCase();
198 + },
199 +
200 + isSetupBusy() {
201 + const state = this.setupState();
202 + return !this.status || Boolean(this.status.installing) || state === "installing" || state === "idle";
203 + },
204 +
205 + isSetupBlocked() {
206 + const state = this.setupState();
207 + return state === "failed" || state === "degraded";
208 + },
209 +
210 + showSetupActions() {
211 + return this.isSetupBlocked() || (!this.isSetupBusy() && !this.status?.healthy);
212 + },
213 +
214 + setupIcon() {
215 + return this.isSetupBlocked() ? "error" : "progress_activity";
216 + },
217 +
218 + setupTitle() {
219 + if (this.isSetupBlocked()) return "Setup needs attention";
220 + return "Setup in progress";
221 + },
222 +
223 + setupMessage() {
224 + if (this.isSetupBlocked()) {
225 + return "Office could not finish setup. Retry when you are ready.";
226 + }
227 + return "Please wait while Office is prepared. This can take a few minutes the first time.";
228 + },
229 +
230 + healthTitle() {
231 + if (this.status?.healthy) return "Office is ready";
232 + if (this.isSetupBlocked()) return "Office setup needs attention";
233 + if (this.isSetupBusy()) return "Office setup is in progress";
234 + return "Office status";
235 + },
236 +
237 + healthText() {
238 + if (this.isSetupBlocked()) return "attention";
239 + if (this.isSetupBusy()) return "setup";
240 + return String(this.status?.state || "status");
241 },
242
243 async create(kind = "document") {
@@ -491,13 +590,71 @@ const model = {
590 return basename || path.split("/").filter(Boolean).pop() || "Office file";
591 },
592
494 - openDocumentMeta(doc) {
495 - const sessions = Number(doc?.open_sessions || 0);
593 + openCards() {
594 + return this.tabs.map((tab) => ({ ...tab, dashboard_open: true }));
595 + },
596 +
597 + recentCards() {
598 + const openFileIds = new Set(this.tabs.map((tab) => normalizeTabId(tab?.file_id)).filter(Boolean));
599 + return (this.recent || []).filter((doc) => !openFileIds.has(normalizeTabId(doc?.file_id)));
600 + },
601 +
602 + dashboardTitle(doc) {
603 + return this.openDocumentLabel(doc);
604 + },
605 +
606 + dashboardMeta(doc) {
607 const extension = String(doc?.extension || "").trim().toUpperCase();
497 - return [
498 - extension,
499 - sessions ? `${sessions} session${sessions === 1 ? "" : "s"}` : "",
500 - ].filter(Boolean).join(" / ");
608 + const size = formatBytes(doc?.size);
609 + return [extension, size].filter(Boolean).join(" / ");
610 + },
611 +
612 + previewKind(doc) {
613 + const kind = String(doc?.preview?.kind || "").trim();
614 + if (kind === "spreadsheet" && !doc?.preview?.rows?.length && doc?.preview?.lines?.length) return "document";
615 + if (kind === "presentation" && !doc?.preview?.slides?.length && doc?.preview?.lines?.length) return "document";
616 + if (kind) return kind;
617 + const extension = String(doc?.extension || "").toLowerCase();
618 + if (["xlsx", "ods"].includes(extension)) return "spreadsheet";
619 + if (["pptx", "odp"].includes(extension)) return "presentation";
620 + if (["docx", "odt"].includes(extension)) return "document";
621 + return "file";
622 + },
623 +
624 + hasPreview(doc) {
625 + const preview = doc?.preview || {};
626 + return Boolean(
627 + preview.available
628 + && (
629 + preview.lines?.length
630 + || preview.rows?.length
631 + || preview.slides?.length
632 + )
633 + );
634 + },
635 +
636 + previewLines(doc) {
637 + const lines = doc?.preview?.lines || [];
638 + if (lines.length) return lines.slice(0, 5).map((line) => String(line || ""));
639 + const slides = doc?.preview?.slides || [];
640 + if (slides.length) {
641 + return [slides[0]?.title, ...(slides[0]?.lines || [])].filter(Boolean).slice(0, 5);
642 + }
643 + return [];
644 + },
645 +
646 + previewRows(doc) {
647 + return (doc?.preview?.rows || [])
648 + .slice(0, 5)
649 + .map((row) => {
650 + const cells = (Array.isArray(row) ? row : []).slice(0, 4).map((cell) => String(cell ?? ""));
651 + while (cells.length < 4) cells.push("");
652 + return cells;
653 + });
654 + },
655 +
656 + previewSlides(doc) {
657 + return (doc?.preview?.slides || []).slice(0, 2);
658 },
659
660 onPostMessage(event) {
tests/test_office_canvas_setup.py new
+53
@@ -0,0 +1,53 @@
1 +from __future__ import annotations
2 +
3 +from pathlib import Path
4 +
5 +
6 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
7 +
8 +
9 +def test_office_setup_canvas_uses_simple_progress_instead_of_install_logs():
10 + panel = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-panel.html").read_text(
11 + encoding="utf-8",
12 + )
13 + store = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-store.js").read_text(
14 + encoding="utf-8",
15 + )
16 +
17 + assert "Agent Zero Office" in panel
18 + assert "setupTitle()" in panel
19 + assert "Setup in progress" in store
20 + assert "office-log" not in panel
21 + assert "collabora_logs" not in store
22 +
23 +
24 +def test_office_dashboard_uses_cards_and_visible_tabs_for_open_files():
25 + panel = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-panel.html").read_text(
26 + encoding="utf-8",
27 + )
28 + store = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-store.js").read_text(
29 + encoding="utf-8",
30 + )
31 +
32 + assert "office-card-grid" in panel
33 + assert "office-document-card" in panel
34 + assert "openCards()" in panel
35 + assert "recentCards()" in panel
36 + assert "office-recent-row" not in panel
37 + assert "sync_open_sessions" in store
38 +
39 +
40 +def test_right_canvas_keeps_restored_office_surface_until_registration_finishes():
41 + canvas_store = (
42 + PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas-store.js"
43 + ).read_text(encoding="utf-8")
44 +
45 + init_registration = canvas_store.index('await callJsExtensions("right_canvas_register_surfaces", this);')
46 + init_ensure = canvas_store.index("this.ensureActiveSurface();", init_registration)
47 + register_surface = canvas_store.index("registerSurface(surface)")
48 + register_guard = canvas_store.index("if (!this._registering)", register_surface)
49 + guarded_ensure = canvas_store.index("this.ensureActiveSurface();", register_guard)
50 + open_surface = canvas_store.index("async open", register_surface)
51 +
52 + assert init_registration < init_ensure
53 + assert register_surface < register_guard < guarded_ensure < open_surface
tests/test_office_wopi_store.py
+33
@@ -106,6 +106,39 @@ def test_close_session_revokes_token_lock_and_open_document_metadata(office_stat
106 assert wopi_store.close_session(session_id=session["session_id"]) == 0
107
108
109 +def test_sync_open_sessions_closes_sessions_without_visible_tabs(office_state):
110 + first = wopi_store.create_document("document", "Visible", "docx", "shown")
111 + second = wopi_store.create_document("document", "Orphan", "docx", "hidden")
112 + visible = wopi_store.create_session(first["file_id"], "user-a", "write", "http://localhost:32080")
113 + orphan = wopi_store.create_session(second["file_id"], "user-a", "write", "http://localhost:32080")
114 + ok, _ = wopi_store.lock(second["file_id"], "orphan-lock", orphan["session_id"], 120)
115 + assert ok is True
116 +
117 + assert wopi_store.sync_open_sessions([visible["session_id"]]) == 1
118 +
119 + open_docs = wopi_store.get_open_documents()
120 + assert len(open_docs) == 1
121 + assert open_docs[0]["file_id"] == first["file_id"]
122 + assert wopi_store.get_lock(second["file_id"]) == ""
123 + with pytest.raises(PermissionError):
124 + wopi_store.validate_token(orphan["access_token"], second["file_id"])
125 +
126 +
127 +def test_recent_documents_include_lightweight_previews(office_state):
128 + doc = wopi_store.create_document("document", "Preview Memo", "docx", "A calm dashboard.")
129 + sheet = wopi_store.create_document("spreadsheet", "Preview Sheet", "xlsx", "Name,Value\nOffice,1")
130 + deck = wopi_store.create_document("presentation", "Preview Deck", "pptx", "First slide")
131 +
132 + previews = {
133 + item["file_id"]: item["preview"]
134 + for item in wopi_store.get_recent_documents(limit=3)
135 + }
136 +
137 + assert previews[doc["file_id"]]["lines"][0] == "Preview Memo"
138 + assert previews[sheet["file_id"]]["rows"][0] == ["Name", "Value"]
139 + assert previews[deck["file_id"]]["slides"][0]["title"] == "Preview Deck"
140 +
141 +
142 def test_put_file_requires_lock_and_updates_version_history(office_state):
143 doc = wopi_store.create_document("document", "Save Test", "docx", "before")
144 session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
webui/components/canvas/right-canvas-store.js
+3 -1
@@ -81,7 +81,9 @@ const model = {
81 this.surfaces.push(normalized);
82 }
83 this.surfaces.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
84 - this.ensureActiveSurface();
84 + if (!this._registering) {
85 + this.ensureActiveSurface();
86 + }
87 },
88
89 ensureActiveSurface() {