From 3da1cf1ab2cbabbaec10209c5235fb477471e147 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 20:55:43 +0000 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20add=20UI=20design=20plan=20?= =?UTF-8?q?=E2=80=94=20NativeAOT=20Minimal=20API=20+=20Fluent=20UI=20Web?= =?UTF-8?q?=20Components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architecture plan for a read-only session browser UI (docx-ui binary): - Back-end: Kestrel Minimal API with CreateSlimBuilder (NativeAOT, zero reflection, source-generated JSON) - Front-end: vanilla JS SPA with Fluent UI Web Components (Microsoft design system) and docx-preview.js for native DOCX rendering - Real-time: SSE (Server-Sent Events) via FileSystemWatcher + polling fallback for live session/patch notifications - 5 REST endpoints: sessions list, detail, DOCX bytes at position, history, and SSE event stream - LRU cache for reconstructed documents (slider scrubbing performance) - CLI integration: `docx-cli server [--port N]` launches docx-ui - No npm/webpack/vite: vendorized JS libs, ES modules, zero build step https://claude.ai/code/session_01AVZcLrhY6w4QujyzEAnLUJ --- docs/PLAN-UI.md | 1180 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1180 insertions(+) create mode 100644 docs/PLAN-UI.md diff --git a/docs/PLAN-UI.md b/docs/PLAN-UI.md new file mode 100644 index 0000000..a3e68b9 --- /dev/null +++ b/docs/PLAN-UI.md @@ -0,0 +1,1180 @@ +# Plan : UI de consultation des sessions docx-mcp + +## Contexte + +Le serveur MCP `docx-mcp` gere des sessions de documents DOCX avec un systeme de persistence base sur : +- **Baseline snapshots** (`.docx`) : etat initial du document +- **Write-Ahead Log** (`.wal`) : journal de toutes les mutations (patches RFC 6902 adaptes OOXML) +- **Checkpoints** (`.ckpt.N.docx`) : snapshots intermediaires tous les N patches +- **Index** (`index.json`) : metadonnees de toutes les sessions + +L'objectif est de creer une UI de consultation read-only, **legere et compilable en NativeAOT**, qui permet de naviguer dans les documents et de visualiser l'evolution de chaque session au fil des patches, avec un rendu fidele du DOCX natif dans le navigateur. + +--- + +## 1. Contrainte : NativeAOT, zero reflexion, binaire leger + +Le binaire `docx-ui` doit etre aussi compact que `docx-mcp` (~28 MB NativeAOT). Cela exclut : +- **Blazor Server** (depend de la reflexion pour le rendu des composants) +- **Blazor WebAssembly** (meme probleme + gros payload WASM) +- **MVC / Razor Pages** (reflection pour les vues) + +La solution : **Kestrel Minimal API (NativeAOT) + SPA statique en pur JS**. + +| Couche | Technologie | NativeAOT | Reflexion | +|--------|-------------|-----------|-----------| +| Serveur HTTP | ASP.NET Core Minimal API (`CreateSlimBuilder`) | Oui | Non | +| Serialisation JSON | `System.Text.Json` source-generated | Oui | Non | +| Frontend shell | Fluent UI Web Components (`@fluentui/web-components`) | N/A (JS pur) | N/A | +| Rendu DOCX | `docx-preview` (JS, ~280 KB) | N/A (JS pur) | N/A | +| Streaming temps reel | SSE (`text/event-stream`) | Oui | Non | + +**Resultat** : le back-end .NET est un serveur de fichiers statiques + 5 endpoints REST/SSE. Toute la logique UI est en JavaScript vanilla + Web Components Microsoft. + +--- + +## 2. Trois piliers technologiques + +### 2.1 Kestrel Minimal API (back-end NativeAOT) + +ASP.NET Core Minimal API est entierement compatible NativeAOT depuis .NET 8 via `WebApplication.CreateSlimBuilder()`. Pas de controllers, pas de reflexion, pas de MVC. + +```csharp +var builder = WebApplication.CreateSlimBuilder(args); + +// Source-generated JSON +builder.Services.ConfigureHttpJsonOptions(o => + o.SerializerOptions.TypeInfoResolverChain.Add(UiJsonContext.Default)); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); +app.UseStaticFiles(); // sert wwwroot/ (le SPA) + +// 5 endpoints REST + SSE (voir section 4) +app.MapGet("/api/sessions", ...); +app.MapGet("/api/sessions/{id}", ...); +app.MapGet("/api/sessions/{id}/docx", ...); +app.MapGet("/api/sessions/{id}/history", ...); +app.MapGet("/api/events", ...); // SSE + +app.Run(); +``` + +### 2.2 Fluent UI Web Components (frontend Microsoft, pur JS) + +[`@fluentui/web-components`](https://github.com/microsoft/fluentui/tree/master/packages/web-components) sont des **Web Components standards** (Custom Elements + Shadow DOM) qui implementent le Fluent Design System de Microsoft. Ils fonctionnent avec du JavaScript vanilla, sans React, Angular, ni framework. + +**Chargement** : un seul fichier JS (~150 KB gzip) via CDN ou vendorise : + +```html + +``` + +**Composants utilises** : + +| Composant | Usage | +|-----------|-------| +| `` | Liste des sessions, historique des patches | +| `` / `` | Arborescence du document | +| `` | Barre d'outils (export, navigation) | +| `` | Boutons d'action | +| `` | Curseur de scrubbing dans la timeline | +| `` / `` / `` | Onglets Document / Diff | +| `` | Dialogues d'export | +| `` | Marqueurs (checkpoint, position courante, SSE status) | +| `` | Toggle dark/light theme | +| `` | Loading spinner pendant le rebuild | +| `` | Separateurs visuels | +| `` | Carte pour chaque session dans la liste | + +**Theming** (dark/light mode natif Fluent) : + +```html + + accent-base-color="#0078d4"> + + +``` + +### 2.3 docx-preview.js (rendu DOCX natif dans le navigateur) + +Au lieu de convertir le DOCX en HTML cote serveur (perte de fidelite), on envoie les **bytes DOCX bruts** au navigateur et on les rend via [`docx-preview`](https://www.npmjs.com/package/docx-preview) (librairie JS open-source, ~280 KB). + +**Pourquoi** : +- Rendu fidele de la mise en page Word (marges, headers/footers, page breaks, styles) +- Pas de conversion HTML lossy cote serveur +- Rendu client-side pur : le serveur ne fait que servir les bytes +- Supporte les images (base64), les tableaux, les styles, les listes numerotees + +**Utilisation** : + +```javascript +// Fetch les bytes DOCX depuis l'API, puis rendre +const response = await fetch(`/api/sessions/${sessionId}/docx?position=${pos}`); +const blob = await response.blob(); + +await docx.renderAsync(blob, container, styleContainer, { + className: "docx-preview", + inWrapper: true, + ignoreWidth: false, + ignoreHeight: false, + ignoreFonts: false, + breakPages: true, + useBase64URL: false, // pas de SignalR, on peut utiliser blob URL directement + experimental: true, + trimXmlDeclaration: true +}); +``` + +**Note** : contrairement a Blazor Server, ici les bytes transitent via HTTP classique (pas SignalR), donc `useBase64URL: false` est possible, ce qui donne de meilleures performances pour les images. + +### 2.4 SSE (Server-Sent Events) pour le streaming temps reel + +``` +┌──────────────┐ file I/O ┌──────────────────┐ +│ MCP Server │ ──── WAL/Index ──→│ Sessions Dir │ +│ (docx-mcp) │ │ ~/.docx-mcp/ │ +└──────────────┘ │ sessions/ │ + └────────┬─────────┘ + │ FileSystemWatcher + │ + polling fallback +┌──────────────┐ SSE stream ┌────────▼─────────┐ +│ Browser │ ←── text/event ───│ UI Server │ +│ (Fluent UI) │ -stream │ (docx-ui) │ +└──────────────┘ └──────────────────┘ +``` + +Le serveur UI surveille le repertoire sessions (`FileSystemWatcher` + polling fallback 2s pour Docker/NFS) et emet des evenements SSE : + +``` +event: index.changed +data: {"type":"index.changed","timestamp":"2026-02-02T14:23:00Z"} + +event: session.patched +data: {"type":"session.patched","sessionId":"abc","position":16,"timestamp":"..."} +``` + +Cote navigateur, l'API standard `EventSource` gere la connexion, la reconnexion automatique, et le parsing : + +```javascript +const events = new EventSource("/api/events"); +events.addEventListener("index.changed", (e) => { + refreshSessionList(); +}); +events.addEventListener("session.patched", (e) => { + const data = JSON.parse(e.data); + if (data.sessionId === currentSessionId) { + updateTimelineMax(data.position); + } +}); +``` + +**Avantages** : +- Latence quasi-nulle (pas de polling) +- Reconnexion automatique integree (spec HTML5) +- Fonctionne a travers les proxys HTTP +- Zero overhead quand rien ne change + +--- + +## 3. Architecture du projet + +### Nouveau projet : `DocxMcp.Ui` + +``` +src/DocxMcp.Ui/ +├── DocxMcp.Ui.csproj +├── Program.cs # CreateSlimBuilder + endpoints + static files +├── UiJsonContext.cs # Source-generated JSON serializer +├── Services/ +│ ├── SessionBrowserService.cs # Lecture read-only + reconstruction + LRU cache +│ └── EventBroadcaster.cs # FileSystemWatcher → Channel → SSE +├── Models/ +│ ├── SessionEvent.cs # Evenements SSE +│ ├── SessionListItem.cs # DTO pour /api/sessions +│ ├── SessionDetailDto.cs # DTO pour /api/sessions/{id} +│ └── HistoryEntryDto.cs # DTO pour /api/sessions/{id}/history +└── wwwroot/ # SPA statique (aucun build step requis) + ├── index.html # Point d'entree HTML + ├── css/ + │ ├── app.css # Layout, theming, docx container, diff styles + │ └── diff.css # Surlignage avant/apres + ├── js/ + │ ├── app.js # Router SPA + composants + SSE client + │ ├── docxRenderer.js # Wrapper docx-preview renderAsync + │ ├── sseClient.js # EventSource manager avec reconnexion + │ ├── diffView.js # Logique diff side-by-side + │ └── documentTree.js # Generation du fluent-tree-view + ├── lib/ # Vendorise (pas de npm/node requis) + │ ├── docx-preview.min.js # ~280 KB + │ └── fluent-web-components.min.js # ~150 KB gzip + └── favicon.ico +``` + +### `.csproj` + +```xml + + + net10.0 + DocxMcp.Ui + docx-ui + enable + enable + true + false + Size + + + + + + +``` + +**Zero dependance NuGet supplementaire** : seul le SDK Web + la reference au projet principal suffisent. Pas de Fluent UI Blazor, pas de packages JS build-time. + +### Ajout a la solution + Dockerfile + +Le binaire `docx-ui` peut etre produit dans la meme image Docker multi-binaires existante : + +```dockerfile +# Stage: build docx-ui (NativeAOT) +RUN dotnet publish src/DocxMcp.Ui -c Release -o /out/ui +``` + +--- + +## 4. Endpoints HTTP (API REST + SSE) + +Tous les endpoints utilisent la serialisation JSON source-generated (NativeAOT-safe). + +### `GET /api/sessions` + +Liste toutes les sessions depuis `index.json`. + +```csharp +app.MapGet("/api/sessions", (SessionBrowserService svc) => + Results.Ok(svc.ListSessions())); + +// Retourne : SessionListItem[] +// { id, sourcePath, createdAt, lastModifiedAt, walCount, cursorPosition } +``` + +### `GET /api/sessions/{id}` + +Detail d'une session (metadonnees + info checkpoints). + +```csharp +app.MapGet("/api/sessions/{id}", (string id, SessionBrowserService svc) => +{ + var detail = svc.GetSessionDetail(id); + return detail is null ? Results.NotFound() : Results.Ok(detail); +}); + +// Retourne : SessionDetailDto +// { id, sourcePath, createdAt, lastModifiedAt, walCount, cursorPosition, +// checkpointPositions: int[] } +``` + +### `GET /api/sessions/{id}/docx?position={N}` + +Retourne les bytes DOCX reconstruits a la position N (pour `docx-preview.js`). + +```csharp +app.MapGet("/api/sessions/{id}/docx", (string id, int? position, SessionBrowserService svc) => +{ + var pos = position ?? svc.GetCurrentPosition(id); + var bytes = svc.GetDocxBytesAtPosition(id, pos); + return Results.File(bytes, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + $"{id}-pos{pos}.docx"); +}); +``` + +### `GET /api/sessions/{id}/history?offset={N}&limit={N}` + +Historique pagine des WAL entries. + +```csharp +app.MapGet("/api/sessions/{id}/history", + (string id, int? offset, int? limit, SessionBrowserService svc) => + Results.Ok(svc.GetHistory(id, offset ?? 0, limit ?? 50))); + +// Retourne : HistoryEntryDto[] +// { position, timestamp, description, isCheckpoint } +``` + +### `GET /api/events` (SSE) + +Stream temps reel. + +```csharp +app.MapGet("/api/events", async (HttpContext ctx, EventBroadcaster broadcaster) => +{ + ctx.Response.ContentType = "text/event-stream"; + ctx.Response.Headers.CacheControl = "no-cache"; + ctx.Response.Headers.Connection = "keep-alive"; + + var channel = Channel.CreateUnbounded(); + broadcaster.Subscribe(channel.Writer); + try + { + await foreach (var evt in channel.Reader.ReadAllAsync(ctx.RequestAborted)) + { + var json = JsonSerializer.Serialize(evt, UiJsonContext.Default.SessionEvent); + await ctx.Response.WriteAsync($"event: {evt.Type}\ndata: {json}\n\n"); + await ctx.Response.Body.FlushAsync(); + } + } + finally { broadcaster.Unsubscribe(channel.Writer); } +}); +``` + +### Source-generated JSON context + +```csharp +[JsonSerializable(typeof(SessionListItem[]))] +[JsonSerializable(typeof(SessionDetailDto))] +[JsonSerializable(typeof(HistoryEntryDto[]))] +[JsonSerializable(typeof(SessionEvent))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +internal partial class UiJsonContext : JsonSerializerContext { } +``` + +--- + +## 5. Services back-end (.NET, NativeAOT-safe) + +### 5.1 `SessionBrowserService` : lecture read-only + LRU cache + +```csharp +public sealed class SessionBrowserService +{ + private readonly SessionStore _store; + private readonly ILogger _logger; + + // Cache LRU : (sessionId, position) → byte[] + // Evite de reconstruire le document a chaque deplacement du slider + private readonly LruCache<(string, int), byte[]> _docxCache = new(capacity: 20); + + // --- Lecture --- + + public SessionListItem[] ListSessions() + { + var index = _store.LoadIndex(); + return index.Sessions.Select(e => new SessionListItem { ... }).ToArray(); + } + + public SessionDetailDto? GetSessionDetail(string sessionId) { ... } + + public int GetCurrentPosition(string sessionId) + { + var index = _store.LoadIndex(); + var entry = index.Sessions.Find(e => e.Id == sessionId); + return entry?.CursorPosition ?? 0; + } + + public HistoryEntryDto[] GetHistory(string id, int offset, int limit) + { + var entries = _store.ReadWalEntries(id); + var index = _store.LoadIndex(); + var session = index.Sessions.Find(e => e.Id == id); + var checkpoints = session?.CheckpointPositions ?? new List(); + + return entries + .Select((e, i) => new HistoryEntryDto + { + Position = i + 1, + Timestamp = e.Timestamp, + Description = e.Description ?? "(no description)", + IsCheckpoint = checkpoints.Contains(i + 1) + }) + .Skip(offset) + .Take(limit) + .ToArray(); + } + + // --- Reconstruction DOCX a une position --- + + public byte[] GetDocxBytesAtPosition(string sessionId, int position) + { + var key = (sessionId, position); + if (_docxCache.TryGet(key, out var cached)) + return cached; + + var bytes = RebuildAtPosition(sessionId, position); + _docxCache.Set(key, bytes); + return bytes; + } + + private byte[] RebuildAtPosition(string sessionId, int position) + { + // 1. Charger le checkpoint le plus proche + var index = _store.LoadIndex(); + var entry = index.Sessions.Find(e => e.Id == sessionId) + ?? throw new KeyNotFoundException($"Session '{sessionId}' not found."); + var checkpoints = entry.CheckpointPositions ?? new List(); + var (ckptPos, ckptBytes) = _store.LoadNearestCheckpoint(sessionId, position, checkpoints); + + // 2. Creer une session temporaire en memoire + using var session = DocxSession.FromBytes(ckptBytes, sessionId, entry.SourcePath); + + // 3. Rejouer les patches du WAL + if (position > ckptPos) + { + var patches = _store.ReadWalRange(sessionId, ckptPos, position); + foreach (var patchJson in patches) + { + try { SessionManager.ReplayPatch(session, patchJson); } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to replay patch during rebuild."); + break; + } + } + } + + // 4. Extraire les bytes + session.Document.Save(); + return session.Stream.ToArray(); + } +} +``` + +### 5.2 `EventBroadcaster` : FileSystemWatcher → SSE + +```csharp +public sealed class EventBroadcaster : IDisposable +{ + private readonly string _sessionsDir; + private FileSystemWatcher? _watcher; + private Timer? _pollTimer; + private readonly List> _subscribers = new(); + private readonly Lock _lock = new(); + private string _lastIndexHash = ""; + + public void Start() + { + // FileSystemWatcher sur le repertoire sessions + if (Directory.Exists(_sessionsDir)) + { + _watcher = new FileSystemWatcher(_sessionsDir, "index.json") + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size + }; + _watcher.Changed += (_, _) => CheckForChanges(); + _watcher.EnableRaisingEvents = true; + } + + // Polling fallback 2s (Docker, NFS, ou watcher non fiable) + _pollTimer = new Timer(_ => CheckForChanges(), null, + TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)); + } + + private void CheckForChanges() + { + try + { + var indexPath = Path.Combine(_sessionsDir, "index.json"); + if (!File.Exists(indexPath)) return; + + var hash = ComputeFileHash(indexPath); + if (hash == _lastIndexHash) return; + _lastIndexHash = hash; + + Emit(new SessionEvent + { + Type = "index.changed", + Timestamp = DateTime.UtcNow + }); + } + catch { /* best effort */ } + } + + public void Subscribe(ChannelWriter writer) + { + lock (_lock) _subscribers.Add(writer); + } + + public void Unsubscribe(ChannelWriter writer) + { + lock (_lock) _subscribers.Remove(writer); + } + + private void Emit(SessionEvent evt) + { + lock (_lock) + { + foreach (var writer in _subscribers) + writer.TryWrite(evt); + } + } +} +``` + +### 5.3 `LruCache` (inline, pas de package) + +```csharp +internal sealed class LruCache where TKey : notnull +{ + private readonly int _capacity; + private readonly Dictionary> _map; + private readonly LinkedList<(TKey Key, TValue Value)> _list; + + public LruCache(int capacity) { ... } + public bool TryGet(TKey key, out TValue value) { ... } + public void Set(TKey key, TValue value) { ... } +} +``` + +--- + +## 6. Frontend SPA (pur JavaScript + Fluent UI Web Components) + +### 6.1 Structure + +Aucun build step (ni npm, ni webpack, ni vite). Les fichiers JS sont ecrits en modules ES natifs (`import`/`export`). Les librairies sont vendorisees dans `wwwroot/lib/`. + +### 6.2 `index.html` : point d'entree + +```html + + + + + + docx-mcp Session Browser + + + + + + +
+
+ +

