Phase 20C Implement Companion detection and communication readiness.

This commit is contained in:
Nick Beckley 2026-07-03 21:57:45 +01:00
parent 9b1f3e9a8d
commit 6f41605f08
17 changed files with 768 additions and 11 deletions

View File

@ -19,6 +19,7 @@ public sealed class WordCompanionHostController(ICurrentUserService currentUser,
{
IsAuthenticated = currentUser.IsAuthenticated,
IsAdmin = isAdmin,
UserID = currentUser.UserId,
DisplayName = currentUser.DisplayName,
LoginUrl = Url.Action("Login", "Account", new { returnUrl = "/word-companion" }) ?? "/Account/Login?returnUrl=%2Fword-companion"
});

View File

@ -1,9 +1,68 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using PlotLine.Models;
using PlotLine.Services;
using System.Security.Claims;
namespace PlotLine.Hubs;
[Authorize]
public sealed class WordCompanionFollowHub : Hub
public sealed class WordCompanionFollowHub(IWordCompanionPresenceService presence) : Hub
{
public override async Task OnConnectedAsync()
{
if (TryGetUserId(out var userId))
{
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
}
await base.OnConnectedAsync();
}
public async Task<WordCompanionPresenceStatus> WatchCompanionPresence()
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
return await presence.GetStatusAsync(userId);
}
public async Task<WordCompanionPresenceStatus> RegisterCompanion(WordCompanionPresenceRegistration registration)
{
var userId = RequireUserId();
if (registration.UserID.HasValue && registration.UserID.Value != userId)
{
throw new HubException("The Companion is signed in as a different user.");
}
var status = await presence.RegisterAsync(userId, Context.ConnectionId, registration);
await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status);
return status;
}
public async Task<WordCompanionPresenceStatus> CompanionHeartbeat(WordCompanionPresenceHeartbeat heartbeat)
{
var userId = RequireUserId();
var status = await presence.HeartbeatAsync(userId, Context.ConnectionId, heartbeat);
await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status);
return status;
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var status = await presence.DisconnectAsync(Context.ConnectionId);
if (status is not null)
{
await Clients.Group(PresenceGroup(status.UserID)).SendAsync("WordCompanionPresenceChanged", status);
}
await base.OnDisconnectedAsync(exception);
}
private int RequireUserId()
=> TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use the Word Companion.");
private bool TryGetUserId(out int userId)
=> int.TryParse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier), out userId);
private static string PresenceGroup(int userId) => $"word-companion-presence:{userId}";
}

View File

@ -0,0 +1,44 @@
namespace PlotLine.Models;
public static class WordCompanionPresenceStatuses
{
public const string Connecting = "Connecting";
public const string Connected = "Connected";
public const string Offline = "Offline";
}
public sealed class WordCompanionPresenceRegistration
{
public int? UserID { get; init; }
public string? CompanionVersion { get; init; }
public string? MachineName { get; init; }
public bool DocumentOpen { get; init; }
public string? CurrentDocumentName { get; init; }
public int? LinkedProjectID { get; init; }
public int? LinkedBookID { get; init; }
}
public sealed class WordCompanionPresenceHeartbeat
{
public string? CompanionVersion { get; init; }
public string? MachineName { get; init; }
public bool DocumentOpen { get; init; }
public string? CurrentDocumentName { get; init; }
public int? LinkedProjectID { get; init; }
public int? LinkedBookID { get; init; }
}
public sealed class WordCompanionPresenceStatus
{
public int UserID { get; init; }
public bool IsConnected { get; init; }
public string Status { get; init; } = WordCompanionPresenceStatuses.Offline;
public string? CompanionVersion { get; init; }
public string? MachineName { get; init; }
public bool DocumentOpen { get; init; }
public string? CurrentDocumentName { get; init; }
public int? LinkedProjectID { get; init; }
public int? LinkedBookID { get; init; }
public DateTime? ConnectedUtc { get; init; }
public DateTime? LastHeartbeatUtc { get; init; }
}

View File

@ -170,6 +170,8 @@ public class Program
builder.Services.AddScoped<IAcceptanceService, AcceptanceService>();
builder.Services.AddScoped<IImportService, ImportService>();
builder.Services.AddScoped<IWordCompanionService, WordCompanionService>();
builder.Services.AddSingleton<IWordCompanionPresenceService, WordCompanionPresenceService>();
builder.Services.AddHostedService<WordCompanionPresenceMonitor>();
builder.Services.AddScoped<IOnboardingService, OnboardingService>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();

