diff --git a/PlotLine/Controllers/WordCompanionHostController.cs b/PlotLine/Controllers/WordCompanionHostController.cs
index 7827974..39ec67e 100644
--- a/PlotLine/Controllers/WordCompanionHostController.cs
+++ b/PlotLine/Controllers/WordCompanionHostController.cs
@@ -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"
});
diff --git a/PlotLine/Hubs/WordCompanionFollowHub.cs b/PlotLine/Hubs/WordCompanionFollowHub.cs
index 2b1b662..315db79 100644
--- a/PlotLine/Hubs/WordCompanionFollowHub.cs
+++ b/PlotLine/Hubs/WordCompanionFollowHub.cs
@@ -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 WatchCompanionPresence()
+ {
+ var userId = RequireUserId();
+ await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
+ return await presence.GetStatusAsync(userId);
+ }
+
+ public async Task 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 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}";
}
diff --git a/PlotLine/Models/WordCompanionPresenceModels.cs b/PlotLine/Models/WordCompanionPresenceModels.cs
new file mode 100644
index 0000000..3db10b9
--- /dev/null
+++ b/PlotLine/Models/WordCompanionPresenceModels.cs
@@ -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; }
+}
diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs
index 9cb1cca..91f2d6d 100644
--- a/PlotLine/Program.cs
+++ b/PlotLine/Program.cs
@@ -170,6 +170,8 @@ public class Program
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+ builder.Services.AddSingleton();
+ builder.Services.AddHostedService();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs
index ec6797b..39896e9 100644
--- a/PlotLine/Services/OnboardingService.cs
+++ b/PlotLine/Services/OnboardingService.cs
@@ -26,6 +26,7 @@ public sealed class OnboardingService(
IBookRepository books,
IBookService bookService,
IProjectActivityService activity,
+ IWordCompanionPresenceService companionPresence,
ICurrentUserService currentUser) : IOnboardingService
{
private static readonly HashSet 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 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);
diff --git a/PlotLine/Services/WordCompanionPresenceMonitor.cs b/PlotLine/Services/WordCompanionPresenceMonitor.cs
new file mode 100644
index 0000000..31f2345
--- /dev/null
+++ b/PlotLine/Services/WordCompanionPresenceMonitor.cs
@@ -0,0 +1,32 @@
+using Microsoft.AspNetCore.SignalR;
+using PlotLine.Hubs;
+
+namespace PlotLine.Services;
+
+public sealed class WordCompanionPresenceMonitor(
+ IWordCompanionPresenceService presence,
+ IHubContext 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}";
+}
diff --git a/PlotLine/Services/WordCompanionPresenceService.cs b/PlotLine/Services/WordCompanionPresenceService.cs
new file mode 100644
index 0000000..2115772
--- /dev/null
+++ b/PlotLine/Services/WordCompanionPresenceService.cs
@@ -0,0 +1,173 @@
+using System.Collections.Concurrent;
+using PlotLine.Models;
+
+namespace PlotLine.Services;
+
+public interface IWordCompanionPresenceService
+{
+ Task RegisterAsync(int userId, string connectionId, WordCompanionPresenceRegistration registration);
+ Task HeartbeatAsync(int userId, string connectionId, WordCompanionPresenceHeartbeat heartbeat);
+ Task DisconnectAsync(string connectionId);
+ Task GetStatusAsync(int userId);
+ Task IsConnectedAsync(int userId);
+ Task> MarkStaleOfflineAsync();
+}
+
+public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
+{
+ private static readonly TimeSpan HeartbeatTimeout = TimeSpan.FromSeconds(55);
+ private readonly ConcurrentDictionary recordsByUser = new();
+ private readonly ConcurrentDictionary usersByConnection = new(StringComparer.Ordinal);
+
+ public Task 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 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 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(null);
+ }
+
+ record.Status = WordCompanionPresenceStatuses.Offline;
+ record.ConnectionID = string.Empty;
+ return Task.FromResult(ToStatus(record, DateTime.UtcNow));
+ }
+
+ public Task 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 IsConnectedAsync(int userId)
+ => (await GetStatusAsync(userId)).IsConnected;
+
+ public Task> MarkStaleOfflineAsync()
+ {
+ var now = DateTime.UtcNow;
+ var changed = new List();
+ 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>(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; }
+ }
+}
diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs
index 42d3a80..1bf234d 100644
--- a/PlotLine/ViewModels/OnboardingViewModels.cs
+++ b/PlotLine/ViewModels/OnboardingViewModels.cs
@@ -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 Options { get; set; } = [];
public IReadOnlyList ProjectOptions { get; set; } = [];
public IReadOnlyList 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; }
}
diff --git a/PlotLine/ViewModels/WordCompanionViewModels.cs b/PlotLine/ViewModels/WordCompanionViewModels.cs
index d1f3d25..f9ec328 100644
--- a/PlotLine/ViewModels/WordCompanionViewModels.cs
+++ b/PlotLine/ViewModels/WordCompanionViewModels.cs
@@ -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;
}
diff --git a/PlotLine/Views/Onboarding/Index.cshtml b/PlotLine/Views/Onboarding/Index.cshtml
index 489d694..74c2f2e 100644
--- a/PlotLine/Views/Onboarding/Index.cshtml
+++ b/PlotLine/Views/Onboarding/Index.cshtml
@@ -236,8 +236,8 @@
Ready for the next phase
@if (Model.IsMicrosoftWordPath)
{
-
Next: connect the Word Companion
-
In the next phase, PlotDirector will guide you through opening the Word Companion and linking it to @Model.SelectedBookTitle.
+
Connect your Word Companion
+
Open Microsoft Word, open your manuscript, start the PlotDirector Word Companion, and sign in using your PlotDirector account.
}
else
{
@@ -245,6 +245,36 @@
In a later phase, PlotDirector will let you upload a .docx export from your writing tool and connect it to @Model.SelectedBookTitle.
}
+ @if (Model.IsMicrosoftWordPath)
+ {
+
+
+
+
+
+
Word Companion
+
@(Model.CompanionPresence.IsConnected ? "Word Companion Connected" : "Waiting for Word Companion...")
+
+ @(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.")
+