docx-mcp

+ Dark mode + SSE +
+
+
+ +
+
+
+ + + + +``` + +### 6.3 `app.js` : router SPA minimaliste + +Pas de framework : un router hash-based (`#/sessions`, `#/session/{id}`, `#/diff/{id}/{position}`) qui monte/demonte les vues. + +```javascript +// js/app.js +import { renderSessionList } from './views/sessionList.js'; +import { renderSessionDetail } from './views/sessionDetail.js'; +import { renderDiffView } from './views/diffView.js'; +import { connectSSE } from './sseClient.js'; + +const content = document.getElementById('app-content'); +const sse = connectSSE('/api/events'); + +function route() { + const hash = location.hash || '#/sessions'; + const [_, path, ...params] = hash.split('/'); + + content.innerHTML = ''; // unmount previous view + + switch (path) { + case 'sessions': + renderSessionList(content, sse); + break; + case 'session': + renderSessionDetail(content, params[0], sse); + break; + case 'diff': + renderDiffView(content, params[0], parseInt(params[1])); + break; + default: + renderSessionList(content, sse); + } +} + +window.addEventListener('hashchange', route); +route(); + +// Theme toggle +document.getElementById('theme-toggle').addEventListener('change', (e) => { + const provider = document.getElementById('design-provider'); + provider.setAttribute('base-layer-luminance', e.target.checked ? '0.15' : '1'); +}); +``` + +### 6.4 `sseClient.js` : gestion SSE avec reconnexion + +```javascript +// js/sseClient.js +export function connectSSE(url) { + const listeners = {}; + let source = new EventSource(url); + const statusBadge = document.getElementById('sse-status'); + + function updateStatus(connected) { + statusBadge.setAttribute('color', connected ? 'success' : 'danger'); + statusBadge.textContent = connected ? 'Live' : 'Disconnected'; + } + + source.onopen = () => updateStatus(true); + source.onerror = () => updateStatus(false); + // EventSource gere la reconnexion automatique + + return { + on(eventType, callback) { + if (!listeners[eventType]) { + listeners[eventType] = []; + source.addEventListener(eventType, (e) => { + const data = JSON.parse(e.data); + listeners[eventType].forEach(cb => cb(data)); + }); + } + listeners[eventType].push(callback); + }, + off(eventType, callback) { + if (listeners[eventType]) { + listeners[eventType] = listeners[eventType].filter(cb => cb !== callback); + } + } + }; +} +``` + +### 6.5 `docxRenderer.js` : wrapper docx-preview + +```javascript +// js/docxRenderer.js + +// Debounce pour le scrubbing rapide du slider +let renderTimeout = null; + +export async function renderDocxAtPosition(container, sessionId, position, debounceMs = 200) { + clearTimeout(renderTimeout); + + return new Promise((resolve) => { + renderTimeout = setTimeout(async () => { + // Afficher spinner + container.innerHTML = ''; + + try { + const resp = await fetch(`/api/sessions/${sessionId}/docx?position=${position}`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const blob = await resp.blob(); + + container.innerHTML = ''; + const styleContainer = document.createElement('style'); + container.appendChild(styleContainer); + + await docx.renderAsync(blob, container, styleContainer, { + className: "docx-preview", + inWrapper: true, + ignoreWidth: false, + ignoreHeight: false, + ignoreFonts: false, + breakPages: true, + useBase64URL: false, + experimental: true, + trimXmlDeclaration: true + }); + } catch (err) { + container.innerHTML = `

Failed to render: ${err.message}

