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() {