View File

@ -26,6 +26,7 @@ public sealed class OnboardingService(
IBookRepository books,
IBookService bookService,
IProjectActivityService activity,
IWordCompanionPresenceService companionPresence,
ICurrentUserService currentUser) : IOnboardingService
{
private static readonly HashSet<string> WritingJourneys = new(StringComparer.Ordinal)
@ -62,6 +63,7 @@ public sealed class OnboardingService(
var selectedBook = state.BookID.HasValue
? await books.GetAsync(state.BookID.Value)
: null;
var presence = await companionPresence.GetStatusAsync(RequireUserId());
return new OnboardingWizardViewModel
{
@ -73,6 +75,7 @@ public sealed class OnboardingService(
BookID = state.BookID,
SelectedProjectName = selectedProject?.ProjectName,
SelectedBookTitle = selectedBook is null ? null : BookOptionTitle(selectedBook),
CompanionPresence = ToPresenceViewModel(presence),
ProjectOptions = projectOptions,
BookOptions = bookOptions,
ProjectForm = new ProjectOnboardingForm
@ -113,6 +116,22 @@ public sealed class OnboardingService(
}
var state = await onboarding.GetAsync(currentUser.UserId.Value);
if (state is not null && IsMicrosoftWordState(state))
{
var status = await companionPresence.GetStatusAsync(currentUser.UserId.Value);
return new OnboardingNudgeViewModel
{
ShouldShow = true,
IsCompanionPresence = true,
CompanionConnected = status.IsConnected,
Title = status.IsConnected ? "Word Companion Connected" : "Connect your Word Companion",
Description = status.IsConnected
? "Your Word Companion is ready for this story setup."
: "Open Word, start the Companion, and sign in with your PlotDirector account.",
ButtonText = status.IsConnected ? "View setup" : "Connect Companion"
};
}
if (state?.OnboardingCompletedUtc is not null)
{
return new OnboardingNudgeViewModel { ShouldShow = false };
@ -137,8 +156,15 @@ public sealed class OnboardingService(
}
public async Task<bool> ShouldShowWordCompanionHeaderAsync()
=> currentUser.UserId.HasValue
&& await onboarding.ShouldShowWordCompanionHeaderAsync(currentUser.UserId.Value);
{
if (!currentUser.UserId.HasValue)
{
return false;
}
var state = await onboarding.GetAsync(currentUser.UserId.Value);
return state is not null && IsMicrosoftWordState(state);
}
public async Task MoveToNextStepAsync()
{
@ -391,6 +417,20 @@ public sealed class OnboardingService(
private static string Clean(string? value) => (value ?? string.Empty).Trim();
private static bool IsMicrosoftWordState(UserOnboardingState state)
=> string.Equals(state.WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal)
|| state.UsesWordCompanion == true;
private static CompanionPresenceViewModel ToPresenceViewModel(WordCompanionPresenceStatus status)
=> new()
{
IsConnected = status.IsConnected,
Status = status.Status,
CompanionVersion = status.CompanionVersion,
CurrentDocumentName = status.CurrentDocumentName,
LastHeartbeatUtc = status.LastHeartbeatUtc
};
private static string? CleanOptional(string? value)
{
var cleaned = Clean(value);

View File

@ -0,0 +1,32 @@
using Microsoft.AspNetCore.SignalR;
using PlotLine.Hubs;
namespace PlotLine.Services;
public sealed class WordCompanionPresenceMonitor(
IWordCompanionPresenceService presence,
IHubContext<WordCompanionFollowHub> hub) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
var offlineStatuses = await presence.MarkStaleOfflineAsync();
foreach (var status in offlineStatuses)
{
await hub.Clients
.Group(PresenceGroup(status.UserID))
.SendAsync("WordCompanionPresenceChanged", status, stoppingToken);
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
}
private static string PresenceGroup(int userId) => $"word-companion-presence:{userId}";
}

View File

@ -0,0 +1,173 @@
using System.Collections.Concurrent;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IWordCompanionPresenceService
{
Task<WordCompanionPresenceStatus> RegisterAsync(int userId, string connectionId, WordCompanionPresenceRegistration registration);
Task<WordCompanionPresenceStatus> HeartbeatAsync(int userId, string connectionId, WordCompanionPresenceHeartbeat heartbeat);
Task<WordCompanionPresenceStatus?> DisconnectAsync(string connectionId);
Task<WordCompanionPresenceStatus> GetStatusAsync(int userId);
Task<bool> IsConnectedAsync(int userId);
Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync();
}
public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
{
private static readonly TimeSpan HeartbeatTimeout = TimeSpan.FromSeconds(55);
private readonly ConcurrentDictionary<int, PresenceRecord> recordsByUser = new();
private readonly ConcurrentDictionary<string, int> usersByConnection = new(StringComparer.Ordinal);
public Task<WordCompanionPresenceStatus> RegisterAsync(int userId, string connectionId, WordCompanionPresenceRegistration registration)
{
var now = DateTime.UtcNow;
var record = new PresenceRecord
{
UserID = userId,
ConnectionID = connectionId,
Status = WordCompanionPresenceStatuses.Connected,
CompanionVersion = Clean(registration.CompanionVersion),
MachineName = Clean(registration.MachineName),
DocumentOpen = registration.DocumentOpen,
CurrentDocumentName = Clean(registration.CurrentDocumentName),
LinkedProjectID = Positive(registration.LinkedProjectID),
LinkedBookID = Positive(registration.LinkedBookID),
ConnectedUtc = now,
LastHeartbeatUtc = now
};
recordsByUser.AddOrUpdate(userId, record, (_, _) => record);
usersByConnection[connectionId] = userId;
return Task.FromResult(ToStatus(record, now));
}
public Task<WordCompanionPresenceStatus> HeartbeatAsync(int userId, string connectionId, WordCompanionPresenceHeartbeat heartbeat)
{
var now = DateTime.UtcNow;
var record = recordsByUser.AddOrUpdate(
userId,
_ => new PresenceRecord
{
UserID = userId,
ConnectionID = connectionId,
ConnectedUtc = now
},
(_, existing) =>
{
existing.ConnectionID = connectionId;
return existing;
});
record.Status = WordCompanionPresenceStatuses.Connected;
record.CompanionVersion = Clean(heartbeat.CompanionVersion) ?? record.CompanionVersion;
record.MachineName = Clean(heartbeat.MachineName) ?? record.MachineName;
record.DocumentOpen = heartbeat.DocumentOpen;
record.CurrentDocumentName = Clean(heartbeat.CurrentDocumentName);
record.LinkedProjectID = Positive(heartbeat.LinkedProjectID);
record.LinkedBookID = Positive(heartbeat.LinkedBookID);
record.LastHeartbeatUtc = now;
usersByConnection[connectionId] = userId;
return Task.FromResult(ToStatus(record, now));
}
public Task<WordCompanionPresenceStatus?> DisconnectAsync(string connectionId)
{
if (!usersByConnection.TryRemove(connectionId, out var userId)
|| !recordsByUser.TryGetValue(userId, out var record)
|| !string.Equals(record.ConnectionID, connectionId, StringComparison.Ordinal))
{
return Task.FromResult<WordCompanionPresenceStatus?>(null);
}
record.Status = WordCompanionPresenceStatuses.Offline;
record.ConnectionID = string.Empty;
return Task.FromResult<WordCompanionPresenceStatus?>(ToStatus(record, DateTime.UtcNow));
}
public Task<WordCompanionPresenceStatus> GetStatusAsync(int userId)
{
if (!recordsByUser.TryGetValue(userId, out var record))
{
return Task.FromResult(new WordCompanionPresenceStatus { UserID = userId });
}
return Task.FromResult(ToStatus(record, DateTime.UtcNow));
}
public async Task<bool> IsConnectedAsync(int userId)
=> (await GetStatusAsync(userId)).IsConnected;
public Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync()
{
var now = DateTime.UtcNow;
var changed = new List<WordCompanionPresenceStatus>();
foreach (var record in recordsByUser.Values)
{
if (!string.Equals(record.Status, WordCompanionPresenceStatuses.Connected, StringComparison.Ordinal)
|| !record.LastHeartbeatUtc.HasValue
|| now - record.LastHeartbeatUtc.Value <= HeartbeatTimeout)
{
continue;
}
record.Status = WordCompanionPresenceStatuses.Offline;
if (!string.IsNullOrWhiteSpace(record.ConnectionID))
{
usersByConnection.TryRemove(record.ConnectionID, out _);
record.ConnectionID = string.Empty;
}
changed.Add(ToStatus(record, now));
}
return Task.FromResult<IReadOnlyList<WordCompanionPresenceStatus>>(changed);
}
private static WordCompanionPresenceStatus ToStatus(PresenceRecord record, DateTime now)
{
var connected = string.Equals(record.Status, WordCompanionPresenceStatuses.Connected, StringComparison.Ordinal)
&& record.LastHeartbeatUtc.HasValue
&& now - record.LastHeartbeatUtc.Value <= HeartbeatTimeout;
return new WordCompanionPresenceStatus
{
UserID = record.UserID,
IsConnected = connected,
Status = connected ? WordCompanionPresenceStatuses.Connected : WordCompanionPresenceStatuses.Offline,
CompanionVersion = record.CompanionVersion,
MachineName = record.MachineName,
DocumentOpen = record.DocumentOpen,
CurrentDocumentName = record.CurrentDocumentName,
LinkedProjectID = record.LinkedProjectID,
LinkedBookID = record.LinkedBookID,
ConnectedUtc = record.ConnectedUtc,
LastHeartbeatUtc = record.LastHeartbeatUtc
};
}
private static string? Clean(string? value)
{
var cleaned = (value ?? string.Empty).Trim();
return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned;
}
private static int? Positive(int? value)
=> value is > 0 ? value.Value : null;
private sealed class PresenceRecord
{
public int UserID { get; init; }
public string ConnectionID { get; set; } = string.Empty;
public string Status { get; set; } = WordCompanionPresenceStatuses.Offline;
public string? CompanionVersion { get; set; }
public string? MachineName { get; set; }
public bool DocumentOpen { get; set; }
public string? CurrentDocumentName { get; set; }
public int? LinkedProjectID { get; set; }
public int? LinkedBookID { get; set; }
public DateTime? ConnectedUtc { get; set; }
public DateTime? LastHeartbeatUtc { get; set; }
}
}

View File

@ -15,6 +15,7 @@ public sealed class OnboardingWizardViewModel
public string? SelectedProjectName { get; set; }
public string? SelectedBookTitle { get; set; }
public bool IsMicrosoftWordPath => string.Equals(WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal);
public CompanionPresenceViewModel CompanionPresence { get; set; } = new();
public IReadOnlyList<OnboardingOptionViewModel> Options { get; set; } = [];
public IReadOnlyList<OnboardingProjectOptionViewModel> ProjectOptions { get; set; } = [];
public IReadOnlyList<OnboardingBookOptionViewModel> BookOptions { get; set; } = [];
@ -98,4 +99,15 @@ public sealed class OnboardingNudgeViewModel
public string ButtonText { get; init; } = "Start setup";
public string Title { get; init; } = "Set up PlotDirector around the way you write";
public string Description { get; init; } = "Answer a few quick questions so PlotDirector can guide your first project.";
public bool IsCompanionPresence { get; init; }
public bool CompanionConnected { get; init; }
}
public sealed class CompanionPresenceViewModel
{
public bool IsConnected { get; init; }
public string Status { get; init; } = "Offline";
public string? CompanionVersion { get; init; }
public string? CurrentDocumentName { get; init; }
public DateTime? LastHeartbeatUtc { get; init; }
}

View File

@ -4,6 +4,7 @@ public sealed class WordCompanionHostViewModel
{
public bool IsAuthenticated { get; init; }
public bool IsAdmin { get; init; }
public int? UserID { get; init; }
public string? DisplayName { get; init; }
public string LoginUrl { get; init; } = string.Empty;
}

View File

@ -236,8 +236,8 @@
<p class="eyebrow">Ready for the next phase</p>
@if (Model.IsMicrosoftWordPath)
{
<h1 id="onboarding-title">Next: connect the Word Companion</h1>
<p>In the next phase, PlotDirector will guide you through opening the Word Companion and linking it to @Model.SelectedBookTitle.</p>
<h1 id="onboarding-title">Connect your Word Companion</h1>
<p>Open Microsoft Word, open your manuscript, start the PlotDirector Word Companion, and sign in using your PlotDirector account.</p>
}
else
{
@ -245,6 +245,36 @@
<p>In a later phase, PlotDirector will let you upload a .docx export from your writing tool and connect it to @Model.SelectedBookTitle.</p>
}
</div>
@if (Model.IsMicrosoftWordPath)
{
<section class="onboarding-companion-card @(Model.CompanionPresence.IsConnected ? "is-connected" : "is-offline")"
data-word-companion-presence
data-word-companion-status="@Model.CompanionPresence.Status"
aria-label="Word Companion connection">
<div class="onboarding-companion-mark" aria-hidden="true">
<span></span>
</div>
<div class="onboarding-companion-body">
<p class="eyebrow">Word Companion</p>
<h2 data-word-companion-waiting-status>@(Model.CompanionPresence.IsConnected ? "Word Companion Connected" : "Waiting for Word Companion...")</h2>
<p data-word-companion-connected-copy>
@(Model.CompanionPresence.IsConnected
? "Your Companion is connected. Continue when you are ready."
: "Once connected this page will update automatically. You stay in control before continuing.")
</p>
<dl class="onboarding-companion-details">
<div>
<dt>Version</dt>
<dd data-word-companion-version>@(Model.CompanionPresence.CompanionVersion ?? "Waiting")</dd>
</div>
<div>
<dt>Document</dt>
<dd data-word-companion-document>@(Model.CompanionPresence.CurrentDocumentName ?? "No document reported yet")</dd>
</div>
</dl>
</div>
</section>
}
<dl class="onboarding-summary">
<div>
<dt>Writing journey</dt>
@ -272,7 +302,7 @@
<a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Go to Project Overview</a>
}
<form asp-controller="Onboarding" asp-action="Finish" method="post">
<button class="btn btn-primary" type="submit">Finish setup for now</button>
<button class="btn btn-primary" type="submit">@(Model.IsMicrosoftWordPath ? "Continue" : "Finish setup for now")</button>
</form>
</div>
}

View File

@ -35,11 +35,13 @@
@if (Model.OnboardingNudge.ShouldShow)
{
<section class="onboarding-dashboard-nudge" aria-label="PlotDirector setup">
<section class="onboarding-dashboard-nudge @(Model.OnboardingNudge.CompanionConnected ? "is-connected" : string.Empty)"
data-word-companion-presence="@(Model.OnboardingNudge.IsCompanionPresence.ToString().ToLowerInvariant())"
aria-label="PlotDirector setup">
<div>
<p class="eyebrow">First setup</p>
<h2>Set up PlotDirector around the way you write</h2>
<p>Answer a few quick questions so PlotDirector can guide your first project.</p>
<h2 data-word-companion-dashboard-title>@Model.OnboardingNudge.Title</h2>
<p data-word-companion-dashboard-description>@Model.OnboardingNudge.Description</p>
</div>
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="Index">@Model.OnboardingNudge.ButtonText</a>
</section>

View File

@ -158,6 +158,13 @@
<div class="d-flex align-items-center gap-2 flex-wrap me-sm-1">
@if (showWordCompanionHeader)
{
<span class="word-companion-presence-pill"
data-word-companion-presence
data-word-companion-status="Offline"
title="Word Companion status">
<span class="word-companion-presence-dot" data-word-companion-status-dot></span>
<span data-word-companion-status-label>Offline</span>
</span>
<button class="btn btn-outline-secondary btn-sm word-companion-follow-toggle" type="button" data-word-companion-follow-toggle aria-pressed="false">
Follow Word Companion
</button>
@ -312,6 +319,10 @@
<environment exclude="Production">
<script src="~/js/site.js" asp-append-version="true"></script>
</environment>
@if (User.Identity?.IsAuthenticated == true)
{
<script src="~/js/word-companion-presence.js" asp-append-version="true"></script>
}
@await RenderSectionAsync("Scripts", required: false)
</body>

View File

@ -13,7 +13,11 @@
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<main class="word-companion-shell" data-authenticated="@Model.IsAuthenticated.ToString().ToLowerInvariant()" data-admin="@Model.IsAdmin.ToString().ToLowerInvariant()">
<main class="word-companion-shell"
data-authenticated="@Model.IsAuthenticated.ToString().ToLowerInvariant()"
data-admin="@Model.IsAdmin.ToString().ToLowerInvariant()"
data-user-id="@Model.UserID"
data-companion-version="20C">
<header class="word-companion-header">
<div>
<h1>PlotDirector</h1>
@ -546,6 +550,10 @@
</dialog>
}
</main>
@if (Model.IsAuthenticated)
{
<script src="https://cdn.jsdelivr.net/npm/@@microsoft/signalr@8.0.7/dist/browser/signalr.min.js"></script>
}
<script src="~/js/word-companion-host.js" asp-append-version="true"></script>
</body>
</html>

View File

@ -151,6 +151,98 @@
margin: 0;
}
.onboarding-companion-card {
display: grid;
grid-template-columns: auto 1fr;
gap: 1.1rem;
align-items: center;
margin: 1rem 0 1.25rem;
padding: 1.1rem;
border: 1px solid rgba(31, 42, 68, .12);
border-radius: 8px;
background: linear-gradient(135deg, rgba(47, 111, 99, .1), rgba(182, 138, 70, .12));
}
.onboarding-companion-mark {
position: relative;
width: 4.25rem;
aspect-ratio: 1;
border-radius: 50%;
background: rgba(47, 111, 99, .12);
display: grid;
place-items: center;
}
.onboarding-companion-mark::before,
.onboarding-companion-mark::after {
content: "";
position: absolute;
inset: .55rem;
border: 2px solid rgba(47, 111, 99, .28);
border-radius: 50%;
animation: onboardingPulse 1.8s ease-out infinite;
}
.onboarding-companion-mark::after {
animation-delay: .6s;
}
.onboarding-companion-mark span {
width: 1.25rem;
aspect-ratio: 1;
border-radius: 50%;
background: #b68a46;
box-shadow: 0 0 0 .45rem rgba(182, 138, 70, .18);
}
.onboarding-companion-card.is-connected .onboarding-companion-mark span,
.word-companion-presence-pill.is-connected .word-companion-presence-dot {
background: #2f6f63;
}
.onboarding-companion-card.is-connected .onboarding-companion-mark::before,
.onboarding-companion-card.is-connected .onboarding-companion-mark::after {
animation-play-state: paused;
border-color: rgba(47, 111, 99, .18);
}
.onboarding-companion-body h2 {
margin: 0 0 .35rem;
font-size: 1.35rem;
}
.onboarding-companion-body p:not(.eyebrow) {
margin: 0;
color: var(--bs-secondary-color);
}
.onboarding-companion-details {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .75rem;
margin: 1rem 0 0;
}
.onboarding-companion-details div {
min-width: 0;
border: 1px solid rgba(31, 42, 68, .1);
border-radius: 8px;
padding: .75rem;
background: rgba(255, 255, 255, .56);
}
.onboarding-companion-details dt {
color: var(--bs-secondary-color);
font-weight: 700;
font-size: .82rem;
}
.onboarding-companion-details dd {
margin: .15rem 0 0;
font-weight: 700;
overflow-wrap: anywhere;
}
.onboarding-summary div {
display: flex;
align-items: center;
@ -192,6 +284,47 @@
color: var(--bs-secondary-color);
}
.onboarding-dashboard-nudge.is-connected {
border-color: rgba(47, 111, 99, .28);
background: rgba(47, 111, 99, .12);
}
.word-companion-presence-pill {
display: inline-flex;
align-items: center;
gap: .45rem;
min-height: 31px;
border: 1px solid rgba(31, 42, 68, .16);
border-radius: 999px;
padding: .25rem .7rem;
background: rgba(255, 255, 255, .72);
color: var(--bs-body-color);
font-size: .875rem;
font-weight: 700;
}
.word-companion-presence-dot {
width: .58rem;
aspect-ratio: 1;
border-radius: 50%;
background: #b68a46;
}
.word-companion-presence-pill.is-offline .word-companion-presence-dot {
background: #8b949e;
}
@keyframes onboardingPulse {
0% {
opacity: .75;
transform: scale(.76);
}
100% {
opacity: 0;
transform: scale(1.28);
}
}
@media (max-width: 720px) {
.onboarding-choice-grid,
.onboarding-option-grid--software {
@ -199,8 +332,14 @@
}
.onboarding-dashboard-nudge,
.onboarding-companion-card,
.onboarding-summary div {
align-items: stretch;
grid-template-columns: 1fr;
flex-direction: column;
}
.onboarding-companion-details {
grid-template-columns: 1fr;
}
}

View File

@ -165,6 +165,8 @@
const diagnosticHeading1 = document.querySelector("[data-diagnostic-heading1]");
const diagnosticHeading2 = document.querySelector("[data-diagnostic-heading2]");
const isAuthenticated = shell?.dataset.authenticated === "true";
const userId = Number.parseInt(shell?.dataset.userId || "", 10);
const companionVersion = String(shell?.dataset.companionVersion || "20C").trim();
let projects = [];
let books = [];
let officeReady = false;
@ -242,6 +244,8 @@
let lastLinkedSceneTitle = "";
let lastLinkedChapterTitle = "";
let runtimeLastDebugAt = 0;
let companionPresenceConnection = null;
let companionPresenceHeartbeatTimer = null;
let currentSceneLinks = {
characters: [],
assets: [],
@ -1182,6 +1186,93 @@
return books.find((book) => book.bookId === bookId) || null;
};
const selectedPresenceProjectId = () => {
const projectId = firstRunDocumentBinding?.projectId
|| selectedFirstRunProject()?.projectId
|| selectedProject()?.projectId
|| readStoredId(storageKeys.projectId);
return Number.isInteger(projectId) && projectId > 0 ? projectId : null;
};
const selectedPresenceBookId = () => {
const bookId = firstRunDocumentBinding?.bookId
|| selectedFirstRunBook()?.bookId
|| selectedBook()?.bookId
|| readStoredId(storageKeys.bookId);
return Number.isInteger(bookId) && bookId > 0 ? bookId : null;
};
const currentDocumentName = () => {
const url = String(window.Office?.context?.document?.url || "").trim();
if (!url) {
return "";
}
const lastSegment = url.split(/[\\/]/).filter(Boolean).pop() || "";
try {
return decodeURIComponent(lastSegment);
} catch {
return lastSegment;
}
};
const companionPresencePayload = () => ({
userID: Number.isInteger(userId) && userId > 0 ? userId : null,
companionVersion,
machineName: "",
documentOpen: !!wordHostAvailable,
currentDocumentName: currentDocumentName(),
linkedProjectID: selectedPresenceProjectId(),
linkedBookID: selectedPresenceBookId()
});
const sendCompanionHeartbeat = async () => {
if (!companionPresenceConnection || companionPresenceConnection.state !== signalR.HubConnectionState.Connected) {
return;
}
await companionPresenceConnection.invoke("CompanionHeartbeat", companionPresencePayload());
};
const refreshCompanionPresence = () => {
sendCompanionHeartbeat().catch((error) => {
console.debug("Word Companion heartbeat unavailable.", error);
});
};
const startCompanionPresence = async () => {
if (!isAuthenticated || !window.signalR || companionPresenceConnection) {
return;
}
companionPresenceConnection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/word-companion-follow")
.withAutomaticReconnect()
.build();
companionPresenceConnection.onreconnected(refreshCompanionPresence);
companionPresenceConnection.onclose(() => {
if (companionPresenceHeartbeatTimer) {
window.clearInterval(companionPresenceHeartbeatTimer);
companionPresenceHeartbeatTimer = null;
}
});
await companionPresenceConnection.start();
await companionPresenceConnection.invoke("RegisterCompanion", companionPresencePayload());
companionPresenceHeartbeatTimer = window.setInterval(refreshCompanionPresence, 25000);
};
window.addEventListener("beforeunload", () => {
if (companionPresenceHeartbeatTimer) {
window.clearInterval(companionPresenceHeartbeatTimer);
companionPresenceHeartbeatTimer = null;
}
if (companionPresenceConnection) {
companionPresenceConnection.stop().catch(() => {});
}
});
const setFirstRunStatus = (message) => setText(firstRunStatus, message);
const updateFirstRunButtons = () => {
@ -5177,6 +5268,7 @@
resetStructurePreview();
resetFirstRunPreview("Select a Book to begin.");
await loadBooks(projectId, null);
refreshCompanionPresence();
});
bookSelect?.addEventListener("change", () => {
@ -5194,6 +5286,7 @@
resetFirstRunPreview(selectedFirstRunBook()
? "Ready to scan manuscript structure."
: "Select a Book to begin.");
refreshCompanionPresence();
});
firstRunProjectSelect?.addEventListener("change", async () => {
@ -5206,6 +5299,7 @@
writeStoredId(storageKeys.bookId, null);
resetFirstRunPreview("Select a Book to begin.");
await loadBooks(projectId, null);
refreshCompanionPresence();
});
firstRunBookSelect?.addEventListener("change", () => {
@ -5219,6 +5313,7 @@
resetFirstRunPreview(selectedFirstRunBook()
? "Ready to scan manuscript structure."
: "Select a Book to begin.");
refreshCompanionPresence();
});
firstRunNewBookTitle?.addEventListener("input", updateFirstRunButtons);
@ -5320,6 +5415,9 @@
ready: "No",
wordApi: "No"
});
startCompanionPresence().catch((error) => {
console.debug("Word Companion presence unavailable.", error);
});
return;
}
@ -5328,6 +5426,7 @@
setOfficeStatus("Word Companion ready");
await projectsLoadPromise;
await initialiseFirstRunBinding();
await startCompanionPresence();
if (!wordHostAvailable) {
setDocumentMessage("Word document access is unavailable.");
setSceneTrackingState("Manual");

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,80 @@
(() => {
const roots = [...document.querySelectorAll("[data-word-companion-presence]")]
.filter((node) => node.dataset.wordCompanionPresence !== "false");
if (roots.length === 0 || !window.signalR) {
return;
}
const labelFor = (status) => (status?.isConnected || status?.IsConnected ? "Connected" : status?.status || status?.Status || "Offline");
const field = (status, camel, pascal) => status?.[camel] ?? status?.[pascal] ?? null;
const formatTime = (value) => {
if (!value) {
return "No heartbeat yet";
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "No heartbeat yet" : `Last heartbeat ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
};
const applyStatus = (status) => {
const connected = !!field(status, "isConnected", "IsConnected");
const version = field(status, "companionVersion", "CompanionVersion") || "Waiting";
const documentName = field(status, "currentDocumentName", "CurrentDocumentName") || "No document reported yet";
const label = connected ? "Connected" : labelFor(status);
const title = [
`Word Companion: ${label}`,
version && version !== "Waiting" ? `Version ${version}` : "",
documentName && documentName !== "No document reported yet" ? documentName : "",
formatTime(field(status, "lastHeartbeatUtc", "LastHeartbeatUtc"))
].filter(Boolean).join("\n");
for (const root of roots) {
root.classList.toggle("is-connected", connected);
root.classList.toggle("is-offline", !connected);
root.dataset.wordCompanionStatus = label;
root.title = title;
}
document.querySelectorAll("[data-word-companion-status-label]").forEach((node) => {
node.textContent = label;
});
document.querySelectorAll("[data-word-companion-waiting-status]").forEach((node) => {
node.textContent = connected ? "Word Companion Connected" : "Word Companion disconnected. Waiting for reconnection...";
});
document.querySelectorAll("[data-word-companion-connected-copy]").forEach((node) => {
node.textContent = connected
? "Your Companion is connected. Continue when you are ready."
: "Open Word and start the Companion. This page will update automatically.";
});
document.querySelectorAll("[data-word-companion-version]").forEach((node) => {
node.textContent = version;
});
document.querySelectorAll("[data-word-companion-document]").forEach((node) => {
node.textContent = documentName;
});
document.querySelectorAll("[data-word-companion-dashboard-title]").forEach((node) => {
node.textContent = connected ? "Word Companion Connected" : "Connect your Word Companion";
});
document.querySelectorAll("[data-word-companion-dashboard-description]").forEach((node) => {
node.textContent = connected
? "Your Word Companion is ready for this story setup."
: "Open Word, start the Companion, and sign in with your PlotDirector account.";
});
};
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/word-companion-follow")
.withAutomaticReconnect()
.build();
connection.on("WordCompanionPresenceChanged", applyStatus);
connection.onreconnecting(() => applyStatus({ status: "Connecting", isConnected: false }));
connection.onreconnected(() => connection.invoke("WatchCompanionPresence").then(applyStatus).catch(() => {}));
connection.onclose(() => applyStatus({ status: "Offline", isConnected: false }));
connection.start()
.then(() => connection.invoke("WatchCompanionPresence"))
.then(applyStatus)
.catch(() => applyStatus({ status: "Offline", isConnected: false }));
})();