`; + } + + resolve(); + }, debounceMs); + }); +} + +// Rendu immediat (sans debounce) pour le diff view +export async function renderDocxImmediate(container, sessionId, position) { + return renderDocxAtPosition(container, sessionId, position, 0); +} +``` + +--- + +## 7. Pages de l'UI + +### 7.1 Page d'accueil : `#/sessions` + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ docx-mcp [Dark mode] ● Live │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─ Active Sessions ──────────────────────────────────────────┐ │ +│ │ │ │ +│ │ │ │ +│ │ ID │ Source │ Modified │ WAL │ Pos │ │ +│ │──────────────│───────────────────│─────────────│──────│──────│ │ +│ │ a1b2c3d4e5f │ /docs/spec.docx │ 2 min ago │ 15 │ 12 │ │ +│ │ x9y8z7w6v5u │ /tmp/draft.docx │ 1h ago │ 3 │ 3 │ │ +│ │ m4n5o6p7q8r │ (new document) │ yesterday │ 42 │ 42 │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ Sessions dir: ~/.docx-mcp/sessions/ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +- `` genere dynamiquement depuis `GET /api/sessions` +- SSE `index.changed` → re-fetch + re-render du grid +- Clic sur une ligne → `location.hash = '#/session/{id}'` + +### 7.2 Page session : `#/session/{id}` + +Layout en 3 zones (CSS Grid + panneau redimensionnable) : + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ [← Sessions] Session a1b2c3d4e5f — spec.docx [Export ▼] │ +├───────────────┬──────────────────────────────────────────────────┤ +│ Document Tree │ DOCX Preview (docx-preview.js) │ +│ │ │ +│ │ │ │ │ +│ │ │ [Rendu natif DOCX : │ │ +│ ▼ Body │ │ marges, polices, tableaux, │ │ +│ §1 Intro │ │ headers/footers, images, │ │ +│ §2 Specs │ │ page breaks, styles, listes] │ │ +│ ▼ Table[0] │ │ │ │ +│ Row 0 │ │ │ │ +│ Row 1 │ └────────────────────────────────────────────┘ │ +│ §3 Concl. │ │ +├───────────────┴──────────────────────────────────────────────────┤ +│ Timeline │ +│ │ +│ ◆━━━○━━━○━━━●━━━○━━━○━━━◆━━━○━━━○━━━●━━━○━━━○━━━◆━━━○━━━▶ │ +│ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 │ +│ ↑ ↑ ↑ ↑ │ +│ Baseline Ckpt Ckpt Cursor │ +│ │ +│ │ +│ Position: 12/15 [◀ Prev] [Next ▶] │ +│ │ +│ (WAL entries) │ +│ │ # │ Time │ Description │ Actions │ │ +│ │ 12│ 14:25:30 │ replace /body/paragraph[0] │ [Diff] │ │ +│ │ 11│ 14:24:02 │ remove /body/table[0]/row[2] │ [Diff] │ │ +│ │ 10│ 14:23:15 │ add /body/paragraph[5] │ [Diff] │ │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +**Comportement** : +- Le `` emet `@change` → `renderDocxAtPosition()` avec debounce 200ms +- Le document tree se genere cote client en parsant le HTML produit par `docx-preview.js` + (pas besoin d'un endpoint serveur : on inspecte le DOM genere) +- Clic tree → `element.scrollIntoView()` dans le conteneur preview +- SSE `session.patched` → slider max s'etend, badge "new" sur le dernier patch +- Bouton [Diff] → `location.hash = '#/diff/{id}/{position}'` +- Cache navigateur : les reponses `/api/sessions/{id}/docx?position=N` sont immutables (meme position = memes bytes), on peut les mettre en cache HTTP (`Cache-Control: immutable`) + +### 7.3 Page diff : `#/diff/{id}/{position}` + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ [← Session] Patch #5 : Position 4 → 5 [Export] │ +│ "replace /body/paragraph[0]/run[0]" │ +├──────────────────────────┬───────────────────────────────────────┤ +│ │ │ +│ Before (Pos 4) │ After (Pos 5) │ +│ │ │ +│ ┌────────────────────┐ │ ┌────────────────────┐ │ +│ │ [docx-preview │ │ │ [docx-preview │ │ +│ │ rendu natif DOCX] │ │ │ rendu natif DOCX] │ │ +│ │ │ │ │ │ │ +│ │ page 1 │ │ │ page 1 │ │ +│ └────────────────────┘ │ └────────────────────┘ │ +│ │ │ +├──────────────────────────┴───────────────────────────────────────┤ +│ Patch Operations │ +│
 formatted JSON des operations du patch 
│ +│ │ +│ [◀ Prev Patch] Patch 5 of 15 [Next Patch ▶] │ +│ [Download Before .docx] [Download After .docx] │ +└──────────────────────────────────────────────────────────────────┘ +``` + +- Deux appels paralleles : `GET .../docx?position=4` et `GET .../docx?position=5` +- Deux conteneurs `docx-preview.js` cote a cote (CSS `display: grid; grid-template-columns: 1fr 1fr`) +- Le JSON du patch est lu depuis `GET /api/sessions/{id}/history` (entree a la position concernee) +- Boutons de telechargement : lien direct vers `/api/sessions/{id}/docx?position=N` (le navigateur telecharge le .docx) +- Navigation Prev/Next : change le hash → re-render + +### 7.4 Document Tree (generation cote client) + +Plutot qu'un endpoint serveur, le tree est genere **en inspectant le DOM produit par docx-preview.js** : + +```javascript +// js/documentTree.js +export function buildTreeFromPreview(previewContainer) { + const treeView = document.createElement('fluent-tree-view'); + const wrapper = previewContainer.querySelector('.docx-wrapper'); + if (!wrapper) return treeView; + + let paragraphIndex = 0; + let tableIndex = 0; + + for (const child of wrapper.children) { + const item = document.createElement('fluent-tree-item'); + + if (child.tagName === 'P' || child.tagName.match(/^H[1-6]$/)) { + const text = child.textContent?.substring(0, 40) || '(empty)'; + const level = child.tagName.match(/^H(\d)$/)?.[1]; + item.textContent = level + ? `H${level}: ${text}` + : `P[${paragraphIndex}]: ${text}`; + item.dataset.target = `p-${paragraphIndex}`; + child.id = `p-${paragraphIndex}`; + paragraphIndex++; + } + else if (child.tagName === 'TABLE') { + const rows = child.querySelectorAll('tr').length; + item.textContent = `Table[${tableIndex}] (${rows} rows)`; + item.dataset.target = `t-${tableIndex}`; + child.id = `t-${tableIndex}`; + tableIndex++; + } + else continue; + + item.addEventListener('click', () => { + const target = previewContainer.querySelector(`#${item.dataset.target}`); + target?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + + treeView.appendChild(item); + } + + return treeView; +} +``` + +--- + +## 8. `Program.cs` complet (NativeAOT) + +```csharp +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Channels; +using DocxMcp.Persistence; +using DocxMcp.Ui.Models; +using DocxMcp.Ui.Services; + +var builder = WebApplication.CreateSlimBuilder(args); + +// Source-generated JSON (NativeAOT-safe) +builder.Services.ConfigureHttpJsonOptions(o => + o.SerializerOptions.TypeInfoResolverChain.Add(UiJsonContext.Default)); + +// Sessions read-only +var sessionsDir = Environment.GetEnvironmentVariable("DOCX_MCP_SESSIONS_DIR") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".docx-mcp", "sessions"); + +builder.Services.AddSingleton(sp => + new SessionStore(sp.GetRequiredService>(), sessionsDir)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var port = builder.Configuration.GetValue("Port", 5200); +builder.WebHost.UseUrls($"http://localhost:{port}"); + +var app = builder.Build(); + +// Servir le SPA statique depuis wwwroot/ +app.UseDefaultFiles(); // index.html comme fallback +app.UseStaticFiles(); + +// --- API Endpoints --- + +app.MapGet("/api/sessions", (SessionBrowserService svc) => + Results.Ok(svc.ListSessions())); + +app.MapGet("/api/sessions/{id}", (string id, SessionBrowserService svc) => +{ + var detail = svc.GetSessionDetail(id); + return detail is null ? Results.NotFound() : Results.Ok(detail); +}); + +app.MapGet("/api/sessions/{id}/docx", (string id, int? position, SessionBrowserService svc) => +{ + var pos = position ?? svc.GetCurrentPosition(id); + var bytes = svc.GetDocxBytesAtPosition(id, pos); + return Results.File(bytes, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + $"{id}-pos{pos}.docx"); +}); + +app.MapGet("/api/sessions/{id}/history", + (string id, int? offset, int? limit, SessionBrowserService svc) => + Results.Ok(svc.GetHistory(id, offset ?? 0, limit ?? 50))); + +app.MapGet("/api/events", async (HttpContext ctx, EventBroadcaster broadcaster) => +{ + ctx.Response.ContentType = "text/event-stream"; + ctx.Response.Headers.CacheControl = "no-cache"; + ctx.Response.Headers.Connection = "keep-alive"; + + var channel = Channel.CreateUnbounded(); + broadcaster.Subscribe(channel.Writer); + try + { + await foreach (var evt in channel.Reader.ReadAllAsync(ctx.RequestAborted)) + { + var json = JsonSerializer.Serialize(evt, UiJsonContext.Default.SessionEvent); + await ctx.Response.WriteAsync($"event: {evt.Type}\ndata: {json}\n\n"); + await ctx.Response.Body.FlushAsync(); + } + } + finally { broadcaster.Unsubscribe(channel.Writer); } +}); + +// Demarrer la surveillance des sessions +app.Services.GetRequiredService().Start(); + +// Auto-ouvrir le navigateur +_ = Task.Run(async () => +{ + await Task.Delay(800); + try { Process.Start(new ProcessStartInfo($"http://localhost:{port}") { UseShellExecute = true }); } + catch { /* headless */ } +}); + +app.Run(); +``` + +--- + +## 9. Integration CLI : `docx-cli server` + +Le CLI NativeAOT lance le binaire `docx-ui` comme sous-processus : + +```csharp +"server" => CmdStartServer(args), + +string CmdStartServer(string[] a) +{ + var port = ParseInt(OptNamed(a, "--port"), 5200); + + // Chercher docx-ui a cote de docx-cli, puis dans PATH + var myDir = Path.GetDirectoryName(Environment.ProcessPath) ?? "."; + var uiExe = Path.Combine(myDir, "docx-ui"); + if (!File.Exists(uiExe)) + { + // Essayer avec extension .exe (Windows) + uiExe = Path.Combine(myDir, "docx-ui.exe"); + if (!File.Exists(uiExe)) + return "Error: docx-ui binary not found next to docx-cli."; + } + + var psi = new ProcessStartInfo + { + FileName = uiExe, + Arguments = $"--Port {port}", + UseShellExecute = false, + }; + var dir = Environment.GetEnvironmentVariable("DOCX_MCP_SESSIONS_DIR"); + if (dir is not null) psi.Environment["DOCX_MCP_SESSIONS_DIR"] = dir; + + using var process = Process.Start(psi); + Console.WriteLine($"UI server started: http://localhost:{port} (PID {process?.Id})"); + Console.WriteLine("Press Ctrl+C to stop."); + process?.WaitForExit(); + return ""; +} +``` + +--- + +## 10. Flux de donnees complet + +### Flux 1 : scrubbing du slider + +``` + User deplace le slider de position 8 → position 12 + + Browser (vanilla JS + docx-preview.js) + │ + ├── 1. @change → debounce 200ms + │ + ├── 2. fetch(`/api/sessions/abc/docx?position=12`) + │ │ + │ └── Serveur (Kestrel NativeAOT) + │ ├── Cache LRU hit? → retourne byte[] directement + │ └── Cache miss: + │ ├── SessionStore.LoadNearestCheckpoint("abc", 12) + │ │ → checkpoint pos 10, bytes du .ckpt.10.docx + │ ├── SessionStore.ReadWalRange("abc", 10, 12) + │ │ → 2 patches JSON + │ ├── DocxSession.FromBytes(ckptBytes) temporaire + │ ├── ReplayPatch(session, patch10) + │ ├── ReplayPatch(session, patch11) + │ ├── session.Stream.ToArray() → byte[] + │ └── session.Dispose() + │ + ├── 3. const blob = await response.blob() + │ + └── 4. docx.renderAsync(blob, container, styleContainer, options) + → Rendu DOCX natif dans le DOM du navigateur +``` + +### Flux 2 : notification temps reel d'un nouveau patch + +``` + MCP Server ecrit un patch (via PatchTool) + + MCP Server (docx-mcp) + ├── PatchTool applique le patch + ├── SessionStore.AppendWal("abc", patchJson) + └── SessionStore.SaveIndex(updatedIndex) ← index.json modifie + + UI Server (docx-ui, NativeAOT) + ├── FileSystemWatcher detecte le changement d'index.json + │ (ou poll 2s detecte le hash different) + ├── EventBroadcaster.Emit({ type: "index.changed" }) + └── SSE endpoint ecrit dans le stream : + event: index.changed + data: {"type":"index.changed","timestamp":"..."} + + Browser + ├── EventSource recoit "index.changed" + ├── Re-fetch GET /api/sessions/{id} → nouveau walCount + ├── Slider max passe de 15 → 16 + └── Badge "new" apparait sur le dernier patch +``` + +--- + +## 11. Plan d'implementation par etapes + +### Etape 1 : Scaffolding du projet NativeAOT +- [ ] Creer `src/DocxMcp.Ui/DocxMcp.Ui.csproj` (SDK Web, PublishAot=true) +- [ ] Ajouter a `DocxMcp.sln` +- [ ] `Program.cs` : `CreateSlimBuilder` + `UseStaticFiles` + page vide +- [ ] `UiJsonContext.cs` : source-generated JSON +- [ ] `wwwroot/index.html` minimal avec Fluent UI Web Components +- [ ] Vendoriser `docx-preview.min.js` et `fluent-web-components.min.js` +- [ ] Verifier : `dotnet run` demarre, page s'affiche, `dotnet publish -c Release` NativeAOT OK + +### Etape 2 : SessionBrowserService + endpoints REST +- [ ] `SessionBrowserService` : ListSessions, GetSessionDetail, GetDocxBytesAtPosition, GetHistory +- [ ] `LruCache` pour les documents reconstruits +- [ ] 4 endpoints REST (`/api/sessions`, `/{id}`, `/{id}/docx`, `/{id}/history`) +- [ ] Tests : curl sur chaque endpoint, verifier les reponses JSON + +### Etape 3 : EventBroadcaster + SSE +- [ ] `EventBroadcaster` : FileSystemWatcher + polling fallback +- [ ] Endpoint SSE `/api/events` +- [ ] `wwwroot/js/sseClient.js` +- [ ] Test : ouvrir 2 onglets, modifier index.json, verifier que les 2 recoivent l'evenement + +### Etape 4 : Page Session List (JS) +- [ ] `wwwroot/js/views/sessionList.js` +- [ ] `` avec colonnes triables +- [ ] SSE `index.changed` → refresh auto +- [ ] Badge SSE connecte/deconnecte +- [ ] Navigation au clic + +### Etape 5 : Document Preview (docx-preview.js) +- [ ] `wwwroot/js/docxRenderer.js` : fetch + renderAsync + debounce +- [ ] CSS pour le conteneur DOCX (scroll, dimensions, overflow) +- [ ] `` pendant le chargement + +### Etape 6 : Session Detail page (assemblage) +- [ ] `wwwroot/js/views/sessionDetail.js` +- [ ] Layout CSS Grid 3 zones (tree | preview | timeline) +- [ ] Toolbar avec `` retour + export +- [ ] Integration preview + tree + timeline + +### Etape 7 : Document Tree (generation depuis le DOM docx-preview) +- [ ] `wwwroot/js/documentTree.js` +- [ ] `` genere depuis le HTML rendu par docx-preview +- [ ] Clic → scrollIntoView dans le preview + +### Etape 8 : History Timeline + Slider +- [ ] `` : position 0..max, step 1 +- [ ] Visualisation graphique de la timeline (SVG ou canvas) +- [ ] Marqueurs : baseline, checkpoints, position courante +- [ ] `` : liste paginee des WAL entries +- [ ] Boutons Prev/Next +- [ ] Slider change → fetch + renderAsync (avec debounce) +- [ ] SSE → extension du slider, notification + +### Etape 9 : Diff View +- [ ] `wwwroot/js/views/diffView.js` +- [ ] Deux conteneurs docx-preview cote a cote (CSS Grid 1fr 1fr) +- [ ] Fetch parallele Before + After +- [ ] JSON du patch dans `
` stylise
+- [ ] Navigation Prev/Next entre patches
+- [ ] Boutons download (lien direct vers l'endpoint `/docx`)
+
+### Etape 10 : Export + CLI + polish
+- [ ] Boutons download dans toolbar (lien vers `/api/sessions/{id}/docx?position=N`)
+- [ ] Commande `docx-cli server [--port N]`
+- [ ] Dark/light theme toggle (`` → `base-layer-luminance`)
+- [ ] Responsive (media queries)
+- [ ] Gestion erreurs (session introuvable, SSE deconnecte, WAL corrompu)
+- [ ] Cache HTTP `Cache-Control: public, max-age=31536000, immutable` sur les DOCX immutables
+
+---
+
+## 12. Considerations techniques
+
+### Taille du binaire
+- `CreateSlimBuilder` au lieu de `CreateBuilder` reduit les dependances
+- `OptimizationPreference=Size` + `PublishAot=true`
+- Pas de Blazor, pas de MVC, pas de SignalR : uniquement Kestrel + static files + endpoints
+- Taille attendue : ~15-25 MB (comparable aux autres binaires du projet)
+
+### Performance du scrubbing
+- Debounce 200ms sur le slider : evite les rebuilds inutiles pendant le glissement
+- Cache LRU serveur (20 entrees) : positions recemment visitees servies instantanement
+- Cache HTTP navigateur : chaque position est immutable, `Cache-Control: immutable`
+- Checkpoints tous les 10 patches : rebuild ne rejoue jamais plus de 10 patches
+- Documents typiques : 50-500 KB, transfert quasi-instantane
+
+### docx-preview.js : limites connues
+- Pas de macros VBA (non pertinent)
+- SmartArt partiellement rendu
+- Polices non-standard : fallback systeme
+- `breakPages: true` peut etre lent pour les tres gros documents (>100 pages)
+
+### Securite
+- Ecoute uniquement sur `localhost` (outil local)
+- Pas d'authentification requise
+- Read-only par design : aucun endpoint de mutation
+- Les bytes DOCX proviennent uniquement du repertoire sessions configure
+
+### Concurrence d'acces
+- Le file lock n'est acquis que pendant les lectures d'index (tres bref)
+- Les reconstructions utilisent des copies en memoire (sessions temporaires jetables)
+- `EventBroadcaster` est thread-safe via `lock` + `Channel`
+- Le cache LRU est thread-safe (lock interne)
+
+### Pas de build step JS
+- Les librairies JS sont vendorisees (copiees) dans `wwwroot/lib/`
+- Pas de `npm install`, pas de `webpack`, pas de `vite`
+- Les fichiers JS utilisent des modules ES natifs (`import`/`export`)
+- Cela garde le projet simple et sans dependances Node.js

From efc7e200f36d2fdeed8931af8a9e8fbb5780fb44 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Mon, 2 Feb 2026 21:09:06 +0000
Subject: [PATCH 2/3] =?UTF-8?q?feat(ui):=20implement=20session=20browser?=
 =?UTF-8?q?=20UI=20=E2=80=94=20NativeAOT=20Minimal=20API=20+=20vanilla=20J?=
 =?UTF-8?q?S=20SPA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Add DocxMcp.Ui project: a lightweight read-only session browser that
visualizes document evolution through snapshots and patches.

Back-end (Kestrel Minimal API, NativeAOT-safe):
- REST endpoints for sessions list, detail, history, DOCX byte export
- SSE endpoint with FileSystemWatcher + polling for real-time updates
- LRU cache for reconstructed document bytes (slider scrubbing)
- SessionBrowserService rebuilds documents at any WAL position
- Source-generated JSON serialization (no reflection)

Front-end (vanilla JS + Fluent UI Web Components + docx-preview.js):
- Session list page with live SSE refresh
- Session detail: document tree, DOCX preview, timeline slider, history
- Diff view: side-by-side before/after rendering with patch details
- Debounced slider input for smooth document navigation
- Dark/light theme toggle

CLI integration:
- `docx-cli server [--port N]` launches the UI binary as subprocess
- Forwards DOCX_MCP_SESSIONS_DIR env var to share session storage

Core changes:
- SessionManager.ReplayPatch: private → internal (for UI rebuild)
- InternalsVisibleTo for DocxMcp.Ui

https://claude.ai/code/session_01AVZcLrhY6w4QujyzEAnLUJ
---
 DocxMcp.sln                                   |  15 +
 src/DocxMcp.Cli/Program.cs                    |  41 +++
 src/DocxMcp.Ui/DocxMcp.Ui.csproj              |  18 +
 src/DocxMcp.Ui/Models/HistoryEntryDto.cs      |  10 +
 src/DocxMcp.Ui/Models/SessionDetailDto.cs     |  12 +
 src/DocxMcp.Ui/Models/SessionEvent.cs         |   9 +
 src/DocxMcp.Ui/Models/SessionListItem.cs      |  11 +
 src/DocxMcp.Ui/Program.cs                     | 117 ++++++
 src/DocxMcp.Ui/Services/EventBroadcaster.cs   | 118 ++++++
 src/DocxMcp.Ui/Services/LruCache.cs           |  53 +++
 .../Services/SessionBrowserService.cs         | 148 ++++++++
 src/DocxMcp.Ui/UiJsonContext.cs               |  13 +
 src/DocxMcp.Ui/wwwroot/css/app.css            | 340 ++++++++++++++++++
 src/DocxMcp.Ui/wwwroot/index.html             |  37 ++
 src/DocxMcp.Ui/wwwroot/js/app.js              |  52 +++
 src/DocxMcp.Ui/wwwroot/js/documentTree.js     |  75 ++++
 src/DocxMcp.Ui/wwwroot/js/docxRenderer.js     |  47 +++
 src/DocxMcp.Ui/wwwroot/js/sseClient.js        |  38 ++
 src/DocxMcp.Ui/wwwroot/js/views/diffView.js   | 102 ++++++
 .../wwwroot/js/views/sessionDetail.js         | 220 ++++++++++++
 .../wwwroot/js/views/sessionList.js           |  99 +++++
 src/DocxMcp/DocxMcp.csproj                    |   1 +
 src/DocxMcp/SessionManager.cs                 |   2 +-
 23 files changed, 1577 insertions(+), 1 deletion(-)
 create mode 100644 src/DocxMcp.Ui/DocxMcp.Ui.csproj
 create mode 100644 src/DocxMcp.Ui/Models/HistoryEntryDto.cs
 create mode 100644 src/DocxMcp.Ui/Models/SessionDetailDto.cs
 create mode 100644 src/DocxMcp.Ui/Models/SessionEvent.cs
 create mode 100644 src/DocxMcp.Ui/Models/SessionListItem.cs
 create mode 100644 src/DocxMcp.Ui/Program.cs
 create mode 100644 src/DocxMcp.Ui/Services/EventBroadcaster.cs
 create mode 100644 src/DocxMcp.Ui/Services/LruCache.cs
 create mode 100644 src/DocxMcp.Ui/Services/SessionBrowserService.cs
 create mode 100644 src/DocxMcp.Ui/UiJsonContext.cs
 create mode 100644 src/DocxMcp.Ui/wwwroot/css/app.css
 create mode 100644 src/DocxMcp.Ui/wwwroot/index.html
 create mode 100644 src/DocxMcp.Ui/wwwroot/js/app.js
 create mode 100644 src/DocxMcp.Ui/wwwroot/js/documentTree.js
 create mode 100644 src/DocxMcp.Ui/wwwroot/js/docxRenderer.js
 create mode 100644 src/DocxMcp.Ui/wwwroot/js/sseClient.js
 create mode 100644 src/DocxMcp.Ui/wwwroot/js/views/diffView.js
 create mode 100644 src/DocxMcp.Ui/wwwroot/js/views/sessionDetail.js
 create mode 100644 src/DocxMcp.Ui/wwwroot/js/views/sessionList.js

diff --git a/DocxMcp.sln b/DocxMcp.sln
index 304ab50..9e123da 100644
--- a/DocxMcp.sln
+++ b/DocxMcp.sln
@@ -11,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxMcp.Cli", "src\DocxMcp.Cli\DocxMcp.Cli.csproj", "{3B0B53E5-AF70-4F88-B383-04849B4CBCE0}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxMcp.Ui", "src\DocxMcp.Ui\DocxMcp.Ui.csproj", "{D4E5F6A7-B8C9-0123-4567-890ABCDEF012}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -57,11 +59,24 @@ Global
 		{3B0B53E5-AF70-4F88-B383-04849B4CBCE0}.Release|x64.Build.0 = Release|Any CPU
 		{3B0B53E5-AF70-4F88-B383-04849B4CBCE0}.Release|x86.ActiveCfg = Release|Any CPU
 		{3B0B53E5-AF70-4F88-B383-04849B4CBCE0}.Release|x86.Build.0 = Release|Any CPU
+		{D4E5F6A7-B8C9-0123-4567-890ABCDEF012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{D4E5F6A7-B8C9-0123-4567-890ABCDEF012}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{D4E5F6A7-B8C9-0123-4567-890ABCDEF012}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{D4E5F6A7-B8C9-0123-4567-890ABCDEF012}.Debug|x64.Build.0 = Debug|Any CPU
+		{D4E5F6A7-B8C9-0123-4567-890ABCDEF012}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{D4E5F6A7-B8C9-0123-4567-890ABCDEF012}.Debug|x86.Build.0 = Debug|Any CPU
+		{D4E5F6A7-B8C9-0123-4567-890ABCDEF012}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{D4E5F6A7-B8C9-0123-4567-890ABCDEF012}.Release|Any CPU.Build.0 = Release|Any CPU
+		{D4E5F6A7-B8C9-0123-4567-890ABCDEF012}.Release|x64.ActiveCfg = Release|Any CPU
+		{D4E5F6A7-B8C9-0123-4567-890ABCDEF012}.Release|x64.Build.0 = Release|Any CPU
+		{D4E5F6A7-B8C9-0123-4567-890ABCDEF012}.Release|x86.ActiveCfg = Release|Any CPU
+		{D4E5F6A7-B8C9-0123-4567-890ABCDEF012}.Release|x86.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
 	EndGlobalSection
 	GlobalSection(NestedProjects) = preSolution
 		{3B0B53E5-AF70-4F88-B383-04849B4CBCE0} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+		{D4E5F6A7-B8C9-0123-4567-890ABCDEF012} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
 	EndGlobalSection
 EndGlobal
diff --git a/src/DocxMcp.Cli/Program.cs b/src/DocxMcp.Cli/Program.cs
index feddb16..49b93d7 100644
--- a/src/DocxMcp.Cli/Program.cs
+++ b/src/DocxMcp.Cli/Program.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
 using System.Text.Json;
 using DocxMcp;
 using DocxMcp.Cli;
@@ -115,6 +116,9 @@ string ResolveDocId(string idOrPath)
         // Session inspection
         "inspect" => CmdInspect(args),
 
+        // UI server
+        "server" => CmdServer(args),
+
         "help" or "--help" or "-h" => Usage(),
         _ => $"Unknown command: '{command}'. Run 'docx-cli help' for usage."
     };
@@ -633,6 +637,40 @@ string FindOrCreateSession(string filePath)
     return session.Id;
 }
 
+string CmdServer(string[] a)
+{
+    var portArg = OptNamed(a, "--port") ?? OptNamed(a, "--Port");
+    var exeDir = Path.GetDirectoryName(Environment.ProcessPath) ?? ".";
+    var uiBinary = OperatingSystem.IsWindows() ? "docx-ui.exe" : "docx-ui";
+    var uiPath = Path.Combine(exeDir, uiBinary);
+
+    if (!File.Exists(uiPath))
+    {
+        // Fallback: look via PATH
+        uiPath = uiBinary;
+    }
+
+    var psi = new ProcessStartInfo(uiPath)
+    {
+        UseShellExecute = false,
+    };
+
+    // Forward sessions dir
+    psi.Environment["DOCX_MCP_SESSIONS_DIR"] = sessionsDir;
+
+    if (portArg is not null)
+    {
+        psi.ArgumentList.Add("--Port");
+        psi.ArgumentList.Add(portArg);
+    }
+
+    using var proc = Process.Start(psi)
+        ?? throw new InvalidOperationException($"Failed to start {uiPath}");
+
+    proc.WaitForExit();
+    return "";
+}
+
 // --- Argument helpers ---
 
 static string Require(string[] a, int idx, string name)
@@ -772,6 +810,9 @@ Sync session with external file (records in WAL)
       watch  [--auto-sync] [--debounce ms] [--pattern *.docx] [--recursive]
                                  Watch file or folder for changes (daemon mode)
 
+    Server:
+      server [--port N]                    Launch the session browser UI (default port 5200)
+
     Options:
       --dry-run    Simulate operation without applying changes
 
diff --git a/src/DocxMcp.Ui/DocxMcp.Ui.csproj b/src/DocxMcp.Ui/DocxMcp.Ui.csproj
new file mode 100644
index 0000000..715b19b
--- /dev/null
+++ b/src/DocxMcp.Ui/DocxMcp.Ui.csproj
@@ -0,0 +1,18 @@
+
+
+  
+    net10.0
+    DocxMcp.Ui
+    docx-ui
+    enable
+    enable
+    true
+    false
+    Size
+  
+
+  
+    
+  
+
+
diff --git a/src/DocxMcp.Ui/Models/HistoryEntryDto.cs b/src/DocxMcp.Ui/Models/HistoryEntryDto.cs
new file mode 100644
index 0000000..69125c2
--- /dev/null
+++ b/src/DocxMcp.Ui/Models/HistoryEntryDto.cs
@@ -0,0 +1,10 @@
+namespace DocxMcp.Ui.Models;
+
+public sealed class HistoryEntryDto
+{
+    public int Position { get; set; }
+    public DateTime Timestamp { get; set; }
+    public string Description { get; set; } = "";
+    public bool IsCheckpoint { get; set; }
+    public string Patches { get; set; } = "";
+}
diff --git a/src/DocxMcp.Ui/Models/SessionDetailDto.cs b/src/DocxMcp.Ui/Models/SessionDetailDto.cs
new file mode 100644
index 0000000..aab9fb2
--- /dev/null
+++ b/src/DocxMcp.Ui/Models/SessionDetailDto.cs
@@ -0,0 +1,12 @@
+namespace DocxMcp.Ui.Models;
+
+public sealed class SessionDetailDto
+{
+    public string Id { get; set; } = "";
+    public string? SourcePath { get; set; }
+    public DateTime CreatedAt { get; set; }
+    public DateTime LastModifiedAt { get; set; }
+    public int WalCount { get; set; }
+    public int CursorPosition { get; set; }
+    public int[] CheckpointPositions { get; set; } = [];
+}
diff --git a/src/DocxMcp.Ui/Models/SessionEvent.cs b/src/DocxMcp.Ui/Models/SessionEvent.cs
new file mode 100644
index 0000000..c8bdd20
--- /dev/null
+++ b/src/DocxMcp.Ui/Models/SessionEvent.cs
@@ -0,0 +1,9 @@
+namespace DocxMcp.Ui.Models;
+
+public sealed class SessionEvent
+{
+    public string Type { get; set; } = "";
+    public string? SessionId { get; set; }
+    public int? Position { get; set; }
+    public DateTime Timestamp { get; set; }
+}
diff --git a/src/DocxMcp.Ui/Models/SessionListItem.cs b/src/DocxMcp.Ui/Models/SessionListItem.cs
new file mode 100644
index 0000000..01c7311
--- /dev/null
+++ b/src/DocxMcp.Ui/Models/SessionListItem.cs
@@ -0,0 +1,11 @@
+namespace DocxMcp.Ui.Models;
+
+public sealed class SessionListItem
+{
+    public string Id { get; set; } = "";
+    public string? SourcePath { get; set; }
+    public DateTime CreatedAt { get; set; }
+    public DateTime LastModifiedAt { get; set; }
+    public int WalCount { get; set; }
+    public int CursorPosition { get; set; }
+}
diff --git a/src/DocxMcp.Ui/Program.cs b/src/DocxMcp.Ui/Program.cs
new file mode 100644
index 0000000..96e2a65
--- /dev/null
+++ b/src/DocxMcp.Ui/Program.cs
@@ -0,0 +1,117 @@
+using System.Diagnostics;
+using System.Text.Json;
+using System.Threading.Channels;
+using DocxMcp.Persistence;
+using DocxMcp.Ui;
+using DocxMcp.Ui.Models;
+using DocxMcp.Ui.Services;
+
+var builder = WebApplication.CreateSlimBuilder(args);
+
+builder.Services.ConfigureHttpJsonOptions(o =>
+    o.SerializerOptions.TypeInfoResolverChain.Add(UiJsonContext.Default));
+
+var sessionsDir = Environment.GetEnvironmentVariable("DOCX_MCP_SESSIONS_DIR")
+    ?? Path.Combine(
+        Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+        ".docx-mcp", "sessions");
+
+builder.Services.AddSingleton(sp =>
+    new SessionStore(sp.GetRequiredService>(), sessionsDir));
+builder.Services.AddSingleton();
+builder.Services.AddSingleton(sp =>
+    new EventBroadcaster(sessionsDir, sp.GetRequiredService>()));
+
+var port = builder.Configuration.GetValue("Port", 5200);
+builder.WebHost.UseUrls($"http://localhost:{port}");
+
+var app = builder.Build();
+
+app.UseDefaultFiles();
+app.UseStaticFiles();
+
+// --- REST Endpoints ---
+
+app.MapGet("/api/sessions", (SessionBrowserService svc) =>
+    Results.Ok(svc.ListSessions()));
+
+app.MapGet("/api/sessions/{id}", (string id, SessionBrowserService svc) =>
+{
+    var detail = svc.GetSessionDetail(id);
+    return detail is null ? Results.NotFound() : Results.Ok(detail);
+});
+
+app.MapGet("/api/sessions/{id}/docx", (string id, int? position, SessionBrowserService svc) =>
+{
+    try
+    {
+        var pos = position ?? svc.GetCurrentPosition(id);
+        var bytes = svc.GetDocxBytesAtPosition(id, pos);
+        return Results.File(bytes,
+            "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+            $"{id}-pos{pos}.docx");
+    }
+    catch (KeyNotFoundException)
+    {
+        return Results.NotFound();
+    }
+    catch (Exception ex)
+    {
+        return Results.Problem(ex.Message);
+    }
+});
+
+app.MapGet("/api/sessions/{id}/history",
+    (string id, int? offset, int? limit, SessionBrowserService svc) =>
+{
+    try
+    {
+        return Results.Ok(svc.GetHistory(id, offset ?? 0, limit ?? 50));
+    }
+    catch (KeyNotFoundException)
+    {
+        return Results.NotFound();
+    }
+});
+
+// --- SSE Endpoint ---
+
+app.MapGet("/api/events", async (HttpContext ctx, EventBroadcaster broadcaster) =>
+{
+    ctx.Response.ContentType = "text/event-stream";
+    ctx.Response.Headers.CacheControl = "no-cache";
+    ctx.Response.Headers.Connection = "keep-alive";
+
+    var channel = Channel.CreateUnbounded();
+    broadcaster.Subscribe(channel.Writer);
+    try
+    {
+        await foreach (var evt in channel.Reader.ReadAllAsync(ctx.RequestAborted))
+        {
+            var json = JsonSerializer.Serialize(evt, UiJsonContext.Default.SessionEvent);
+            await ctx.Response.WriteAsync($"event: {evt.Type}\ndata: {json}\n\n");
+            await ctx.Response.Body.FlushAsync();
+        }
+    }
+    catch (OperationCanceledException) { /* client disconnected */ }
+    finally
+    {
+        broadcaster.Unsubscribe(channel.Writer);
+    }
+});
+
+// Start watching for session changes
+app.Services.GetRequiredService().Start();
+
+// Auto-open browser
+_ = Task.Run(async () =>
+{
+    await Task.Delay(800);
+    try { Process.Start(new ProcessStartInfo($"http://localhost:{port}") { UseShellExecute = true }); }
+    catch { /* headless environment */ }
+});
+
+Console.Error.WriteLine($"docx-ui listening on http://localhost:{port}");
+Console.Error.WriteLine($"Sessions directory: {sessionsDir}");
+
+app.Run();
diff --git a/src/DocxMcp.Ui/Services/EventBroadcaster.cs b/src/DocxMcp.Ui/Services/EventBroadcaster.cs
new file mode 100644
index 0000000..0368066
--- /dev/null
+++ b/src/DocxMcp.Ui/Services/EventBroadcaster.cs
@@ -0,0 +1,118 @@
+using System.Security.Cryptography;
+using System.Threading.Channels;
+using DocxMcp.Ui.Models;
+using Microsoft.Extensions.Logging;
+
+namespace DocxMcp.Ui.Services;
+
+public sealed class EventBroadcaster : IDisposable
+{
+    private readonly string _sessionsDir;
+    private readonly ILogger _logger;
+    private FileSystemWatcher? _watcher;
+    private Timer? _pollTimer;
+    private readonly List> _subscribers = [];
+    private readonly Lock _lock = new();
+    private string _lastIndexHash = "";
+
+    public EventBroadcaster(string sessionsDir, ILogger logger)
+    {
+        _sessionsDir = sessionsDir;
+        _logger = logger;
+    }
+
+    public void Start()
+    {
+        // Compute initial hash
+        _lastIndexHash = ComputeIndexHash();
+
+        if (Directory.Exists(_sessionsDir))
+        {
+            try
+            {
+                _watcher = new FileSystemWatcher(_sessionsDir, "index.json")
+                {
+                    NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size
+                };
+                _watcher.Changed += (_, _) => CheckForChanges();
+                _watcher.EnableRaisingEvents = true;
+                _logger.LogInformation("FileSystemWatcher started on {Dir}", _sessionsDir);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogWarning(ex, "FileSystemWatcher failed; relying on polling only.");
+            }
+        }
+
+        // Polling fallback every 2s
+        _pollTimer = new Timer(_ => CheckForChanges(), null,
+            TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2));
+    }
+
+    public void Subscribe(ChannelWriter writer)
+    {
+        lock (_lock) _subscribers.Add(writer);
+    }
+
+    public void Unsubscribe(ChannelWriter writer)
+    {
+        lock (_lock) _subscribers.Remove(writer);
+    }
+
+    private void CheckForChanges()
+    {
+        try
+        {
+            var hash = ComputeIndexHash();
+            if (hash == _lastIndexHash) return;
+            _lastIndexHash = hash;
+
+            Emit(new SessionEvent
+            {
+                Type = "index.changed",
+                Timestamp = DateTime.UtcNow
+            });
+        }
+        catch (Exception ex)
+        {
+            _logger.LogDebug(ex, "Error checking for index changes.");
+        }
+    }
+
+    private void Emit(SessionEvent evt)
+    {
+        lock (_lock)
+        {
+            for (int i = _subscribers.Count - 1; i >= 0; i--)
+            {
+                if (!_subscribers[i].TryWrite(evt))
+                {
+                    _subscribers.RemoveAt(i);
+                }
+            }
+        }
+    }
+
+    private string ComputeIndexHash()
+    {
+        var path = Path.Combine(_sessionsDir, "index.json");
+        if (!File.Exists(path)) return "";
+
+        try
+        {
+            var bytes = File.ReadAllBytes(path);
+            var hash = SHA256.HashData(bytes);
+            return Convert.ToHexString(hash);
+        }
+        catch
+        {
+            return "";
+        }
+    }
+
+    public void Dispose()
+    {
+        _watcher?.Dispose();
+        _pollTimer?.Dispose();
+    }
+}
diff --git a/src/DocxMcp.Ui/Services/LruCache.cs b/src/DocxMcp.Ui/Services/LruCache.cs
new file mode 100644
index 0000000..8d1832d
--- /dev/null
+++ b/src/DocxMcp.Ui/Services/LruCache.cs
@@ -0,0 +1,53 @@
+namespace DocxMcp.Ui.Services;
+
+internal sealed class LruCache where TKey : notnull
+{
+    private readonly int _capacity;
+    private readonly Dictionary> _map;
+    private readonly LinkedList<(TKey Key, TValue Value)> _list = new();
+    private readonly Lock _lock = new();
+
+    public LruCache(int capacity)
+    {
+        _capacity = capacity;
+        _map = new Dictionary>(capacity);
+    }
+
+    public bool TryGet(TKey key, out TValue value)
+    {
+        lock (_lock)
+        {
+            if (_map.TryGetValue(key, out var node))
+            {
+                _list.Remove(node);
+                _list.AddFirst(node);
+                value = node.Value.Value;
+                return true;
+            }
+
+            value = default!;
+            return false;
+        }
+    }
+
+    public void Set(TKey key, TValue value)
+    {
+        lock (_lock)
+        {
+            if (_map.TryGetValue(key, out var existing))
+            {
+                _list.Remove(existing);
+                _map.Remove(key);
+            }
+            else if (_map.Count >= _capacity)
+            {
+                var last = _list.Last!;
+                _map.Remove(last.Value.Key);
+                _list.RemoveLast();
+            }
+
+            var node = _list.AddFirst((key, value));
+            _map[key] = node;
+        }
+    }
+}
diff --git a/src/DocxMcp.Ui/Services/SessionBrowserService.cs b/src/DocxMcp.Ui/Services/SessionBrowserService.cs
new file mode 100644
index 0000000..415ddd1
--- /dev/null
+++ b/src/DocxMcp.Ui/Services/SessionBrowserService.cs
@@ -0,0 +1,148 @@
+using DocxMcp.Persistence;
+using DocxMcp.Ui.Models;
+using Microsoft.Extensions.Logging;
+
+namespace DocxMcp.Ui.Services;
+
+public sealed class SessionBrowserService
+{
+    private readonly SessionStore _store;
+    private readonly ILogger _logger;
+    private readonly LruCache<(string, int), byte[]> _docxCache = new(capacity: 20);
+
+    public SessionBrowserService(SessionStore store, ILogger logger)
+    {
+        _store = store;
+        _logger = logger;
+    }
+
+    public SessionListItem[] ListSessions()
+    {
+        var index = _store.LoadIndex();
+        return index.Sessions.Select(e => new SessionListItem
+        {
+            Id = e.Id,
+            SourcePath = e.SourcePath,
+            CreatedAt = e.CreatedAt,
+            LastModifiedAt = e.LastModifiedAt,
+            WalCount = e.WalCount,
+            CursorPosition = e.CursorPosition
+        }).ToArray();
+    }
+
+    public SessionDetailDto? GetSessionDetail(string sessionId)
+    {
+        var index = _store.LoadIndex();
+        var entry = index.Sessions.Find(e => e.Id == sessionId);
+        if (entry is null) return null;
+
+        return new SessionDetailDto
+        {
+            Id = entry.Id,
+            SourcePath = entry.SourcePath,
+            CreatedAt = entry.CreatedAt,
+            LastModifiedAt = entry.LastModifiedAt,
+            WalCount = entry.WalCount,
+            CursorPosition = entry.CursorPosition,
+            CheckpointPositions = entry.CheckpointPositions.ToArray()
+        };
+    }
+
+    public int GetCurrentPosition(string sessionId)
+    {
+        var index = _store.LoadIndex();
+        var entry = index.Sessions.Find(e => e.Id == sessionId);
+        return entry?.CursorPosition ?? 0;
+    }
+
+    public HistoryEntryDto[] GetHistory(string sessionId, int offset, int limit)
+    {
+        var entries = _store.ReadWalEntries(sessionId);
+        var index = _store.LoadIndex();
+        var session = index.Sessions.Find(e => e.Id == sessionId);
+        var checkpoints = session?.CheckpointPositions ?? [];
+
+        return entries
+            .Select((e, i) => new HistoryEntryDto
+            {
+                Position = i + 1,
+                Timestamp = e.Timestamp,
+                Description = e.Description ?? SummarizePatch(e.Patches),
+                IsCheckpoint = checkpoints.Contains(i + 1),
+                Patches = e.Patches
+            })
+            .Reverse()
+            .Skip(offset)
+            .Take(limit)
+            .ToArray();
+    }
+
+    public byte[] GetDocxBytesAtPosition(string sessionId, int position)
+    {
+        var key = (sessionId, position);
+        if (_docxCache.TryGet(key, out var cached))
+            return cached;
+
+        var bytes = RebuildAtPosition(sessionId, position);
+        _docxCache.Set(key, bytes);
+        return bytes;
+    }
+
+    private byte[] RebuildAtPosition(string sessionId, int position)
+    {
+        var index = _store.LoadIndex();
+        var entry = index.Sessions.Find(e => e.Id == sessionId)
+            ?? throw new KeyNotFoundException($"Session '{sessionId}' not found.");
+
+        if (position == 0)
+        {
+            return _store.LoadBaseline(sessionId);
+        }
+
+        var checkpoints = entry.CheckpointPositions ?? [];
+        var (ckptPos, ckptBytes) = _store.LoadNearestCheckpoint(sessionId, position, checkpoints);
+
+        using var session = DocxSession.FromBytes(ckptBytes, sessionId, entry.SourcePath);
+
+        if (position > ckptPos)
+        {
+            var patches = _store.ReadWalRange(sessionId, ckptPos, position);
+            foreach (var patchJson in patches)
+            {
+                try
+                {
+                    SessionManager.ReplayPatch(session, patchJson);
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogWarning(ex, "Failed to replay patch during rebuild for session {SessionId}.", sessionId);
+                    break;
+                }
+            }
+        }
+
+        session.Document.Save();
+        return session.Stream.ToArray();
+    }
+
+    private static string SummarizePatch(string patchesJson)
+    {
+        try
+        {
+            using var doc = System.Text.Json.JsonDocument.Parse(patchesJson);
+            if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array)
+            {
+                var count = doc.RootElement.GetArrayLength();
+                if (count > 0)
+                {
+                    var first = doc.RootElement[0];
+                    var op = first.TryGetProperty("op", out var opProp) ? opProp.GetString() : "?";
+                    var path = first.TryGetProperty("path", out var pathProp) ? pathProp.GetString() : "?";
+                    return count == 1 ? $"{op} {path}" : $"{op} {path} (+{count - 1} more)";
+                }
+            }
+        }
+        catch { }
+        return "(no description)";
+    }
+}
diff --git a/src/DocxMcp.Ui/UiJsonContext.cs b/src/DocxMcp.Ui/UiJsonContext.cs
new file mode 100644
index 0000000..4acdc1d
--- /dev/null
+++ b/src/DocxMcp.Ui/UiJsonContext.cs
@@ -0,0 +1,13 @@
+using System.Text.Json.Serialization;
+using DocxMcp.Ui.Models;
+
+namespace DocxMcp.Ui;
+
+[JsonSerializable(typeof(SessionListItem[]))]
+[JsonSerializable(typeof(SessionDetailDto))]
+[JsonSerializable(typeof(HistoryEntryDto[]))]
+[JsonSerializable(typeof(SessionEvent))]
+[JsonSourceGenerationOptions(
+    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
+internal partial class UiJsonContext : JsonSerializerContext { }
diff --git a/src/DocxMcp.Ui/wwwroot/css/app.css b/src/DocxMcp.Ui/wwwroot/css/app.css
new file mode 100644
index 0000000..b540dbb
--- /dev/null
+++ b/src/DocxMcp.Ui/wwwroot/css/app.css
@@ -0,0 +1,340 @@
+/* === Reset & base === */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+html, body {
+    height: 100%;
+    font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
+    background: var(--neutral-layer-1, #fafafa);
+    color: var(--neutral-foreground-rest, #242424);
+}
+
+#app-shell {
+    display: flex;
+    flex-direction: column;
+    height: 100vh;
+}
+
+/* === Header === */
+#app-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 8px 16px;
+    border-bottom: 1px solid var(--neutral-stroke-rest, #e0e0e0);
+    background: var(--neutral-layer-2, #f5f5f5);
+    min-height: 48px;
+}
+
+.header-left {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
+.header-left h1 {
+    font-size: 16px;
+    font-weight: 600;
+}
+
+.header-right {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+}
+
+/* === Main content === */
+#app-content {
+    flex: 1;
+    overflow: auto;
+    padding: 16px;
+}
+
+/* === Session List === */
+.session-list {
+    max-width: 1000px;
+    margin: 0 auto;
+}
+
+.session-list h2 {
+    font-size: 20px;
+    font-weight: 600;
+    margin-bottom: 16px;
+}
+
+.sessions-table {
+    width: 100%;
+    border-collapse: collapse;
+}
+
+.sessions-table th,
+.sessions-table td {
+    text-align: left;
+    padding: 10px 12px;
+    border-bottom: 1px solid var(--neutral-stroke-rest, #e0e0e0);
+}
+
+.sessions-table th {
+    font-weight: 600;
+    font-size: 12px;
+    text-transform: uppercase;
+    letter-spacing: 0.5px;
+    color: var(--neutral-foreground-hint, #707070);
+}
+
+.sessions-table tr {
+    cursor: pointer;
+    transition: background 0.1s;
+}
+
+.sessions-table tbody tr:hover {
+    background: var(--neutral-layer-3, #ebebeb);
+}
+
+.session-id {
+    font-family: 'Cascadia Code', 'Consolas', monospace;
+    font-size: 13px;
+}
+
+.session-path {
+    max-width: 300px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.footer-info {
+    margin-top: 16px;
+    font-size: 12px;
+    color: var(--neutral-foreground-hint, #707070);
+}
+
+/* === Session Detail === */
+.session-detail {
+    display: grid;
+    grid-template-columns: 240px 1fr;
+    grid-template-rows: auto 1fr auto;
+    height: calc(100vh - 80px);
+    gap: 0;
+}
+
+.detail-toolbar {
+    grid-column: 1 / -1;
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 8px 12px;
+    border-bottom: 1px solid var(--neutral-stroke-rest, #e0e0e0);
+    background: var(--neutral-layer-2, #f5f5f5);
+}
+
+.detail-toolbar h2 {
+    font-size: 14px;
+    font-weight: 600;
+    flex: 1;
+}
+
+.detail-toolbar .session-source {
+    font-size: 12px;
+    color: var(--neutral-foreground-hint, #707070);
+    font-family: monospace;
+}
+
+.detail-tree {
+    grid-column: 1;
+    grid-row: 2;
+    overflow: auto;
+    border-right: 1px solid var(--neutral-stroke-rest, #e0e0e0);
+    padding: 8px;
+    font-size: 13px;
+}
+
+.detail-preview {
+    grid-column: 2;
+    grid-row: 2;
+    overflow: auto;
+    background: #fff;
+    position: relative;
+}
+
+.detail-preview .docx-wrapper {
+    margin: 0 auto;
+}
+
+.detail-timeline {
+    grid-column: 1 / -1;
+    grid-row: 3;
+    border-top: 1px solid var(--neutral-stroke-rest, #e0e0e0);
+    padding: 12px 16px;
+    max-height: 300px;
+    overflow: auto;
+}
+
+.timeline-controls {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    margin-bottom: 8px;
+}
+
+.timeline-controls .pos-label {
+    font-size: 13px;
+    font-weight: 600;
+    min-width: 100px;
+}
+
+.timeline-controls fluent-slider {
+    flex: 1;
+}
+
+.timeline-track {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    margin-bottom: 8px;
+    padding: 4px 0;
+    overflow-x: auto;
+}
+
+.timeline-dot {
+    width: 12px;
+    height: 12px;
+    border-radius: 50%;
+    background: var(--neutral-stroke-rest, #ccc);
+    flex-shrink: 0;
+    cursor: pointer;
+    transition: all 0.15s;
+}
+
+.timeline-dot:hover { transform: scale(1.3); }
+.timeline-dot.baseline { background: #0078d4; width: 14px; height: 14px; border-radius: 3px; }
+.timeline-dot.checkpoint { background: #0078d4; }
+.timeline-dot.current { background: #d83b01; box-shadow: 0 0 0 3px rgba(216, 59, 1, 0.3); }
+.timeline-dot.segment { width: 16px; height: 2px; border-radius: 1px; background: var(--neutral-stroke-rest, #ccc); cursor: default; }
+
+.history-table {
+    width: 100%;
+    border-collapse: collapse;
+    font-size: 13px;
+}
+
+.history-table th,
+.history-table td {
+    text-align: left;
+    padding: 6px 10px;
+    border-bottom: 1px solid var(--neutral-stroke-rest, #e0e0e0);
+}
+
+.history-table th {
+    font-weight: 600;
+    font-size: 11px;
+    text-transform: uppercase;
+    color: var(--neutral-foreground-hint, #707070);
+}
+
+.history-table tr { cursor: pointer; }
+.history-table tbody tr:hover { background: var(--neutral-layer-3, #ebebeb); }
+.history-table .pos-badge { font-family: monospace; }
+.history-table .ckpt-badge { color: #0078d4; font-weight: 600; }
+
+/* === Diff View === */
+.diff-view {
+    display: grid;
+    grid-template-rows: auto 1fr auto;
+    height: calc(100vh - 80px);
+}
+
+.diff-toolbar {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 8px 12px;
+    border-bottom: 1px solid var(--neutral-stroke-rest, #e0e0e0);
+    background: var(--neutral-layer-2, #f5f5f5);
+}
+
+.diff-toolbar h2 {
+    font-size: 14px;
+    font-weight: 600;
+    flex: 1;
+}
+
+.diff-panels {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 0;
+    overflow: hidden;
+}
+
+.diff-panel {
+    overflow: auto;
+    background: #fff;
+    position: relative;
+}
+
+.diff-panel-label {
+    position: sticky;
+    top: 0;
+    z-index: 10;
+    background: var(--neutral-layer-2, #f5f5f5);
+    padding: 6px 12px;
+    font-size: 12px;
+    font-weight: 600;
+    border-bottom: 1px solid var(--neutral-stroke-rest, #e0e0e0);
+}
+
+.diff-panel:first-child {
+    border-right: 2px solid var(--neutral-stroke-rest, #e0e0e0);
+}
+
+.diff-bottom {
+    border-top: 1px solid var(--neutral-stroke-rest, #e0e0e0);
+    padding: 12px 16px;
+    max-height: 250px;
+    overflow: auto;
+}
+
+.patch-json {
+    background: var(--neutral-layer-4, #f0f0f0);
+    border-radius: 4px;
+    padding: 12px;
+    font-family: 'Cascadia Code', 'Consolas', monospace;
+    font-size: 12px;
+    overflow-x: auto;
+    white-space: pre-wrap;
+    margin-bottom: 12px;
+}
+
+.diff-nav {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+}
+
+/* === Tree === */
+.tree-container fluent-tree-item {
+    font-size: 13px;
+}
+
+/* === Loading === */
+.loading-container {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 200px;
+}
+
+/* === Error === */
+.error {
+    color: #d83b01;
+    padding: 16px;
+    text-align: center;
+}
+
+/* === Empty state === */
+.empty-state {
+    text-align: center;
+    padding: 48px 24px;
+    color: var(--neutral-foreground-hint, #707070);
+}
+
+.empty-state h3 { margin-bottom: 8px; }
diff --git a/src/DocxMcp.Ui/wwwroot/index.html b/src/DocxMcp.Ui/wwwroot/index.html
new file mode 100644
index 0000000..0095951
--- /dev/null
+++ b/src/DocxMcp.Ui/wwwroot/index.html
@@ -0,0 +1,37 @@
+
+
+
+    
+    
+    docx-mcp Session Browser
+    
+    
+    
+
+
+    
+        
+
+
+ +

docx-mcp

+
+
+ + Dark + Light + + Connecting... +
+
+
+ +
+
+
+ + + + diff --git a/src/DocxMcp.Ui/wwwroot/js/app.js b/src/DocxMcp.Ui/wwwroot/js/app.js new file mode 100644 index 0000000..161c210 --- /dev/null +++ b/src/DocxMcp.Ui/wwwroot/js/app.js @@ -0,0 +1,52 @@ +import { renderSessionList } from './views/sessionList.js'; +import { renderSessionDetail } from './views/sessionDetail.js'; +import { renderDiffView } from './views/diffView.js'; +import { connectSSE } from './sseClient.js'; + +const content = document.getElementById('app-content'); +const sse = connectSSE('/api/events'); + +function route() { + const hash = location.hash || '#/sessions'; + const parts = hash.replace('#/', '').split('/'); + const page = parts[0]; + + content.innerHTML = ''; + + switch (page) { + case 'session': + if (parts[1]) { + renderSessionDetail(content, parts[1], sse); + } else { + renderSessionList(content, sse); + } + break; + case 'diff': + if (parts[1] && parts[2]) { + renderDiffView(content, parts[1], parseInt(parts[2])); + } else { + renderSessionList(content, sse); + } + break; + case 'sessions': + default: + renderSessionList(content, sse); + break; + } +} + +window.addEventListener('hashchange', route); +route(); + +// Theme toggle +const themeToggle = document.getElementById('theme-toggle'); +if (themeToggle) { + themeToggle.addEventListener('change', (e) => { + const provider = document.getElementById('design-provider'); + if (provider) { + const isDark = e.target.checked; + provider.setAttribute('base-layer-luminance', isDark ? '0.15' : '1'); + document.body.style.background = isDark ? '#1a1a1a' : '#fafafa'; + } + }); +} diff --git a/src/DocxMcp.Ui/wwwroot/js/documentTree.js b/src/DocxMcp.Ui/wwwroot/js/documentTree.js new file mode 100644 index 0000000..49396c1 --- /dev/null +++ b/src/DocxMcp.Ui/wwwroot/js/documentTree.js @@ -0,0 +1,75 @@ +/** + * Build a fluent-tree-view from the DOM generated by docx-preview.js. + * Inspects the rendered HTML to extract headings, paragraphs, and tables. + */ +export function buildTreeFromPreview(previewContainer) { + const treeView = document.createElement('fluent-tree-view'); + + // docx-preview wraps content in a div with class "docx-wrapper" or the rendered section + const wrapper = previewContainer.querySelector('.docx-wrapper') + || previewContainer.querySelector('section.docx') + || previewContainer; + + if (!wrapper || !wrapper.children) return treeView; + + let pIdx = 0, tIdx = 0; + + for (const child of wrapper.children) { + // Skip style elements and non-content + if (child.tagName === 'STYLE' || child.tagName === 'LINK') continue; + + const item = document.createElement('fluent-tree-item'); + + if (child.tagName?.match(/^H[1-6]$/i)) { + const level = child.tagName[1]; + const text = truncate(child.textContent, 40); + item.textContent = `H${level}: ${text}`; + child.id = child.id || `el-h-${pIdx}`; + item.dataset.target = child.id; + pIdx++; + } + else if (child.tagName === 'P' || child.classList?.contains('docx')) { + const text = truncate(child.textContent, 40); + if (!text.trim()) { pIdx++; continue; } + item.textContent = `P[${pIdx}]: ${text}`; + child.id = child.id || `el-p-${pIdx}`; + item.dataset.target = child.id; + pIdx++; + } + else if (child.tagName === 'TABLE') { + const rows = child.querySelectorAll('tr').length; + item.textContent = `Table[${tIdx}] (${rows} rows)`; + child.id = child.id || `el-t-${tIdx}`; + item.dataset.target = child.id; + tIdx++; + } + else { + pIdx++; + continue; + } + + item.addEventListener('click', () => { + const target = previewContainer.querySelector(`#${item.dataset.target}`); + target?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // Brief highlight + target?.classList.add('highlight-flash'); + setTimeout(() => target?.classList.remove('highlight-flash'), 1500); + }); + + treeView.appendChild(item); + } + + if (treeView.children.length === 0) { + const empty = document.createElement('fluent-tree-item'); + empty.textContent = '(empty document)'; + empty.setAttribute('disabled', ''); + treeView.appendChild(empty); + } + + return treeView; +} + +function truncate(text, maxLen) { + const clean = (text || '').replace(/\s+/g, ' ').trim(); + return clean.length > maxLen ? clean.substring(0, maxLen) + '...' : clean; +} diff --git a/src/DocxMcp.Ui/wwwroot/js/docxRenderer.js b/src/DocxMcp.Ui/wwwroot/js/docxRenderer.js new file mode 100644 index 0000000..5d32fe6 --- /dev/null +++ b/src/DocxMcp.Ui/wwwroot/js/docxRenderer.js @@ -0,0 +1,47 @@ +/** + * Render DOCX bytes from the API into a container using docx-preview.js. + * Includes debounce for slider scrubbing and a loading spinner. + */ + +let renderTimeout = null; + +export async function renderDocxAtPosition(container, sessionId, position, debounceMs = 200) { + clearTimeout(renderTimeout); + + return new Promise((resolve) => { + renderTimeout = setTimeout(async () => { + container.innerHTML = '
'; + + try { + const resp = await fetch(`/api/sessions/${sessionId}/docx?position=${position}`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const blob = await resp.blob(); + + container.innerHTML = ''; + const styleEl = document.createElement('style'); + container.appendChild(styleEl); + + await window.docx.renderAsync(blob, container, styleEl, { + className: "docx-preview", + inWrapper: true, + ignoreWidth: false, + ignoreHeight: false, + ignoreFonts: false, + breakPages: true, + useBase64URL: false, + experimental: true, + trimXmlDeclaration: true + }); + } catch (err) { + container.innerHTML = `

Failed to render document: ${err.message}

`; + } + + resolve(); + }, debounceMs); + }); +} + +/** Immediate render (no debounce) — used for diff view. */ +export async function renderDocxImmediate(container, sessionId, position) { + return renderDocxAtPosition(container, sessionId, position, 0); +} diff --git a/src/DocxMcp.Ui/wwwroot/js/sseClient.js b/src/DocxMcp.Ui/wwwroot/js/sseClient.js new file mode 100644 index 0000000..ee6d84c --- /dev/null +++ b/src/DocxMcp.Ui/wwwroot/js/sseClient.js @@ -0,0 +1,38 @@ +/** SSE client with auto-reconnect and typed event dispatch. */ +export function connectSSE(url) { + const listeners = {}; + const source = new EventSource(url); + const statusBadge = document.getElementById('sse-status'); + + function updateStatus(connected) { + if (statusBadge) { + statusBadge.setAttribute('color', connected ? 'success' : 'danger'); + statusBadge.textContent = connected ? 'Live' : 'Disconnected'; + } + } + + source.onopen = () => updateStatus(true); + source.onerror = () => updateStatus(false); + + return { + on(eventType, callback) { + if (!listeners[eventType]) { + listeners[eventType] = []; + source.addEventListener(eventType, (e) => { + let data; + try { data = JSON.parse(e.data); } catch { data = e.data; } + listeners[eventType].forEach(cb => cb(data)); + }); + } + listeners[eventType].push(callback); + }, + off(eventType, callback) { + if (listeners[eventType]) { + listeners[eventType] = listeners[eventType].filter(cb => cb !== callback); + } + }, + close() { + source.close(); + } + }; +} diff --git a/src/DocxMcp.Ui/wwwroot/js/views/diffView.js b/src/DocxMcp.Ui/wwwroot/js/views/diffView.js new file mode 100644 index 0000000..c063c46 --- /dev/null +++ b/src/DocxMcp.Ui/wwwroot/js/views/diffView.js @@ -0,0 +1,102 @@ +import { renderDocxImmediate } from '../docxRenderer.js'; + +/** Diff View page — #/diff/{sessionId}/{position} */ +export async function renderDiffView(container, sessionId, position) { + const beforePos = Math.max(0, position - 1); + const afterPos = position; + + // Fetch history entry for this patch + let patchEntry = null; + try { + const resp = await fetch(`/api/sessions/${sessionId}/history?limit=200`); + const entries = await resp.json(); + patchEntry = entries.find(e => e.position === afterPos); + } catch { /* ok */ } + + // Fetch total count + let detail = null; + try { + const resp = await fetch(`/api/sessions/${sessionId}`); + detail = await resp.json(); + } catch { /* ok */ } + + const walCount = detail?.walCount || afterPos; + const desc = patchEntry?.description || `Position ${beforePos} to ${afterPos}`; + const patchJson = patchEntry?.patches || '[]'; + + let prettyJson; + try { + prettyJson = JSON.stringify(JSON.parse(patchJson), null, 2); + } catch { + prettyJson = patchJson; + } + + container.innerHTML = ` +
+
+ Back +

Patch #${afterPos}: Position ${beforePos} → ${afterPos}

+ ${escapeHtml(desc)} +
+ Before .docx + After .docx +
+
+
+
Before (Position ${beforePos})
+
+
+
+
After (Position ${afterPos})
+
+
+
+
+
+ Patch Operations +
${escapeHtml(prettyJson)}
+
+
+ Prev Patch + Patch ${afterPos} of ${walCount} + = walCount ? 'disabled' : ''}>Next Patch +
+
+
`; + + // Render both documents in parallel + const beforeContainer = document.getElementById('diff-before'); + const afterContainer = document.getElementById('diff-after'); + + await Promise.all([ + renderDocxImmediate(beforeContainer, sessionId, beforePos), + renderDocxImmediate(afterContainer, sessionId, afterPos) + ]); + + // Navigation + document.getElementById('btn-back-diff').addEventListener('click', () => { + location.hash = `#/session/${sessionId}`; + }); + + document.getElementById('btn-dl-before').addEventListener('click', () => { + window.open(`/api/sessions/${sessionId}/docx?position=${beforePos}`, '_blank'); + }); + + document.getElementById('btn-dl-after').addEventListener('click', () => { + window.open(`/api/sessions/${sessionId}/docx?position=${afterPos}`, '_blank'); + }); + + document.getElementById('btn-prev-patch').addEventListener('click', () => { + if (afterPos > 1) location.hash = `#/diff/${sessionId}/${afterPos - 1}`; + }); + + document.getElementById('btn-next-patch').addEventListener('click', () => { + if (afterPos < walCount) location.hash = `#/diff/${sessionId}/${afterPos + 1}`; + }); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text || ''; + return div.innerHTML; +} diff --git a/src/DocxMcp.Ui/wwwroot/js/views/sessionDetail.js b/src/DocxMcp.Ui/wwwroot/js/views/sessionDetail.js new file mode 100644 index 0000000..2566ff6 --- /dev/null +++ b/src/DocxMcp.Ui/wwwroot/js/views/sessionDetail.js @@ -0,0 +1,220 @@ +import { renderDocxAtPosition } from '../docxRenderer.js'; +import { buildTreeFromPreview } from '../documentTree.js'; + +/** Session Detail page — #/session/{id} */ +export async function renderSessionDetail(container, sessionId, sse) { + container.innerHTML = '
'; + + // Fetch session detail + let detail; + try { + const resp = await fetch(`/api/sessions/${sessionId}`); + if (!resp.ok) throw new Error(`Session not found`); + detail = await resp.json(); + } catch (err) { + container.innerHTML = `

${err.message}

`; + return; + } + + const walCount = detail.walCount; + const cursorPos = detail.cursorPosition >= 0 ? detail.cursorPosition : walCount; + let currentPos = cursorPos; + + // Fetch history + let history = []; + try { + const hResp = await fetch(`/api/sessions/${sessionId}/history?limit=200`); + history = await hResp.json(); + } catch { /* ok */ } + + const sourceName = detail.sourcePath + ? detail.sourcePath.split('/').pop().split('\\').pop() + : '(new document)'; + + container.innerHTML = ` +
+
+ Back +

${sessionId}

+ ${sourceName} + Export .docx +
+
+
+
+
+ Position: ${currentPos}/${walCount} + Prev + + = walCount ? 'disabled' : ''}>Next +
+
+
+
+
`; + + // Refs + const previewPanel = document.getElementById('preview-panel'); + const treePanel = document.getElementById('tree-panel'); + const slider = document.getElementById('pos-slider'); + const posLabel = document.getElementById('pos-label'); + const btnPrev = document.getElementById('btn-prev'); + const btnNext = document.getElementById('btn-next'); + + // Build timeline track + buildTimelineTrack(walCount, detail.checkpointPositions, currentPos); + + // Build history table + buildHistoryTable(history, sessionId); + + // --- Render document at current position --- + async function renderAtPosition(pos) { + currentPos = pos; + posLabel.textContent = `Position: ${pos}/${walCount}`; + slider.value = pos; + btnPrev.disabled = pos <= 0; + btnNext.disabled = pos >= walCount; + + // Update timeline track highlights + buildTimelineTrack(walCount, detail.checkpointPositions, pos); + + await renderDocxAtPosition(previewPanel, sessionId, pos); + + // Rebuild tree from rendered preview + treePanel.innerHTML = ''; + const tree = buildTreeFromPreview(previewPanel); + treePanel.appendChild(tree); + } + + await renderAtPosition(currentPos); + + // --- Event handlers --- + document.getElementById('btn-back').addEventListener('click', () => { + location.hash = '#/sessions'; + }); + + document.getElementById('btn-export').addEventListener('click', () => { + window.open(`/api/sessions/${sessionId}/docx?position=${currentPos}`, '_blank'); + }); + + slider.addEventListener('input', (e) => { + const newPos = parseInt(e.target.value); + posLabel.textContent = `Position: ${newPos}/${walCount}`; + }); + + slider.addEventListener('change', (e) => { + renderAtPosition(parseInt(e.target.value)); + }); + + btnPrev.addEventListener('click', () => { + if (currentPos > 0) renderAtPosition(currentPos - 1); + }); + + btnNext.addEventListener('click', () => { + if (currentPos < walCount) renderAtPosition(currentPos + 1); + }); + + // SSE: live update when new patches arrive + const onChanged = async () => { + try { + const resp = await fetch(`/api/sessions/${sessionId}`); + if (!resp.ok) return; + const updated = await resp.json(); + if (updated.walCount > walCount) { + detail.walCount = updated.walCount; + slider.max = updated.walCount; + + // Refresh history + const hResp = await fetch(`/api/sessions/${sessionId}/history?limit=200`); + const newHistory = await hResp.json(); + buildHistoryTable(newHistory, sessionId); + buildTimelineTrack(updated.walCount, updated.checkpointPositions, currentPos); + } + } catch { /* ignore */ } + }; + sse.on('index.changed', onChanged); + + // Cleanup + const observer = new MutationObserver(() => { + if (!document.contains(previewPanel)) { + sse.off('index.changed', onChanged); + observer.disconnect(); + } + }); + observer.observe(container, { childList: true }); +} + +function buildTimelineTrack(walCount, checkpoints, currentPos) { + const track = document.getElementById('timeline-track'); + if (!track) return; + track.innerHTML = ''; + + const ckptSet = new Set(checkpoints || []); + + for (let i = 0; i <= walCount; i++) { + const dot = document.createElement('span'); + dot.className = 'timeline-dot'; + dot.title = `Position ${i}`; + + if (i === 0) dot.classList.add('baseline'); + else if (ckptSet.has(i)) dot.classList.add('checkpoint'); + if (i === currentPos) dot.classList.add('current'); + + dot.addEventListener('click', () => { + const slider = document.getElementById('pos-slider'); + if (slider) { + slider.value = i; + slider.dispatchEvent(new Event('change')); + } + }); + + track.appendChild(dot); + + if (i < walCount) { + const seg = document.createElement('span'); + seg.className = 'timeline-dot segment'; + track.appendChild(seg); + } + } +} + +function buildHistoryTable(history, sessionId) { + const hc = document.getElementById('history-container'); + if (!hc) return; + + if (history.length === 0) { + hc.innerHTML = '

No patches yet.

'; + return; + } + + hc.innerHTML = ` + + + + + + ${history.map(h => ` + + + + + + `).join('')} + +
#TimeDescription
${h.position}${h.isCheckpoint ? ' ckpt' : ''}${new Date(h.timestamp).toLocaleTimeString()}${escapeHtml(h.description)}Diff
`; + + hc.querySelectorAll('.btn-diff').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const pos = btn.dataset.pos; + location.hash = `#/diff/${sessionId}/${pos}`; + }); + }); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text || ''; + return div.innerHTML; +} diff --git a/src/DocxMcp.Ui/wwwroot/js/views/sessionList.js b/src/DocxMcp.Ui/wwwroot/js/views/sessionList.js new file mode 100644 index 0000000..fbd8a93 --- /dev/null +++ b/src/DocxMcp.Ui/wwwroot/js/views/sessionList.js @@ -0,0 +1,99 @@ +/** Session List page — #/sessions */ +export async function renderSessionList(container, sse) { + container.innerHTML = ` +
+

Active Sessions

+
+
+
+ +
`; + + await loadSessions(); + + // Live refresh via SSE + const onIndexChanged = () => loadSessions(); + sse.on('index.changed', onIndexChanged); + + // Cleanup when navigating away (container gets cleared) + const observer = new MutationObserver(() => { + if (!document.contains(container.querySelector('.session-list'))) { + sse.off('index.changed', onIndexChanged); + observer.disconnect(); + } + }); + observer.observe(container, { childList: true }); +} + +async function loadSessions() { + const gridContainer = document.getElementById('session-grid-container'); + if (!gridContainer) return; + + try { + const resp = await fetch('/api/sessions'); + const sessions = await resp.json(); + + if (sessions.length === 0) { + gridContainer.innerHTML = ` +
+

No active sessions

+

Open a document with docx-mcp or docx-cli to see it here.

+
`; + return; + } + + gridContainer.innerHTML = ` + + + + + + + + + + + + ${sessions.map(s => ` + + + + + + + `).join('')} + +
IDSourceModifiedWALPos
${s.id}${s.sourcePath ? fileName(s.sourcePath) : '(new document)'}${timeAgo(s.lastModifiedAt)}${s.walCount}${s.cursorPosition >= 0 ? s.cursorPosition : s.walCount}
`; + + // Click handler + gridContainer.querySelectorAll('tr[data-id]').forEach(tr => { + tr.addEventListener('click', () => { + location.hash = `#/session/${tr.dataset.id}`; + }); + }); + + const footer = document.getElementById('sessions-footer'); + if (footer) footer.textContent = `${sessions.length} session(s)`; + } catch (err) { + gridContainer.innerHTML = `

Failed to load sessions: ${err.message}

`; + } +} + +function fileName(path) { + return path?.split('/').pop()?.split('\\').pop() || path; +} + +function timeAgo(isoStr) { + if (!isoStr) return '—'; + const date = new Date(isoStr); + const now = new Date(); + const diffMs = now - date; + const diffS = Math.floor(diffMs / 1000); + if (diffS < 60) return `${diffS}s ago`; + const diffM = Math.floor(diffS / 60); + if (diffM < 60) return `${diffM}m ago`; + const diffH = Math.floor(diffM / 60); + if (diffH < 24) return `${diffH}h ago`; + const diffD = Math.floor(diffH / 24); + return `${diffD}d ago`; +} diff --git a/src/DocxMcp/DocxMcp.csproj b/src/DocxMcp/DocxMcp.csproj index 1a4d1c3..0f36a7c 100644 --- a/src/DocxMcp/DocxMcp.csproj +++ b/src/DocxMcp/DocxMcp.csproj @@ -14,6 +14,7 @@ + diff --git a/src/DocxMcp/SessionManager.cs b/src/DocxMcp/SessionManager.cs index 9776ccc..3973555 100644 --- a/src/DocxMcp/SessionManager.cs +++ b/src/DocxMcp/SessionManager.cs @@ -854,7 +854,7 @@ private static string GenerateDescription(string patchesJson) /// Replay a single patch operation against a session's document. /// Uses the same logic as PatchTool.ApplyPatch but without MCP tool wiring. /// - private static void ReplayPatch(DocxSession session, string patchesJson) + internal static void ReplayPatch(DocxSession session, string patchesJson) { var patchArray = JsonDocument.Parse(patchesJson).RootElement; if (patchArray.ValueKind != JsonValueKind.Array) From 1048c29f034703f48e698e7a8e99509a39352036 Mon Sep 17 00:00:00 2001 From: Laurent Valdes Date: Wed, 4 Feb 2026 14:06:38 +0100 Subject: [PATCH 3/3] fix: correct InternalsVisibleTo assembly name for docx-ui Co-Authored-By: Claude Opus 4.5 --- src/DocxMcp/DocxMcp.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DocxMcp/DocxMcp.csproj b/src/DocxMcp/DocxMcp.csproj index 0f36a7c..c96f0c7 100644 --- a/src/DocxMcp/DocxMcp.csproj +++ b/src/DocxMcp/DocxMcp.csproj @@ -14,7 +14,7 @@ - +