Agent Host changes for agents/improve-changelog-promotion-ui (#152)

This commit is contained in:
Oliver Kieselbach
2026-09-08 14:57:11 +02:00
committed by GitHub
parent 4df52b3da9
commit 4e73ba8900
39 changed files with 2277 additions and 47 deletions
+24 -2
View File
@@ -40,6 +40,23 @@ jobs:
lfs: false
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
# The customer docs repo is the single source of the "What's new" panel: the prebuild
# step (scripts/whats-new/build-whats-new.js) turns changelog/*.md into
# public/whats-new.json and dates every bullet via git blame — hence the full history.
# Public repo, no token. Editing the docs does NOT update the live panel until this
# workflow runs again (push under src/Web/** or a manual dispatch); versions/web.json
# records which docs commit is live.
- name: Check out documentation bundle
uses: actions/checkout@v7
with:
repository: autopilotmonitor/autopilotmonitor-docs
path: whats-new-docs
fetch-depth: 0
- name: Resolve docs commit
id: docs
run: echo "sha=$(git -C whats-new-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- uses: actions/setup-node@v7
with:
node-version: '22'
@@ -51,7 +68,7 @@ jobs:
run: npm ci
# next.config output:'export' -- `next build` writes the static site to out/
# (prebuild: sync-error-codes, generate-lastmod, generate-version).
# (prebuild: sync-error-codes, generate-lastmod, generate-version, whats-new).
- name: Build
working-directory: ${{ env.WEB_DIR }}
env:
@@ -68,9 +85,12 @@ jobs:
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
# the verify step below polls the deployed file for exactly this commit.
GITHUB_SHA: ${{ github.sha }}
# Docs checkout for public/whats-new.json; the prebuild fails in CI without it.
WHATS_NEW_DOCS_DIR: ${{ github.workspace }}/whats-new-docs
run: |
npm run build
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
test -f out/whats-new.json || { echo "out/whats-new.json missing - whats-new prebuild did not run"; exit 1; }
# One archive of the exact bytes that get uploaded, so the attestation has a single subject.
- name: Package export
@@ -136,7 +156,8 @@ jobs:
throw "Portal did not serve commit $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
# Cross-component deploy record, written last and only on a verified deploy --
# same contract as versions/backend.json and versions/mcp.json.
# same contract as versions/backend.json and versions/mcp.json. docsCommit says which
# docs revision the live What's new panel was generated from (as for the MCP corpus).
- name: Publish version manifest
shell: pwsh
run: |
@@ -145,6 +166,7 @@ jobs:
commit = '${{ github.sha }}'.Substring(0, 7)
deployedUtc = [DateTime]::UtcNow.ToString('o')
runId = '${{ github.run_id }}'
docsCommit = '${{ steps.docs.outputs.sha }}'.Substring(0, 7)
} | ConvertTo-Json
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
+4
View File
@@ -145,6 +145,10 @@ internal/docs/~$Introduction-AutopilotMonitor.pptx
# changes each run) and served at /version.json from the static export.
src/Web/autopilot-monitor-web/public/version.json
# What's new payload: generated by scripts/whats-new/build-whats-new.js from the docs
# checkout on every build and served at /whats-new.json from the static export.
src/Web/autopilot-monitor-web/public/whats-new.json
# Local IME installer scratch (MSI + extracted payload per version, for grepping
# in binaries). Operator-only, never committed.
@@ -55,6 +55,76 @@ public class AuthFunctionTests
Assert.True((bool)body.hasMcpAccess);
}
[Fact]
public void HappyPath_SurfacesWhatsNewSeenMarks()
{
var platformSeen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
var agentSeen = new DateTime(2026, 9, 8, 10, 5, 0, DateTimeKind.Utc);
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: AdminRole(), mcpCheck: McpAllowed(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId,
whatsNewSeenPlatformUtc: platformSeen,
whatsNewSeenAgentUtc: agentSeen);
Assert.True(result.IsSuccess);
var body = ToDynamic(result.Body);
Assert.Equal(platformSeen, (DateTime)body.whatsNewSeenPlatformUtc);
Assert.Equal(agentSeen, (DateTime)body.whatsNewSeenAgentUtc);
}
[Theory]
[InlineData("platform", "platform")]
[InlineData("Platform", "platform")]
[InlineData("agent", "agent")]
public void TryNormalizeWhatsNewSeen_AcceptsKnownChannels(string input, string expected)
{
var seen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
var ok = AuthFunction.TryNormalizeWhatsNewSeen(
new WhatsNewSeenRequest { Channel = input, SeenUtc = seen },
seen.AddMinutes(1),
out var channel,
out var seenUtc,
out var error);
Assert.True(ok, error);
Assert.Equal(expected, channel);
Assert.Equal(seen, seenUtc);
}
[Fact]
public void TryNormalizeWhatsNewSeen_RejectsUnknownChannel()
{
var ok = AuthFunction.TryNormalizeWhatsNewSeen(
new WhatsNewSeenRequest { Channel = "other", SeenUtc = DateTime.UtcNow },
DateTime.UtcNow,
out _,
out _,
out var error);
Assert.False(ok);
Assert.Equal("channel must be 'platform' or 'agent'", error);
}
[Fact]
public void TryNormalizeWhatsNewSeen_ClampsFarFutureTimestamp()
{
var now = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
var ok = AuthFunction.TryNormalizeWhatsNewSeen(
new WhatsNewSeenRequest { Channel = "agent", SeenUtc = now.AddMinutes(6) },
now,
out _,
out var seenUtc,
out var error);
Assert.True(ok, error);
Assert.Equal(now, seenUtc);
}
[Fact]
public void HappyPath_OperatorUser_ReturnsCorrectRole()
{
@@ -28,6 +28,8 @@ public class InfrastructureWireParityTests
var displayName = "Admin User";
var objectId = "0b6f7a37-1111-4d61-9c93-0aa111111111";
var managedTenantIds = new[] { "7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e" };
var platformSeen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
var agentSeen = new DateTime(2026, 9, 8, 10, 5, 0, DateTimeKind.Utc);
AssertParity(
new
@@ -46,7 +48,9 @@ public class InfrastructureWireParityTests
hasMcpAccess = true,
homedApp = "primary",
bootstrapTokenEnabled = true,
unrestrictedModeEnabled = false
unrestrictedModeEnabled = false,
whatsNewSeenPlatformUtc = platformSeen,
whatsNewSeenAgentUtc = agentSeen
},
new AuthMeResponse
{
@@ -64,7 +68,9 @@ public class InfrastructureWireParityTests
HasMcpAccess = true,
HomedApp = "primary",
BootstrapTokenEnabled = true,
UnrestrictedModeEnabled = false
UnrestrictedModeEnabled = false,
WhatsNewSeenPlatformUtc = platformSeen,
WhatsNewSeenAgentUtc = agentSeen
});
}
@@ -78,6 +84,8 @@ public class InfrastructureWireParityTests
var displayName = "Reader User";
var objectId = "0b6f7a37-2222-4d61-9c93-0aa222222222";
string? role = null;
DateTime? whatsNewSeenPlatformUtc = null;
DateTime? whatsNewSeenAgentUtc = null;
AssertParity(
new
@@ -96,7 +104,9 @@ public class InfrastructureWireParityTests
hasMcpAccess = true,
homedApp = "legacy",
bootstrapTokenEnabled = false,
unrestrictedModeEnabled = false
unrestrictedModeEnabled = false,
whatsNewSeenPlatformUtc,
whatsNewSeenAgentUtc
},
new AuthMeResponse
{
@@ -114,7 +124,9 @@ public class InfrastructureWireParityTests
HasMcpAccess = true,
HomedApp = "legacy",
BootstrapTokenEnabled = false,
UnrestrictedModeEnabled = false
UnrestrictedModeEnabled = false,
WhatsNewSeenPlatformUtc = null,
WhatsNewSeenAgentUtc = null
});
}
@@ -0,0 +1,126 @@
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
public class UserWhatsNewSeenStorageTests
{
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
private const string Upn = "user@contoso.com";
[Fact]
public async Task GetUserWhatsNewSeen_MissingRow_ReturnsNullMarks()
{
var h = new Harness();
h.Table.Setup(t => t.GetEntityAsync<TableEntity>(
TenantId,
TableStorageService.PresenceRowKey(Upn),
It.Is<IEnumerable<string>>(s => s.SequenceEqual(new[] { "WhatsNewSeenPlatformUtc", "WhatsNewSeenAgentUtc" })),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(404, "Not found"));
var seen = await h.Sut.GetUserWhatsNewSeenAsync(TenantId, Upn);
Assert.Null(seen.PlatformUtc);
Assert.Null(seen.AgentUtc);
}
[Fact]
public async Task MarkUserWhatsNewSeen_OlderThanExisting_DoesNotWrite()
{
var existing = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
var h = new Harness();
h.SetupRead(new TableEntity(TenantId, TableStorageService.PresenceRowKey(Upn))
{
["WhatsNewSeenPlatformUtc"] = existing
});
await h.Sut.MarkUserWhatsNewSeenAsync(TenantId, Upn, "platform", existing.AddMinutes(-1));
Assert.Empty(h.Upserts);
}
[Fact]
public async Task MarkUserWhatsNewSeen_NewerValue_MergeUpsertsOnlySelectedChannelAndUpn()
{
var existing = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
var newer = existing.AddMinutes(1);
var h = new Harness();
h.SetupRead(new TableEntity(TenantId, TableStorageService.PresenceRowKey(Upn))
{
["WhatsNewSeenAgentUtc"] = existing
});
await h.Sut.MarkUserWhatsNewSeenAsync(TenantId, Upn, "agent", newer);
var (entity, mode) = Assert.Single(h.Upserts);
Assert.Equal(TableUpdateMode.Merge, mode);
Assert.Equal(Upn, entity.GetString("Upn"));
Assert.Equal(newer, entity.GetDateTime("WhatsNewSeenAgentUtc"));
Assert.False(entity.ContainsKey("LastSeen"));
Assert.False(entity.ContainsKey("UserRole"));
Assert.False(entity.ContainsKey("WhatsNewSeenPlatformUtc"));
}
[Fact]
public async Task MarkUserWhatsNewSeen_MissingRow_MergeCreatesRowWithUpn()
{
var seen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
var h = new Harness();
h.Table.Setup(t => t.GetEntityAsync<TableEntity>(
TenantId,
TableStorageService.PresenceRowKey(Upn),
It.IsAny<IEnumerable<string>>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(404, "Not found"));
await h.Sut.MarkUserWhatsNewSeenAsync(TenantId, Upn, "platform", seen);
var (entity, mode) = Assert.Single(h.Upserts);
Assert.Equal(TableUpdateMode.Merge, mode);
Assert.Equal(TenantId, entity.PartitionKey);
Assert.Equal(TableStorageService.PresenceRowKey(Upn), entity.RowKey);
Assert.Equal(Upn, entity.GetString("Upn"));
Assert.Equal(seen, entity.GetDateTime("WhatsNewSeenPlatformUtc"));
Assert.False(entity.ContainsKey("LastSeen"));
Assert.False(entity.ContainsKey("UserRole"));
}
private sealed class Harness
{
public Mock<TableClient> Table { get; } = new();
public TableStorageService Sut { get; }
public List<(TableEntity Entity, TableUpdateMode Mode)> Upserts { get; } = new();
public Harness()
{
Table.Setup(t => t.UpsertEntityAsync(
It.IsAny<TableEntity>(),
It.IsAny<TableUpdateMode>(),
It.IsAny<CancellationToken>()))
.Returns<TableEntity, TableUpdateMode, CancellationToken>((e, mode, _) =>
{
Upserts.Add((e, mode));
return Task.FromResult(Mock.Of<Response>());
});
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(c => c.GetTableClient(Constants.TableNames.UserPresence)).Returns(Table.Object);
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
}
public void SetupRead(TableEntity entity)
{
Table.Setup(t => t.GetEntityAsync<TableEntity>(
entity.PartitionKey,
entity.RowKey,
It.IsAny<IEnumerable<string>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(entity, Mock.Of<Response>()));
}
}
}
@@ -92,6 +92,12 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task RecordUserPresenceAsync(string tenantId, string upn, string userRole)
=> _storage.RecordUserPresenceAsync(tenantId, upn, userRole);
public Task<UserWhatsNewSeen> GetUserWhatsNewSeenAsync(string tenantId, string upn)
=> _storage.GetUserWhatsNewSeenAsync(tenantId, upn);
public Task MarkUserWhatsNewSeenAsync(string tenantId, string upn, string channel, DateTime seenUtc)
=> _storage.MarkUserWhatsNewSeenAsync(tenantId, upn, channel, seenUtc);
public Task<List<UserPresenceEntry>> GetActivePresenceAsync(TimeSpan window)
=> _storage.GetActivePresenceAsync(window);
@@ -100,7 +100,8 @@ public class AuthFunction
// --- Parallel data fetch: all independent queries run concurrently ---
// Fail-fast: if any fetch throws, AggregateException propagates → Azure Functions returns 500.
// This is intentional — all 6 queries are required for the auth decision.
// This is intentional — the auth-decision queries are required; the What's-new marker read
// is fail-soft inside the repository because it is only a UX convenience.
// Non-creating read: do NOT auto-persist a default config row just because someone authenticated.
// A delegated-only external MSP login must not phantom-onboard its home tenant (see the side-effect
// gate below). A genuine first-user still gets their config created by HandleNewTenantDomainAsync.
@@ -116,9 +117,10 @@ public class AuthFunction
// middleware does (the table-vs-claim reconciliation happens inside the shared role resolver).
var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn, tenantId, objectId, principal.GetAppRoles());
var existingAdminsTask = _tenantAdminsService.GetTenantAdminsAsync(tenantId);
var whatsNewSeenTask = _metricsRepo.GetUserWhatsNewSeenAsync(tenantId, upn);
await Task.WhenAll(tenantConfigTask, globalRoleTask, delegatedScopeTask, isApprovedTask,
membershipTask, mcpCheckTask, existingAdminsTask);
membershipTask, mcpCheckTask, existingAdminsTask, whatsNewSeenTask);
var (tenantConfig, _) = tenantConfigTask.Result;
var globalRole = globalRoleTask.Result;
@@ -131,6 +133,7 @@ public class AuthFunction
var (tableState, tableRole) = membershipTask.Result;
var mcpCheck = mcpCheckTask.Result;
var existingAdmins = existingAdminsTask.Result;
var whatsNewSeen = whatsNewSeenTask.Result;
// Reconcile the TenantAdmins table state with any Entra app-role claim. An enabled row
// wins; a disabled row is an explicit deny (claim ignored); only a missing row falls back
@@ -164,7 +167,9 @@ public class AuthFunction
memberRole, mcpCheck, existingAdmins.Count > 0,
tenantId, upn, displayName ?? string.Empty, objectId ?? string.Empty,
delegatedTenantIds,
homedApp: _appRegistry.ResolveForTenant(tenantConfig).IsLegacy ? "legacy" : "primary");
homedApp: _appRegistry.ResolveForTenant(tenantConfig).IsLegacy ? "legacy" : "primary",
whatsNewSeenPlatformUtc: whatsNewSeen.PlatformUtc,
whatsNewSeenAgentUtc: whatsNewSeen.AgentUtc);
if (!decision.IsSuccess)
{
@@ -193,6 +198,35 @@ public class AuthFunction
return response;
}
/// <summary>
/// PUT /api/auth/me/whats-new-seen
/// Marks the caller's What's-new channel as seen.
/// </summary>
[Function("MarkWhatsNewSeen")]
[Authorize]
public async Task<HttpResponseData> MarkWhatsNewSeen(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "auth/me/whats-new-seen")] HttpRequestData req,
FunctionContext context)
{
var principal = context.GetUser();
if (principal == null)
return req.CreateResponse(HttpStatusCode.Unauthorized);
var tenantId = principal.GetTenantId();
var upn = principal.GetUserPrincipalName();
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return await req.BadRequestAsync("Missing required claims");
var read = await req.ReadAsync<WhatsNewSeenRequest>();
if (read.Error != null) return read.Error;
if (!TryNormalizeWhatsNewSeen(read.Value, DateTime.UtcNow, out var channel, out var seenUtc, out var error))
return await req.BadRequestAsync(error);
await _metricsRepo.MarkUserWhatsNewSeenAsync(tenantId, upn, channel, seenUtc);
return req.CreateResponse(HttpStatusCode.NoContent);
}
/// <summary>
/// GET /api/auth/is-global-admin
/// Checks if the current user is a Global Admin
@@ -515,7 +549,9 @@ public class AuthFunction
bool hasTenantAdmins,
string tenantId, string upn, string displayName, string objectId,
IReadOnlyCollection<string>? delegatedTenantIds = null,
string homedApp = "primary")
string homedApp = "primary",
DateTime? whatsNewSeenPlatformUtc = null,
DateTime? whatsNewSeenAgentUtc = null)
{
// A delegated ("MSP") admin manages a subset of OTHER tenants. They are explicitly authorized, so —
// like a Global Admin / Reader — they bypass the private-preview gate even when their own home tenant
@@ -603,10 +639,61 @@ public class AuthFunction
BootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow),
UnrestrictedModeEnabled =
FeatureEntitlementCatalog.Get(TenantEntitlementService.Resolve(tenantConfig, DateTime.UtcNow)).UnrestrictedModeAvailable
&& tenantConfig.UnrestrictedModeEnabled
&& tenantConfig.UnrestrictedModeEnabled,
WhatsNewSeenPlatformUtc = whatsNewSeenPlatformUtc,
WhatsNewSeenAgentUtc = whatsNewSeenAgentUtc
}, needsAutoAdmin);
}
internal static bool TryNormalizeWhatsNewSeen(
WhatsNewSeenRequest? body,
DateTime nowUtc,
out string channel,
out DateTime seenUtc,
out string error)
{
channel = string.Empty;
seenUtc = default;
error = string.Empty;
if (body == null)
{
error = "Request body is required";
return false;
}
channel = (body.Channel ?? string.Empty).Trim().ToLowerInvariant();
if (channel is not ("platform" or "agent"))
{
error = "channel must be 'platform' or 'agent'";
return false;
}
if (body.SeenUtc == default)
{
error = "seenUtc is required";
return false;
}
nowUtc = NormalizeUtc(nowUtc);
seenUtc = NormalizeUtc(body.SeenUtc);
if (seenUtc > nowUtc.AddMinutes(5))
{
// Client clocks can run ahead; clamp rather than reject so the marker remains harmless.
seenUtc = nowUtc;
}
return true;
}
private static DateTime NormalizeUtc(DateTime value)
=> value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
/// <summary>
/// Extracts domain name from UPN (e.g., user@contoso.com -> contoso.com)
/// </summary>
@@ -209,6 +209,7 @@ public static class EndpointAccessPolicyCatalog
// ── AuthenticatedUser ───────────────────────────────────────────
new("GET", "auth/me", EndpointPolicy.AuthenticatedUser),
new("PUT", "auth/me/whats-new-seen", EndpointPolicy.AuthenticatedUser),
new("GET", "auth/is-global-admin", EndpointPolicy.AuthenticatedUser),
new("POST", "realtime/negotiate", EndpointPolicy.AuthenticatedUser),
// Group join/leave is AuthenticatedUserWithRole — NOT MemberRead — on purpose. The real,
@@ -1091,7 +1091,7 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// Upserts a single presence row (PK=tenantId, RK=hash(UPN)) stamped with LastSeen=now.
/// One row per user — overwritten on every call, so the table never grows past the distinct-user count.
/// One row per user, so the table never grows past the distinct-user count.
/// </summary>
public async Task RecordUserPresenceAsync(string tenantId, string upn, string userRole)
{
@@ -1105,7 +1105,11 @@ namespace AutopilotMonitor.Functions.Services
["LastSeen"] = DateTime.UtcNow
};
await tableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
// Merge preserves the What's-new seen marks carried on the same row
// (WhatsNewSeenPlatformUtc / WhatsNewSeenAgentUtc); a Replace upsert every
// ≥60s would wipe them. Upn/UserRole/LastSeen are always set here, so Merge
// is semantically identical for presence.
await tableClient.UpsertEntityAsync(entity, TableUpdateMode.Merge);
}
catch (Exception ex)
{
@@ -1114,6 +1118,89 @@ namespace AutopilotMonitor.Functions.Services
}
}
public async Task<UserWhatsNewSeen> GetUserWhatsNewSeenAsync(string tenantId, string upn)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserPresence);
var entity = (await tableClient.GetEntityAsync<TableEntity>(
tenantId,
PresenceRowKey(upn),
select: new[] { WhatsNewSeenPlatformColumn, WhatsNewSeenAgentColumn })).Value;
return new UserWhatsNewSeen(
entity.GetDateTime(WhatsNewSeenPlatformColumn),
entity.GetDateTime(WhatsNewSeenAgentColumn));
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return new UserWhatsNewSeen();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read What's-new seen marks for {Upn} in tenant {TenantId}", upn, tenantId);
return new UserWhatsNewSeen();
}
}
public async Task MarkUserWhatsNewSeenAsync(string tenantId, string upn, string channel, DateTime seenUtc)
{
var column = channel.ToLowerInvariant() switch
{
"platform" => WhatsNewSeenPlatformColumn,
"agent" => WhatsNewSeenAgentColumn,
_ => throw new ArgumentException("Unknown What's-new channel.", nameof(channel))
};
seenUtc = NormalizeTableUtc(seenUtc);
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserPresence);
var rowKey = PresenceRowKey(upn);
DateTime? existing = null;
try
{
var entity = (await tableClient.GetEntityAsync<TableEntity>(
tenantId,
rowKey,
select: new[] { column })).Value;
existing = entity.GetDateTime(column);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// Missing presence is fine: this endpoint may be the first thing that writes the row.
}
if (existing.HasValue && NormalizeTableUtc(existing.Value) >= seenUtc)
return;
var patch = new TableEntity(tenantId, rowKey)
{
["Upn"] = upn ?? string.Empty,
[column] = seenUtc
};
await tableClient.UpsertEntityAsync(patch, TableUpdateMode.Merge);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to mark What's-new {Channel} as seen for {Upn} in tenant {TenantId}", channel, upn, tenantId);
}
}
private const string WhatsNewSeenPlatformColumn = "WhatsNewSeenPlatformUtc";
private const string WhatsNewSeenAgentColumn = "WhatsNewSeenAgentUtc";
private static DateTime NormalizeTableUtc(DateTime value)
=> value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
/// <summary>
/// Returns all users whose LastSeen is within the given window (cross-tenant), newest first.
/// Intentionally NOT wrapped in a swallow-and-return-empty try/catch: a storage failure must
@@ -7,7 +7,8 @@
* mcp live in-process build info (this process IS the live deployment)
* mcp manifest versions/mcp.json → … + docsCommit
* web live GET {PORTAL}/version.json → { component, commit, buildUtc }
* web manifest versions/web.json → { component, commit, deployedUtc, runId }
* web manifest versions/web.json → { component, commit, deployedUtc, runId, docsCommit }
* (docsCommit = the docs revision the live What's new panel was generated from)
* agent manifest {AGENT}/version.json → { version, commit, buildUtc, sha256, bootstrapVersion }
* (no live endpoint — the agent runs on devices)
*
@@ -758,6 +758,10 @@ export interface AuthMeResponse {
homedApp: string;
bootstrapTokenEnabled: boolean;
unrestrictedModeEnabled: boolean;
/** When this user last viewed the Platform tab of What's new; null = never and the key is omitted (client treats as first visit). */
whatsNewSeenPlatformUtc?: string;
/** Same for the Agent tab; null = never and the key is omitted. */
whatsNewSeenAgentUtc?: string;
}
export interface AutoResolveCpeMappingItem {
@@ -5248,3 +5252,11 @@ export interface VerdictCalibrationTrendWindow {
sessions: number;
sharePct: number;
}
/** Body of PUT auth/me/whats-new-seen. */
export interface WhatsNewSeenRequest {
/** What's-new channel: "platform" or "agent". */
channel: string;
/** UTC timestamp when the caller viewed the channel. */
seenUtc: string;
}
@@ -86,6 +86,10 @@ namespace AutopilotMonitor.Shared.DataAccess
// --- Live Presence (one row per user, upserted) ---
/// <summary>Upserts the caller's presence row (PK=tenantId, RK=SHA-256(lowercase UPN) hex) with LastSeen=now.</summary>
Task RecordUserPresenceAsync(string tenantId, string upn, string userRole);
/// <summary>Gets the caller's per-channel What's-new seen marks from the presence row; nulls mean never or unavailable.</summary>
Task<UserWhatsNewSeen> GetUserWhatsNewSeenAsync(string tenantId, string upn);
/// <summary>Monotonically merge-upserts the caller's What's-new seen mark for one channel without touching presence fields.</summary>
Task MarkUserWhatsNewSeenAsync(string tenantId, string upn, string channel, DateTime seenUtc);
/// <summary>Returns all users whose LastSeen falls within the given window, newest first (cross-tenant).</summary>
Task<List<UserPresenceEntry>> GetActivePresenceAsync(TimeSpan window);
/// <summary>Retention cleanup: deletes presence rows whose LastSeen is older than the cutoff (drops one-off testers). Returns the number deleted.</summary>
@@ -37,6 +37,20 @@ namespace AutopilotMonitor.Shared.Models
public bool BootstrapTokenEnabled { get; set; }
public bool UnrestrictedModeEnabled { get; set; }
/// <summary>When this user last viewed the Platform tab of What's new; null = never and the key is omitted (client treats as first visit).</summary>
public DateTime? WhatsNewSeenPlatformUtc { get; set; }
/// <summary>Same for the Agent tab; null = never and the key is omitted.</summary>
public DateTime? WhatsNewSeenAgentUtc { get; set; }
}
/// <summary>Body of PUT auth/me/whats-new-seen.</summary>
public class WhatsNewSeenRequest : IApiRequest
{
/// <summary>What's-new channel: "platform" or "agent".</summary>
public string Channel { get; set; } = default!;
/// <summary>UTC timestamp when the caller viewed the channel.</summary>
public DateTime SeenUtc { get; set; }
}
/// <summary>
@@ -0,0 +1,17 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>The caller's per-channel What's-new seen marks from the UserPresence row.</summary>
public sealed class UserWhatsNewSeen
{
public UserWhatsNewSeen(DateTime? platformUtc = null, DateTime? agentUtc = null)
{
PlatformUtc = platformUtc;
AgentUtc = agentUtc;
}
public DateTime? PlatformUtc { get; }
public DateTime? AgentUtc { get; }
}
}
@@ -3,6 +3,7 @@
import { useSyncExternalStore } from "react";
import { DOCS_URL } from "@/utils/config";
import { trackEvent } from "@/lib/appInsights";
import { useWhatsNew } from "@/hooks/useWhatsNew";
// Session-scoped dismissal: sessionStorage is per-tab, so the banner stays hidden
// across reloads in this tab (service-desk monitor use case) but reappears in every
@@ -30,6 +31,7 @@ const linkClassName =
export function ActivelyDevelopedBanner() {
const dismissed = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const whatsNew = useWhatsNew();
if (dismissed) return null;
const trackLink = (link: string) => trackEvent("dev_banner_link_clicked", { link });
@@ -43,16 +45,14 @@ export function ActivelyDevelopedBanner() {
<span className="font-semibold">Actively developed.</span>{" "}
Autopilot Monitor recognizes a wide range of deployment scenarios and improves
continuously — your reports directly shape it.{" "}
If something looks off, check the{" "}
<a
href={`${DOCS_URL}/changelog/platform-changelog`}
target="_blank"
rel="noopener noreferrer"
If something looks off, check{" "}
<button
type="button"
className={linkClassName}
onClick={() => trackLink("platform_changelog")}
onClick={() => { trackLink("whats_new"); whatsNew.open("banner", "platform"); }}
>
Platform Changelog
</a>{" "}
What&apos;s new
</button>{" "}
or{" "}
<a
href={`${DOCS_URL}/troubleshooting/service-announcements`}
@@ -896,6 +896,26 @@ html:hover ::-webkit-scrollbar-thumb {
animation: lpFadeUp 0.4s ease-out both;
}
/* What's new flyout: slides in from the right edge; unmounted on close, so no exit animation. */
@keyframes whatsNewSlideIn {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: none;
}
}
.whats-new-card {
animation: whatsNewSlideIn 0.22s ease-out both;
}
@media (prefers-reduced-motion: reduce) {
.whats-new-card {
animation: none;
}
}
@keyframes lpBlink {
0%,
49% {
@@ -1,4 +1,5 @@
import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { WhatsNewTile } from "../../components/landing/WhatsNewTile";
import { SiteFooter } from "../../components/SiteFooter";
import { DOCS_URL } from "@/utils/config";
@@ -6,6 +7,9 @@ const GITHUB_ISSUES = "https://github.com/okieselbach/AutopilotMonitor/issues";
const LINKEDIN_PROFILE = "https://www.linkedin.com/in/oliver-kieselbach/";
const GITHUB_ADVISORY = "https://github.com/okieselbach/AutopilotMonitor/security/advisories/new";
const TILE_CLASS =
"rounded-xl border border-[var(--lp-line-soft)] bg-[var(--lp-surface)] px-5 py-4 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors";
const SELF_SERVICE_LINKS = [
{
label: "Documentation",
@@ -22,11 +26,6 @@ const SELF_SERVICE_LINKS = [
description: "Known issues and current service status.",
href: `${DOCS_URL}/troubleshooting/service-announcements`,
},
{
label: "Platform Changelog",
description: "What changed recently in the portal and backend.",
href: `${DOCS_URL}/changelog/platform-changelog`,
},
];
export default function HelpPage() {
@@ -142,12 +141,13 @@ export default function HelpPage() {
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-[var(--lp-line-soft)] bg-[var(--lp-surface)] px-5 py-4 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors"
className={TILE_CLASS}
>
<span className="block text-sm font-semibold text-gray-800 mb-1">{link.label} →</span>
<span className="block text-sm text-gray-600 leading-relaxed">{link.description}</span>
</a>
))}
<WhatsNewTile className={TILE_CLASS} />
</div>
</section>
@@ -11,6 +11,7 @@ import { ThemeProvider } from "../contexts/ThemeContext";
import Navbar from "../components/Navbar";
import ScrollToTopButton from "../components/ScrollToTopButton";
import FeedbackBubble from "../components/FeedbackBubble";
import { WhatsNewPanelHost } from "../components/WhatsNewPanelHost";
import { SidebarProvider } from "../contexts/SidebarContext";
import { GlobalSidebar } from "../components/GlobalSidebar";
import AppInsightsInit from "../components/AppInsightsInit";
@@ -198,6 +199,7 @@ export default function RootLayout({
</GlobalSidebar>
<ScrollToTopButton />
<FeedbackBubble />
<WhatsNewPanelHost />
</SidebarProvider>
</TenantProvider>
</TenantNotificationProvider>
@@ -16,6 +16,8 @@ import { useEditionInfo } from '@/hooks/useEditionInfo';
import GlobalSearch from './GlobalSearch';
import { DOCS_URL } from "@/utils/config";
import { DOCS_PATHS } from "@/lib/docsPaths";
import { useWhatsNew } from "@/hooks/useWhatsNew";
import { WhatsNewCountBadge, WhatsNewIconBadge } from "./WhatsNewPanel";
export default function Navbar() {
const { isAuthenticated, user, hasGlobalScope, logout } = useAuth();
@@ -37,6 +39,7 @@ export default function Navbar() {
// null until the feature-flags fetch confirms → no label flash for Pro tenants.
const editionInfo = useEditionInfo();
const showCommunityTag = editionInfo?.edition === 'community';
const whatsNew = useWhatsNew();
const notificationRef = useRef<HTMLDivElement>(null);
const userMenuRef = useRef<HTMLDivElement>(null);
@@ -485,10 +488,11 @@ export default function Navbar() {
{/* Help Menu (?) — hidden on <sm, moved to overflow */}
<div className="hidden sm:block relative" ref={helpRef}>
<button onClick={() => setShowHelp(!showHelp)} className="p-2 rounded-lg hover:bg-gray-100 transition-colors" title="Help & Info">
<button onClick={() => setShowHelp(!showHelp)} className="relative p-2 rounded-lg hover:bg-gray-100 transition-colors" title="Help & Info">
<svg className="w-5 h-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<WhatsNewIconBadge count={whatsNew.totalUnseen} />
</button>
{showHelp && (
@@ -506,18 +510,17 @@ export default function Navbar() {
<span>Documentation</span>
</a>
<a
href={`${DOCS_URL}${DOCS_PATHS.platformChangelog}`}
target="_blank"
rel="noopener noreferrer"
<button
type="button"
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
onClick={() => setShowHelp(false)}
onClick={() => { setShowHelp(false); whatsNew.open("help"); }}
>
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span>Changelog</span>
</a>
<span>What&apos;s new</span>
<WhatsNewCountBadge count={whatsNew.totalUnseen} className="ml-auto" />
</button>
<a
href={`${DOCS_URL}${DOCS_PATHS.serviceAnnouncements}`}
@@ -578,12 +581,13 @@ export default function Navbar() {
<div className="sm:hidden relative" ref={overflowRef}>
<button
onClick={() => { setShowOverflow(!showOverflow); setOverflowSubmenu(null); }}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
className="relative p-2 rounded-lg hover:bg-gray-100 transition-colors"
title="More"
>
<svg className="w-5 h-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0z" />
</svg>
<WhatsNewIconBadge count={whatsNew.totalUnseen} />
</button>
{showOverflow && (
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50 py-1">
@@ -641,6 +645,7 @@ export default function Navbar() {
<path strokeLinecap="round" strokeLinejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Help</span>
<WhatsNewCountBadge count={whatsNew.totalUnseen} />
</div>
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
@@ -722,10 +727,11 @@ export default function Navbar() {
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /></svg>
<span>Documentation</span>
</a>
<a href={`${DOCS_URL}${DOCS_PATHS.platformChangelog}`} target="_blank" rel="noopener noreferrer" className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); }}>
<button type="button" className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); whatsNew.open("overflow"); }}>
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>
<span>Changelog</span>
</a>
<span>What&apos;s new</span>
<WhatsNewCountBadge count={whatsNew.totalUnseen} className="ml-auto" />
</button>
<a href={`${DOCS_URL}${DOCS_PATHS.serviceAnnouncements}`} target="_blank" rel="noopener noreferrer" className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); }}>
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /></svg>
<span>Service Announcements</span>
@@ -0,0 +1,271 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ModalPortal } from "./ModalPortal";
import { useWhatsNew } from "@/hooks/useWhatsNew";
import { closeWhatsNew } from "@/lib/whatsNewStore";
import { formatInlineMarkdown } from "@/lib/formatInlineMarkdown";
import { trackEvent } from "@/lib/appInsights";
import {
formatBadgeCount,
unseenEntries,
WHATS_NEW_CHANNELS,
type WhatsNewChannel,
type WhatsNewEntry,
type WhatsNewSeen,
} from "@/lib/whatsNew";
const CHANNEL_LABEL: Record<WhatsNewChannel, string> = { platform: "Platform", agent: "Agent" };
/**
* The red unread counter used on the navbar icons, the menu rows and the panel tabs —
* same size and cap as the notification bell's badge.
*/
export function WhatsNewCountBadge({ count, className = "" }: { count: number; className?: string }) {
if (count <= 0) return null;
return (
<span
className={`inline-flex items-center justify-center min-w-4 h-4 px-1 text-[10px] font-bold leading-none text-white bg-red-600 rounded-full ${className}`}
aria-label={`${count} new`}
>
{formatBadgeCount(count)}
</span>
);
}
/** The badge pinned to the top-right corner of an icon button. */
export function WhatsNewIconBadge({ count }: { count: number }) {
if (count <= 0) return null;
return <WhatsNewCountBadge count={count} className="absolute top-0.5 right-0.5" />;
}
function BellIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
);
}
function ExternalIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
);
}
const dateFormatter = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" });
const MARKDOWN_OPTIONS = { links: true, emphasis: true, linkClassName: "text-[var(--lp-accent-ink)] hover:underline" } as const;
function EntryRow({ entry, isNew }: { entry: WhatsNewEntry; isNew: boolean }) {
const onLink = () => trackEvent("whats_new_link_clicked", { entryId: entry.id });
return (
<li className="py-4 border-b border-[var(--lp-line-soft)] last:border-b-0">
<div className="flex items-center gap-2 text-xs text-[var(--lp-ink-faint)]">
<time dateTime={entry.addedUtc}>{dateFormatter.format(new Date(entry.addedUtc))}</time>
{isNew && (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--lp-accent-soft)] px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--lp-accent-ink)]">
New
</span>
)}
</div>
{entry.title ? (
<>
<h3 className="mt-1 text-[15px] font-semibold leading-snug text-[var(--lp-ink)]">{formatInlineMarkdown(entry.title, { emphasis: true })}</h3>
<p className="mt-1.5 text-sm leading-relaxed text-[var(--lp-ink-soft)]">
{formatInlineMarkdown(entry.body, MARKDOWN_OPTIONS)}
</p>
</>
) : (
<p className="mt-1 text-[15px] leading-relaxed text-[var(--lp-ink)]">
{formatInlineMarkdown(entry.body, MARKDOWN_OPTIONS)}
</p>
)}
{entry.link && (
<a
href={entry.link}
target="_blank"
rel="noopener noreferrer"
onClick={onLink}
className="mt-2 inline-flex items-center gap-1 text-sm font-semibold text-[var(--lp-accent-ink)] hover:underline"
>
Read update
<ExternalIcon className="w-3.5 h-3.5" />
</a>
)}
</li>
);
}
function SkeletonRows() {
return (
<ul className="animate-pulse" aria-hidden="true">
{[0, 1, 2].map(i => (
<li key={i} className="py-4 border-b border-[var(--lp-line-soft)] last:border-b-0">
<div className="h-3 w-20 rounded bg-[var(--lp-surface-2)]" />
<div className="mt-2 h-4 w-3/4 rounded bg-[var(--lp-surface-2)]" />
<div className="mt-2 h-3 w-full rounded bg-[var(--lp-surface-2)]" />
<div className="mt-1 h-3 w-5/6 rounded bg-[var(--lp-surface-2)]" />
</li>
))}
</ul>
);
}
/**
* The What's new flyout: a floating card on the right, over the page. Rendered only while
* open (the host unmounts it), so closing leaves nothing behind. Signed-in users see "New"
* labels and tab counters against their server-side mark; anonymous visitors see the list
* with dates only. Colors come from the landing tokens (`--lp-*`), which are defined globally and follow `.dark` in the portal;
* the host passes `landing-v2` as the root class on the public site so the card stays
* light there (the class must sit INSIDE the portal — CSS variables do not cross it).
*/
export function WhatsNewPanel({ rootClassName }: { rootClassName?: string }) {
const wn = useWhatsNew();
const closeRef = useRef<HTMLButtonElement>(null);
// Marks as they were when the panel opened: the "New" labels must not vanish under the
// reader's eyes when the channel is marked seen a moment later.
const [seenAtOpen] = useState<WhatsNewSeen>(() => wn.seen);
const [openedAt] = useState(() => new Date());
const channel = wn.panelChannel;
const entries = wn.entries(channel);
const newIds = new Set(wn.tracksUnseen ? unseenEntries(entries, seenAtOpen[channel], openedAt).map(e => e.id) : []);
useEffect(() => {
closeRef.current?.focus();
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") closeWhatsNew();
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener("keydown", onKeyDown);
};
}, []);
// Viewing a tab marks it seen up to the newest loaded entry.
const { status, markSeen } = wn;
useEffect(() => {
if (status !== "ready") return;
void markSeen(channel);
}, [status, channel, markSeen]);
const switchChannel = (next: WhatsNewChannel) => {
if (next === channel) return;
trackEvent("whats_new_tab_changed", { channel: next });
wn.setChannel(next);
};
const docsUrl = wn.docsUrl(channel);
return (
<ModalPortal>
<div className={`fixed inset-0 z-[60] ${rootClassName ?? ""}`} role="presentation">
<div className="absolute inset-0 bg-black/20 backdrop-blur-[2px]" onClick={wn.close} aria-hidden="true" />
<div
role="dialog"
aria-modal="true"
aria-labelledby="whats-new-title"
className="whats-new-card absolute top-2 right-2 bottom-2 sm:top-4 sm:right-4 sm:bottom-4 w-[calc(100vw-1rem)] sm:w-[28rem] flex flex-col rounded-2xl border border-[var(--lp-line-soft)] bg-[var(--lp-surface)] shadow-2xl overflow-hidden"
>
<header className="px-5 pt-5 pb-4 border-b border-[var(--lp-line-soft)]">
<div className="flex items-start gap-3">
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--lp-accent-soft)] text-[var(--lp-accent-ink)]">
<BellIcon className="w-5 h-5" />
</span>
<div className="min-w-0 flex-1">
<h2 id="whats-new-title" className="text-lg font-bold leading-tight text-[var(--lp-ink)]">What&apos;s new</h2>
<p className="mt-0.5 text-sm text-[var(--lp-ink-soft)]">News and improvements from Autopilot Monitor.</p>
</div>
<button
ref={closeRef}
type="button"
onClick={wn.close}
aria-label="Close"
className="-mr-2 -mt-1 p-2 rounded-lg text-[var(--lp-ink-faint)] hover:text-[var(--lp-ink)] hover:bg-[var(--lp-surface-2)] transition-colors"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div className="mt-4 flex rounded-lg bg-[var(--lp-surface-2)] p-1" role="tablist" aria-label="Channel">
{WHATS_NEW_CHANNELS.map(c => {
const active = c === channel;
return (
<button
key={c}
type="button"
role="tab"
aria-selected={active}
onClick={() => switchChannel(c)}
className={`flex-1 inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
active
? "bg-[var(--lp-surface)] text-[var(--lp-ink)] shadow-sm"
: "text-[var(--lp-ink-soft)] hover:text-[var(--lp-ink)]"
}`}
>
{CHANNEL_LABEL[c]}
<WhatsNewCountBadge count={wn.unseen[c]} />
</button>
);
})}
</div>
</header>
<div className="flex-1 overflow-y-auto px-5" role="tabpanel">
{wn.status === "loading" || wn.status === "idle" ? (
<SkeletonRows />
) : wn.status === "error" || entries.length === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-[var(--lp-ink-soft)]">
{wn.status === "error" ? "The update list could not be loaded right now." : "No recent entries."}
</p>
<p className="mt-1 text-sm text-[var(--lp-ink-soft)]">The full changelog is always available in the documentation.</p>
</div>
) : (
<ul>
{entries.map((entry, i) => {
const showPeriod = i === 0 || entries[i - 1].period !== entry.period;
return (
<li key={entry.id} className="list-none">
{showPeriod && (
<p className={`text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--lp-ink-faint)] ${i === 0 ? "pt-4" : "pt-6"}`}>
{entry.period}
</p>
)}
<ul>
<EntryRow entry={entry} isNew={newIds.has(entry.id)} />
</ul>
</li>
);
})}
</ul>
)}
</div>
<footer className="px-5 py-4 border-t border-[var(--lp-line-soft)] bg-[var(--lp-surface)]">
{docsUrl && (
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackEvent("whats_new_link_clicked", { entryId: `all-${channel}` })}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-[var(--lp-accent-ink)] px-4 py-2.5 text-sm font-semibold text-white hover:brightness-110 transition-all"
>
View all {CHANNEL_LABEL[channel].toLowerCase()} updates
<ExternalIcon className="w-4 h-4" />
</a>
)}
</footer>
</div>
</div>
</ModalPortal>
);
}
@@ -0,0 +1,39 @@
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
import { WhatsNewPanel } from "./WhatsNewPanel";
import { useWhatsNewState, openWhatsNew } from "@/lib/whatsNewStore";
import { trackEvent } from "@/lib/appInsights";
import { isPublicPath } from "@/lib/hostRouting";
import { WHATS_NEW_CHANNELS, type WhatsNewChannel } from "@/lib/whatsNew";
/**
* Mounts the What's new panel once, in the root layout, so the portal navbar, the landing
* navbar and the Help page tile can all open the same instance through the module store.
*
* `?whats-new=platform|agent` opens it on load (release posts, LinkedIn links).
*
* The panel is styled with the landing tokens (`--lp-*`). Those are redefined by `.dark`
* globally, which is what the portal wants — but the public site is light-only and forces
* its tokens through `.landing-v2`. The panel portals to <body>, outside that wrapper, so on
* public paths the class is re-applied here to keep the card as light as the page.
*/
export function WhatsNewPanelHost() {
const { panelOpen } = useWhatsNewState();
const pathname = usePathname();
const onPublicPage = isPublicPath(pathname);
useEffect(() => {
const requested = new URLSearchParams(window.location.search).get("whats-new");
if (requested === null) return;
const channel = (WHATS_NEW_CHANNELS as readonly string[]).includes(requested)
? (requested as WhatsNewChannel)
: undefined;
trackEvent("whats_new_opened", { source: "deeplink", channel: channel ?? "default" });
openWhatsNew("deeplink", channel);
}, []);
if (!panelOpen) return null;
return <WhatsNewPanel rootClassName={onPublicPage ? "landing-v2" : undefined} />;
}
@@ -6,6 +6,7 @@ import { useAuth } from "../../contexts/AuthContext";
import { getPortalLoginUrl, shouldCrossOriginToPortal } from "../../lib/hostRouting";
import { DOCS_URL } from "@/utils/config";
import { BrandMark } from "../BrandMark";
import { useWhatsNew } from "@/hooks/useWhatsNew";
// Root-anchored (/#…) so the links also work from subpages
// like /get-started, /about, /terms, /privacy.
@@ -25,6 +26,19 @@ function GitHubIcon({ className }: { className?: string }) {
);
}
function BellIcon({ className }: { className?: string }) {
// Ringing bell: the public site announces "there is news" without claiming a count —
// anonymous visitors have no seen mark, so a counter would read 9+ for everyone.
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
<path d="M2.5 8a10 10 0 0 1 2-4.5" />
<path d="M21.5 8a10 10 0 0 0-2-4.5" />
</svg>
);
}
/**
* Full-width landing navigation. Same auth handoff behavior as
* PublicSiteNavbar: on www/apex, sign-in is delegated to the portal
@@ -33,6 +47,7 @@ function GitHubIcon({ className }: { className?: string }) {
export function LandingNavbar() {
const { login, isAuthenticated } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
const whatsNew = useWhatsNew();
useEffect(() => {
if (!menuOpen) return;
@@ -102,6 +117,15 @@ export function LandingNavbar() {
>
<GitHubIcon className="w-4 h-4" />
</a>
<button
type="button"
onClick={() => whatsNew.open("landing")}
className="hidden md:block p-2 rounded-lg text-[var(--lp-ink-faint)] hover:text-[var(--lp-ink)] hover:bg-[var(--lp-surface-2)] transition-colors"
title="What's new"
aria-label="What's new"
>
<BellIcon className="w-4 h-4" />
</button>
<button
onClick={handleSignIn}
className="hidden md:block px-3 py-2 text-sm font-semibold text-[var(--lp-ink)] hover:text-[var(--lp-accent-ink)] transition-colors"
@@ -169,6 +193,14 @@ export function LandingNavbar() {
<GitHubIcon className="w-4 h-4" />
GitHub
</a>
<button
type="button"
onClick={() => { setMenuOpen(false); whatsNew.open("landing-menu"); }}
className="flex items-center gap-2.5 px-3 py-2.5 text-[15px] font-medium rounded-lg text-[var(--lp-ink-soft)] hover:text-[var(--lp-ink)] hover:bg-[var(--lp-surface-2)] transition-colors"
>
<BellIcon className="w-4 h-4" />
What&apos;s new
</button>
<div className="h-px bg-[var(--lp-line-soft)] my-2" />
<button
onClick={handleSignIn}
@@ -0,0 +1,20 @@
"use client";
import { useWhatsNew } from "@/hooks/useWhatsNew";
/**
* The "What's new" tile on the Help page. Same look as the link tiles around it, but a
* button: it opens the What's new panel instead of leaving for the docs. No counter —
* the public site has no seen mark to count against.
*/
export function WhatsNewTile({ className }: { className: string }) {
const whatsNew = useWhatsNew();
return (
<button type="button" onClick={() => whatsNew.open("help-page")} className={`${className} text-left w-full`}>
<span className="block text-sm font-semibold text-gray-800 mb-1">What&apos;s new →</span>
<span className="block text-sm text-gray-600 leading-relaxed">
What changed recently in the portal and the agent.
</span>
</button>
);
}
@@ -263,6 +263,9 @@ function toUserInfo(data: AuthMeResponse, account: AccountInfo): UserInfo {
hasMcpAccess: data.hasMcpAccess || false,
bootstrapTokenEnabled: data.bootstrapTokenEnabled || false,
unrestrictedModeEnabled: data.unrestrictedModeEnabled || false,
// What's new seen marks: absent key = never viewed (first-visit window applies).
whatsNewSeenPlatformUtc: data.whatsNewSeenPlatformUtc,
whatsNewSeenAgentUtc: data.whatsNewSeenAgentUtc,
};
}
@@ -0,0 +1,132 @@
"use client";
import { useCallback, useEffect, useMemo } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import { fetchOk, jsonBody } from "@/lib/apiClient";
import { trackEvent } from "@/lib/appInsights";
import {
countUnseen,
laterMark,
nextSeenMark,
WHATS_NEW_CHANNELS,
type WhatsNewChannel,
type WhatsNewEntry,
type WhatsNewSeen,
} from "@/lib/whatsNew";
import {
closeWhatsNew,
ensureWhatsNewLoaded,
openWhatsNew,
recordSessionSeen,
setWhatsNewChannel,
useWhatsNewState,
type WhatsNewLoadStatus,
} from "@/lib/whatsNewStore";
import type { WhatsNewSeenRequest } from "@/utils/wire-types.generated";
export interface UseWhatsNew {
status: WhatsNewLoadStatus;
entries: (channel: WhatsNewChannel) => WhatsNewEntry[];
docsUrl: (channel: WhatsNewChannel) => string | null;
/**
* Whether unseen entries are counted at all — only for signed-in users, whose mark lives
* on the server. Anonymous visitors get no counter and no "New" labels.
*/
tracksUnseen: boolean;
/** Effective seen mark per channel: server mark plus this session's writes; NEVER_SEEN when not tracking. */
seen: WhatsNewSeen;
unseen: Record<WhatsNewChannel, number>;
totalUnseen: number;
panelOpen: boolean;
panelChannel: WhatsNewChannel;
open: (source: string, channel?: WhatsNewChannel) => void;
close: () => void;
setChannel: (channel: WhatsNewChannel) => void;
/** Marks a channel as viewed up to the newest loaded entry (no-op when not tracking). */
markSeen: (channel: WhatsNewChannel) => Promise<void>;
}
/**
* The one hook behind every What's new trigger and the panel. Mounting it loads the payload
* (once, shared); signed-in users read their mark from auth/me and write it through the
* backend, so the counter resets across browsers.
*/
export function useWhatsNew(): UseWhatsNew {
const { isAuthenticated, user, getAccessToken } = useAuth();
const s = useWhatsNewState();
useEffect(() => {
void ensureWhatsNewLoaded();
}, []);
const tracksUnseen = isAuthenticated && user !== null;
const serverPlatform = tracksUnseen ? user.whatsNewSeenPlatformUtc ?? null : null;
const serverAgent = tracksUnseen ? user.whatsNewSeenAgentUtc ?? null : null;
const { sessionSeen } = s;
const seen = useMemo<WhatsNewSeen>(
() => ({
platform: laterMark(serverPlatform, sessionSeen.platform),
agent: laterMark(serverAgent, sessionSeen.agent),
}),
[serverPlatform, serverAgent, sessionSeen],
);
const entries = useCallback(
(channel: WhatsNewChannel): WhatsNewEntry[] => s.payload?.channels[channel].entries ?? [],
[s.payload],
);
const unseen = {
platform: tracksUnseen ? countUnseen(entries("platform"), seen.platform) : 0,
agent: tracksUnseen ? countUnseen(entries("agent"), seen.agent) : 0,
};
const markSeen = useCallback(
async (channel: WhatsNewChannel) => {
if (!tracksUnseen) return;
const mark = nextSeenMark(entries(channel));
if (mark === null) return;
if (laterMark(seen[channel], mark) === seen[channel]) return;
recordSessionSeen(channel, mark);
try {
await fetchOk(api.auth.whatsNewSeen(), getAccessToken, {
method: "PUT",
body: jsonBody<WhatsNewSeenRequest>({ channel, seenUtc: mark }),
});
} catch {
// Best effort: the session layer already hides the badge; the next visit retries.
}
},
[entries, seen, tracksUnseen, getAccessToken],
);
const open = useCallback(
(source: string, channel?: WhatsNewChannel) => {
trackEvent("whats_new_opened", {
source,
authenticated: isAuthenticated,
unseenPlatform: unseen.platform,
unseenAgent: unseen.agent,
});
openWhatsNew(source, channel);
},
[isAuthenticated, unseen.platform, unseen.agent],
);
return {
status: s.status,
entries,
docsUrl: channel => s.payload?.channels[channel].docsUrl ?? null,
tracksUnseen,
seen,
unseen,
totalUnseen: WHATS_NEW_CHANNELS.reduce((sum, c) => sum + unseen[c], 0),
panelOpen: s.panelOpen,
panelChannel: s.panelChannel,
open,
close: closeWhatsNew,
setChannel: setWhatsNewChannel,
markSeen,
};
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import React from "react";
import { formatInlineMarkdown } from "../formatInlineMarkdown";
function tag(node: React.ReactNode): string | null {
return React.isValidElement(node) ? String(node.type) : null;
}
function props(node: React.ReactNode): Record<string, unknown> {
return React.isValidElement(node) ? (node.props as Record<string, unknown>) : {};
}
describe("formatInlineMarkdown", () => {
it("renders bold and code, leaves links as text by default (rule explanations stay unchanged)", () => {
const out = formatInlineMarkdown("A **b** `c` [d](https://e)");
expect(out.map(tag)).toEqual([null, "strong", null, "code", null]);
expect(out[4]).toBe(" [d](https://e)");
});
it("renders https links as new-tab anchors when opted in", () => {
const out = formatInlineMarkdown("See [Docs](https://docs.example/p#a) now", { links: true, linkClassName: "x" });
expect(out.map(tag)).toEqual([null, "a", null]);
expect(props(out[1])).toMatchObject({ href: "https://docs.example/p#a", target: "_blank", rel: "noopener noreferrer", className: "x", children: "Docs" });
});
it("degrades non-http link targets to their label", () => {
const out = formatInlineMarkdown("[x](javascript:void) and [y](mailto:a@b)", { links: true });
expect(out.map(tag)).toEqual([null, null, null, null, null]);
expect(out.join("")).toBe("x and y");
});
it("mixes links with bold and code", () => {
const out = formatInlineMarkdown("**T** — `--flag` then [L](https://l)", { links: true });
expect(out.map(tag)).toEqual([null, "strong", null, "code", null, "a", null]);
});
it("renders single-asterisk emphasis only when opted in, never inside bold", () => {
expect(formatInlineMarkdown("mapped to *Soft reboot* or **Hard**").map(tag)).toEqual([null, "strong", null]);
const out = formatInlineMarkdown("mapped to *Soft reboot* or **Hard**", { emphasis: true });
expect(out.map(tag)).toEqual([null, "em", null, "strong", null]);
expect(props(out[1]).children).toBe("Soft reboot");
expect(formatInlineMarkdown("a * b * c", { emphasis: true }).map(tag)).toEqual([null]);
});
});
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import {
countUnseen,
FIRST_VISIT_WINDOW_DAYS,
formatBadgeCount,
laterMark,
nextSeenMark,
parseWhatsNewPayload,
unseenEntries,
type WhatsNewEntry,
} from "../whatsNew";
const NOW = new Date("2026-09-08T12:00:00.000Z");
function entry(id: string, addedUtc: string): WhatsNewEntry {
return { id, addedUtc, period: "September 2026", title: null, body: id, link: null };
}
const ENTRIES = [
entry("a", "2026-09-07T10:00:00.000Z"),
entry("b", "2026-09-01T10:00:00.000Z"),
entry("c", "2026-08-01T10:00:00.000Z"), // older than the first-visit window
];
describe("unseen logic", () => {
it("counts entries newer than the seen mark", () => {
expect(unseenEntries(ENTRIES, "2026-09-01T10:00:00.000Z", NOW).map(e => e.id)).toEqual(["a"]);
expect(countUnseen(ENTRIES, "2026-09-07T10:00:00.000Z", NOW)).toBe(0);
});
it("falls back to the first-visit window without a mark", () => {
expect(unseenEntries(ENTRIES, null, NOW).map(e => e.id)).toEqual(["a", "b"]);
const edge = new Date(NOW.getTime() - FIRST_VISIT_WINDOW_DAYS * 86_400_000).toISOString();
expect(countUnseen([entry("x", edge)], null, NOW)).toBe(0);
});
it("treats an unparsable mark like no mark", () => {
expect(countUnseen(ENTRIES, "garbage", NOW)).toBe(2);
});
it("uses the newest loaded entry as the next mark, never now", () => {
expect(nextSeenMark(ENTRIES)).toBe("2026-09-07T10:00:00.000Z");
expect(nextSeenMark([])).toBeNull();
});
it("keeps marks monotonic", () => {
expect(laterMark(null, "2026-09-01T00:00:00Z")).toBe("2026-09-01T00:00:00Z");
expect(laterMark("2026-09-02T00:00:00Z", "2026-09-01T00:00:00Z")).toBe("2026-09-02T00:00:00Z");
expect(laterMark("2026-09-01T00:00:00Z", "2026-09-02T00:00:00Z")).toBe("2026-09-02T00:00:00Z");
});
it("caps the badge like the notification bell", () => {
expect(formatBadgeCount(3)).toBe("3");
expect(formatBadgeCount(9)).toBe("9");
expect(formatBadgeCount(10)).toBe("9+");
});
});
describe("parseWhatsNewPayload", () => {
const valid = {
schemaVersion: 1,
generatedUtc: "2026-09-08T09:00:00.000Z",
docsCommit: "abc1234",
channels: {
platform: { docsUrl: "https://d/p", entries: [ENTRIES[0]] },
agent: { docsUrl: "https://d/a", entries: [] },
},
};
it("accepts the generated shape", () => {
expect(parseWhatsNewPayload(valid)).not.toBeNull();
expect(parseWhatsNewPayload({ ...valid, docsCommit: null })).not.toBeNull();
});
it("rejects wrong schema versions, missing channels and bad entries", () => {
expect(parseWhatsNewPayload({ ...valid, schemaVersion: 2 })).toBeNull();
expect(parseWhatsNewPayload({ ...valid, channels: { platform: valid.channels.platform } })).toBeNull();
expect(
parseWhatsNewPayload({
...valid,
channels: { ...valid.channels, platform: { docsUrl: "x", entries: [{ id: "a" }] } },
}),
).toBeNull();
expect(
parseWhatsNewPayload({
...valid,
channels: { ...valid.channels, platform: { docsUrl: "x", entries: [entry("a", "not-a-date")] } },
}),
).toBeNull();
expect(parseWhatsNewPayload(null)).toBeNull();
expect(parseWhatsNewPayload("string")).toBeNull();
});
});
+1
View File
@@ -18,6 +18,7 @@ export const api = {
// ── Auth ──────────────────────────────────────────────────────────────────
auth: {
me: () => `${API_BASE_URL}/api/auth/me`,
whatsNewSeen: () => `${API_BASE_URL}/api/auth/me/whats-new-seen`,
},
// ── Sessions ──────────────────────────────────────────────────────────────
@@ -45,8 +45,9 @@ export const DOCS_PATHS = {
usageMetrics: "/portal-guide/usage-metrics",
auditLog: "/portal-guide/audit-log-and-system-health#audit-log",
systemHealth: "/portal-guide/audit-log-and-system-health#system-health",
// Navbar help menu
// Navbar help menu / What's new panel
platformChangelog: "/changelog/platform-changelog",
agentChangelog: "/changelog/agent-changelog",
serviceAnnouncements: "/troubleshooting-and-support/service-announcements",
// Dual app-registration window (sign-in banners, failed-sign-in screen)
appRegistrationMigration: "/troubleshooting-and-support/app-registration-migration",
@@ -1,12 +1,35 @@
import React from "react";
export interface InlineMarkdownOptions {
/**
* Render `[label](https://…)` as links opening in a new tab. Off by default: rule
* explanations never carried links, and their callers must stay pixel-identical.
* Only http(s) targets become anchors; anything else renders as its label.
*/
links?: boolean;
linkClassName?: string;
/** Render single-asterisk `*emphasis*` as <em>. Off by default for the same reason. */
emphasis?: boolean;
}
const BOLD_OR_CODE = "\\*\\*[^*]+\\*\\*|`[^`]+`";
const LINK = "\\[[^\\]]+\\]\\([^)\\s]+\\)";
const EMPHASIS = "(?<!\\*)\\*[^*\\s][^*]*\\*(?!\\*)";
function tokenPattern(options: InlineMarkdownOptions): RegExp {
const alternatives = [BOLD_OR_CODE];
if (options.links) alternatives.push(LINK);
if (options.emphasis) alternatives.push(EMPHASIS);
return new RegExp(`(${alternatives.join("|")})`, "g");
}
/**
* Converts basic inline markdown (**bold** and `code`) to React elements.
* Returns an array of React nodes suitable for use as JSX children.
* Converts basic inline markdown (**bold** and `code`, optionally [label](url) and
* *emphasis*) to React elements. Returns an array of React nodes suitable for use as JSX children.
*/
export function formatInlineMarkdown(text: string): React.ReactNode[] {
// Split on **bold** and `code` patterns, keeping the delimiters as capture groups
const parts = text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
export function formatInlineMarkdown(text: string, options: InlineMarkdownOptions = {}): React.ReactNode[] {
// Split on the token patterns, keeping the delimiters as capture groups
const parts = text.split(tokenPattern(options));
return parts.map((part, i) => {
if (part.startsWith("**") && part.endsWith("**")) {
@@ -26,6 +49,21 @@ export function formatInlineMarkdown(text: string): React.ReactNode[] {
</code>
);
}
if (options.links && part.startsWith("[")) {
const m = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(part);
if (m) {
const [, label, href] = m;
if (!/^https?:\/\//i.test(href)) return label;
return (
<a key={i} href={href} target="_blank" rel="noopener noreferrer" className={options.linkClassName}>
{label}
</a>
);
}
}
if (options.emphasis && part.length > 2 && part.startsWith("*") && part.endsWith("*")) {
return <em key={i}>{part.slice(1, -1)}</em>;
}
return part;
});
}
@@ -0,0 +1,136 @@
/**
* "What's new" — the portal's rendering of the two customer changelogs.
*
* Data: /whats-new.json, generated at build time from the docs repo by
* scripts/whats-new/build-whats-new.js. The docs markdown stays the only authoritative
* text; this module only types the payload and counts what a signed-in user has not seen
* yet. Anonymous visitors on the public site get the panel without a counter: they have no
* identity to attach a seen mark to, and a counter that reads 9+ for every first visit would
* claim urgency nobody missed.
*/
export const WHATS_NEW_URL = "/whats-new.json";
export const WHATS_NEW_CHANNELS = ["platform", "agent"] as const;
export type WhatsNewChannel = (typeof WHATS_NEW_CHANNELS)[number];
/** First visit of a signed-in user (no mark yet): entries younger than this count as unseen. */
export const FIRST_VISIT_WINDOW_DAYS = 30;
export interface WhatsNewEntry {
id: string;
/** ISO timestamp — when the bullet was committed to the docs repo. */
addedUtc: string;
/** The `## <Period>` heading the bullet lives under, e.g. "September 2026". */
period: string;
/** Platform bullets carry a bold lead; agent bullets are a single sentence (null). */
title: string | null;
/** Inline markdown: **bold**, `code`, [label](https://…). */
body: string;
/** First docs link of the bullet — the "Read update" target — or null. */
link: string | null;
}
export interface WhatsNewChannelData {
docsUrl: string;
entries: WhatsNewEntry[];
}
export interface WhatsNewPayload {
schemaVersion: 1;
generatedUtc: string;
docsCommit: string | null;
channels: Record<WhatsNewChannel, WhatsNewChannelData>;
}
/** ISO timestamp per channel of the newest entry the user has seen; null = never. */
export type WhatsNewSeen = Record<WhatsNewChannel, string | null>;
export const NEVER_SEEN: WhatsNewSeen = { platform: null, agent: null };
// ── Payload guard ────────────────────────────────────────────
function isEntry(value: unknown): value is WhatsNewEntry {
if (!value || typeof value !== "object") return false;
const e = value as Record<string, unknown>;
return (
typeof e.id === "string" &&
typeof e.addedUtc === "string" &&
!Number.isNaN(Date.parse(e.addedUtc)) &&
typeof e.period === "string" &&
(e.title === null || typeof e.title === "string") &&
typeof e.body === "string" &&
(e.link === null || typeof e.link === "string")
);
}
function isChannel(value: unknown): value is WhatsNewChannelData {
if (!value || typeof value !== "object") return false;
const c = value as Record<string, unknown>;
return (
typeof c.docsUrl === "string" &&
Array.isArray(c.entries) &&
c.entries.every(isEntry)
);
}
/**
* Validates the fetched JSON. Anything off-shape yields null — the panel then shows
* its docs links instead of half-rendered content, and no badge is computed from it.
*/
export function parseWhatsNewPayload(value: unknown): WhatsNewPayload | null {
if (!value || typeof value !== "object") return null;
const p = value as Record<string, unknown>;
if (p.schemaVersion !== 1) return null;
if (typeof p.generatedUtc !== "string") return null;
if (!(p.docsCommit === null || typeof p.docsCommit === "string")) return null;
const channels = p.channels as Record<string, unknown> | undefined;
if (!channels || typeof channels !== "object") return null;
for (const name of WHATS_NEW_CHANNELS) {
if (!isChannel(channels[name])) return null;
}
return value as WhatsNewPayload;
}
// ── Unseen logic ─────────────────────────────────────────────
/**
* Entries newer than the seen mark. Without a mark (first visit, or a browser before the
* backend deploy) the last FIRST_VISIT_WINDOW_DAYS count — bounded, but enough to show
* how often the platform moves.
*/
export function unseenEntries(entries: WhatsNewEntry[], seenUtc: string | null, now: Date = new Date()): WhatsNewEntry[] {
const threshold =
seenUtc !== null && !Number.isNaN(Date.parse(seenUtc))
? Date.parse(seenUtc)
: now.getTime() - FIRST_VISIT_WINDOW_DAYS * 86_400_000;
return entries.filter(e => Date.parse(e.addedUtc) > threshold);
}
export function countUnseen(entries: WhatsNewEntry[], seenUtc: string | null, now: Date = new Date()): number {
return unseenEntries(entries, seenUtc, now).length;
}
/**
* The mark to store once a channel has been viewed: the newest `addedUtc` among the
* LOADED entries — not `now`. A bullet committed before the web deploy that carried
* it would otherwise be marked seen without ever having been shown.
*/
export function nextSeenMark(entries: WhatsNewEntry[]): string | null {
let max: string | null = null;
for (const e of entries) {
if (max === null || Date.parse(e.addedUtc) > Date.parse(max)) max = e.addedUtc;
}
return max;
}
/** Later of two ISO timestamps; null-safe. Keeps a mark monotonic across tabs. */
export function laterMark(a: string | null, b: string | null): string | null {
if (a === null) return b;
if (b === null) return a;
return Date.parse(b) > Date.parse(a) ? b : a;
}
/** "9+" cap identical to the notification bell. */
export function formatBadgeCount(n: number): string {
return n > 9 ? "9+" : String(n);
}
@@ -0,0 +1,130 @@
"use client";
import { useSyncExternalStore } from "react";
import { parseJsonBody } from "@/lib/apiClient";
import {
laterMark,
NEVER_SEEN,
parseWhatsNewPayload,
WHATS_NEW_URL,
type WhatsNewChannel,
type WhatsNewPayload,
type WhatsNewSeen,
} from "@/lib/whatsNew";
/**
* Module-level store for What's new: the fetched payload, the panel's open state and the
* marks written in this browser session. One store instead of a context because three
* unrelated triggers open the same panel — the portal navbar, the landing navbar and the
* Help page tile — and the panel itself is mounted once in the root layout.
*
* Snapshot is a single immutable object; the server snapshot is the initial state so the
* badge only appears after hydration (no markup mismatch for the prerendered pages).
*/
export type WhatsNewLoadStatus = "idle" | "loading" | "ready" | "error";
export interface WhatsNewState {
status: WhatsNewLoadStatus;
payload: WhatsNewPayload | null;
fetchedAt: number;
/** Marks written in this session, layered over the server marks (optimistic). */
sessionSeen: WhatsNewSeen;
panelOpen: boolean;
panelChannel: WhatsNewChannel;
panelSource: string;
}
const INITIAL: WhatsNewState = {
status: "idle",
payload: null,
fetchedAt: 0,
sessionSeen: NEVER_SEEN,
panelOpen: false,
panelChannel: "platform",
panelSource: "",
};
/** Re-fetch when the tab comes back after this long — a deploy may have shipped new entries. */
const STALE_AFTER_MS = 30 * 60 * 1000;
let state: WhatsNewState = INITIAL;
const listeners = new Set<() => void>();
let inflight: Promise<void> | null = null;
let visibilityHooked = false;
function setState(patch: Partial<WhatsNewState>): void {
state = { ...state, ...patch };
for (const listener of [...listeners]) listener();
}
function subscribe(onStoreChange: () => void): () => void {
listeners.add(onStoreChange);
return () => listeners.delete(onStoreChange);
}
export function useWhatsNewState(): WhatsNewState {
return useSyncExternalStore(subscribe, () => state, () => INITIAL);
}
/** Loads the payload once (and again when stale); safe to call from every trigger's mount. */
export function ensureWhatsNewLoaded(): Promise<void> {
if (typeof window === "undefined") return Promise.resolve();
if (inflight) return inflight;
if (!visibilityHooked) {
visibilityHooked = true;
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") void ensureWhatsNewLoaded();
});
}
const fresh = state.status === "ready" && Date.now() - state.fetchedAt < STALE_AFTER_MS;
if (fresh) return Promise.resolve();
setState({ status: state.payload ? state.status : "loading" });
inflight = (async () => {
try {
const response = await fetch(WHATS_NEW_URL, { cache: "no-store" });
if (!response.ok) throw new Error(`whats-new.json ${response.status}`);
const payload = parseWhatsNewPayload(await parseJsonBody<unknown>(response));
if (!payload) throw new Error("whats-new.json: unexpected shape");
setState({ status: "ready", payload, fetchedAt: Date.now() });
} catch {
// Keep a previously loaded payload; only a first load surfaces the error state.
setState({ status: state.payload ? "ready" : "error", fetchedAt: Date.now() });
} finally {
inflight = null;
}
})();
return inflight;
}
export function openWhatsNew(source: string, channel?: WhatsNewChannel): void {
setState({ panelOpen: true, panelSource: source, ...(channel ? { panelChannel: channel } : {}) });
void ensureWhatsNewLoaded();
}
export function closeWhatsNew(): void {
setState({ panelOpen: false });
}
export function setWhatsNewChannel(channel: WhatsNewChannel): void {
setState({ panelChannel: channel });
}
/**
* Records a mark for this session so the badge updates immediately; the hook persists it
* server-side. Monotonic: an older mark never wins.
*/
export function recordSessionSeen(channel: WhatsNewChannel, seenUtc: string): void {
const next = laterMark(state.sessionSeen[channel], seenUtc);
if (next === state.sessionSeen[channel]) return;
setState({ sessionSeen: { ...state.sessionSeen, [channel]: next } });
}
/** Test seam. */
export function resetWhatsNewStoreForTests(): void {
state = INITIAL;
inflight = null;
}
+2 -1
View File
@@ -7,8 +7,9 @@
},
"scripts": {
"dev": "next dev",
"prebuild": "node scripts/sync-error-codes.js && node scripts/generate-lastmod.js && node scripts/generate-version.js",
"prebuild": "node scripts/sync-error-codes.js && node scripts/generate-lastmod.js && node scripts/generate-version.js && node scripts/whats-new/build-whats-new.js",
"sync-error-codes": "node scripts/sync-error-codes.js",
"whats-new": "node scripts/whats-new/build-whats-new.js",
"generate:manifests": "node scripts/generate-shared-manifest-types.js",
"build": "next build",
"start": "next start",
@@ -0,0 +1,266 @@
import { describe, expect, it } from "vitest";
import core from "../whats-new-core.js";
const {
parseChangelog,
splitTitle,
slugifyHeading,
buildSummaryUrlMap,
resolveLink,
rewriteLinks,
parseBlamePorcelain,
stableId,
buildPayload,
emptyPayload,
} = core;
// Synthetic docs bundle — never copied from the real changelog, so the test does not
// silently drift with the content it is meant to exercise.
const SUMMARY = [
"# Table of contents",
"",
"* [Welcome](README.md)",
"* [Plans](plans.md)",
"",
"## Portal Guide",
"",
"* [Dashboard & Sessions](portal-guide/dashboard-and-sessions.md)",
"",
"## Rules",
"",
"* [Analyze Rules](rules/analyze-rules/README.md)",
" * [Concepts](rules/analyze-rules/concepts.md)",
"",
"## Trust & Security",
"",
"* [Security FAQ](trust/security-faq.md)",
"",
"## Troubleshooting & Support",
"",
"* [FAQ](troubleshooting/faq.md)",
"* [How to Purchase](troubleshooting-and-support/how-to-purchase/README.md)",
" * [Marketplace](troubleshooting-and-support/how-to-purchase/microsoft-marketplace.md)",
"",
"## Changelog",
"",
"* [Agent Changelog](changelog/agent-changelog.md)",
"* [Platform Changelog](changelog/platform-changelog.md)",
].join("\n");
const PLATFORM_MD = [
"---",
"type: Changelog",
"description: >-",
" Two-line frontmatter,",
" newest first.",
"---",
"",
"# Platform Changelog",
"",
"Intro paragraph with a * star that is not a bullet.",
"",
"## September 2026",
"",
"* **Filter widgets by priority** — Every chip filters the list. See [Dashboard](../portal-guide/dashboard-and-sessions.md#search-syntax) and [Concepts](../rules/analyze-rules/concepts.md).",
"* **Wrapped bullet** — First line",
" continues here with `code` and a [release](https://github.com/okieselbach/AutopilotMonitor/releases).",
"* **Unknown page** — Links to [somewhere](../troubleshooting/new-page.md) and [outside](http://insecure.example).",
"",
"## August 2026",
"",
"* **Older** — Older body. See [FAQ](../troubleshooting/faq.md).",
"",
"## July 2026",
"",
"* **July** — July body.",
"",
"## June 2026",
"",
"* **Dropped** — Outside the three-period window.",
].join("\r\n");
const AGENT_MD = [
"# Agent Changelog",
"",
"**Current versions:** ![badge](https://img.shields.io/x)",
"",
"## September 2026",
"",
"* Diagnostics upload events now report how many files the package skipped",
"* Keep-awake no longer releases early (see the [platform changelog](platform-changelog.md))",
].join("\n");
function blameFor(lines: Record<number, number | null>): string {
// Minimal --line-porcelain shape: header, author-time, tab-prefixed content.
return Object.entries(lines)
.map(([line, time]) => {
const hash = time === null ? "0".repeat(40) : "a".repeat(40);
return [`${hash} ${line} ${line} 1`, ...(time === null ? [] : [`author-time ${time}`]), "\tcontent"].join("\n");
})
.join("\n");
}
describe("parseChangelog", () => {
it("skips frontmatter and intro, keeps blocks in file order, folds continuation lines", () => {
const blocks = parseChangelog(PLATFORM_MD);
expect(blocks.map(b => b.period)).toEqual(["September 2026", "August 2026", "July 2026", "June 2026"]);
expect(blocks[0].bullets).toHaveLength(3);
expect(blocks[0].bullets[1].text).toBe(
"**Wrapped bullet** — First line continues here with `code` and a [release](https://github.com/okieselbach/AutopilotMonitor/releases).",
);
});
it("records the 1-based source line of each bullet's first line (CRLF input)", () => {
const blocks = parseChangelog(PLATFORM_MD);
expect(blocks[0].bullets.map(b => b.line)).toEqual([14, 15, 17]);
});
it("ignores the agent badge line and reads sentence bullets", () => {
const blocks = parseChangelog(AGENT_MD);
expect(blocks).toHaveLength(1);
expect(blocks[0].bullets).toHaveLength(2);
});
});
describe("splitTitle", () => {
it("splits the platform lead at the em dash", () => {
expect(splitTitle("**Title here** — Body text.")).toEqual({ title: "Title here", body: "Body text." });
});
it("returns null title for a plain sentence", () => {
expect(splitTitle("Agent reports something")).toEqual({ title: null, body: "Agent reports something" });
});
it("does not treat a bold word inside the sentence as a title", () => {
expect(splitTitle("Now **bold** inside").title).toBeNull();
});
});
describe("GitBook URL map", () => {
const map = buildSummaryUrlMap(SUMMARY);
it("slugifies group headings the way GitBook does", () => {
expect(slugifyHeading("Troubleshooting & Support")).toBe("troubleshooting-and-support");
expect(slugifyHeading("Trust & Security")).toBe("trust-and-security");
expect(slugifyHeading("Getting Started")).toBe("getting-started");
});
it("maps root pages, grouped pages, README folders and nested pages", () => {
expect(map.get("README.md")).toBe("/");
expect(map.get("plans.md")).toBe("/plans");
expect(map.get("portal-guide/dashboard-and-sessions.md")).toBe("/portal-guide/dashboard-and-sessions");
expect(map.get("troubleshooting/faq.md")).toBe("/troubleshooting-and-support/faq");
expect(map.get("rules/analyze-rules/README.md")).toBe("/rules/analyze-rules");
expect(map.get("rules/analyze-rules/concepts.md")).toBe("/rules/analyze-rules/concepts");
expect(map.get("troubleshooting-and-support/how-to-purchase/microsoft-marketplace.md")).toBe(
"/troubleshooting-and-support/how-to-purchase/microsoft-marketplace",
);
expect(map.get("changelog/platform-changelog.md")).toBe("/changelog/platform-changelog");
});
});
describe("resolveLink / rewriteLinks", () => {
const urlMap = buildSummaryUrlMap(SUMMARY);
const folderSlugMap = new Map([["troubleshooting", "troubleshooting-and-support"]]);
const ctx = { docsUrl: "https://docs.example", fromDir: "changelog", urlMap, folderSlugMap };
it("resolves relative docs links with anchors and sibling links", () => {
expect(resolveLink("../portal-guide/dashboard-and-sessions.md#search-syntax", ctx)).toBe(
"https://docs.example/portal-guide/dashboard-and-sessions#search-syntax",
);
expect(resolveLink("platform-changelog.md", ctx)).toBe("https://docs.example/changelog/platform-changelog");
});
it("passes https through, drops http and same-page anchors", () => {
expect(resolveLink("https://github.com/x/y", ctx)).toBe("https://github.com/x/y");
expect(resolveLink("http://insecure.example", ctx)).toBeNull();
expect(resolveLink("#anchor", ctx)).toBeNull();
expect(resolveLink("mailto:a@b.c", ctx)).toBeNull();
});
it("falls back to the folder's group slug for pages missing from SUMMARY", () => {
expect(resolveLink("../troubleshooting/new-page.md", ctx)).toBe("https://docs.example/troubleshooting-and-support/new-page");
expect(resolveLink("../nowhere/page.md", ctx)).toBeNull();
});
it("rewrites every link, unwraps unresolvable ones, returns the first URL", () => {
const { text, link } = rewriteLinks("See [A](../troubleshooting/faq.md) and [B](http://x) and [C](https://c).", ctx);
expect(text).toBe("See [A](https://docs.example/troubleshooting-and-support/faq) and B and [C](https://c).");
expect(link).toBe("https://docs.example/troubleshooting-and-support/faq");
});
});
describe("parseBlamePorcelain", () => {
it("maps line numbers to author-time and uncommitted lines to null", () => {
const map = parseBlamePorcelain(blameFor({ 14: 1757000000, 15: null, 17: 1757100000 }));
expect(map.get(14)).toBe(1757000000);
expect(map.get(15)).toBeNull();
expect(map.get(17)).toBe(1757100000);
expect(map.get(16)).toBeUndefined();
});
});
describe("buildPayload", () => {
const now = new Date("2026-09-08T10:00:00.000Z");
const payload = buildPayload({
docsUrl: "https://docs.example/",
docsCommit: "abc1234",
summary: SUMMARY,
now,
channels: {
platform: { markdown: PLATFORM_MD, blame: blameFor({ 14: 1757200000, 15: null, 17: 1757100000, 22: 1756000000, 26: 1755000000, 30: 1754000000 }) },
agent: { markdown: AGENT_MD, blame: blameFor({ 7: 1757300000, 8: 1757250000 }) },
},
});
it("carries schema, commit and channel docs URLs", () => {
expect(payload.schemaVersion).toBe(1);
expect(payload.docsCommit).toBe("abc1234");
expect(payload.generatedUtc).toBe(now.toISOString());
expect(payload.channels.platform.docsUrl).toBe("https://docs.example/changelog/platform-changelog");
expect(payload.channels.agent.docsUrl).toBe("https://docs.example/changelog/agent-changelog");
});
it("keeps the three newest periods only and preserves file order", () => {
const periods = payload.channels.platform.entries.map(e => e.period);
expect(periods).toEqual(["September 2026", "September 2026", "September 2026", "August 2026", "July 2026"]);
});
it("dates bullets from blame and uncommitted ones from now", () => {
const [first, second] = payload.channels.platform.entries;
expect(first.addedUtc).toBe(new Date(1757200000 * 1000).toISOString());
expect(second.addedUtc).toBe(now.toISOString());
});
it("splits platform titles, rewrites links and picks the first as the read-more target", () => {
const first = payload.channels.platform.entries[0];
expect(first.title).toBe("Filter widgets by priority");
expect(first.body).toBe(
"Every chip filters the list. See [Dashboard](https://docs.example/portal-guide/dashboard-and-sessions#search-syntax) and [Concepts](https://docs.example/rules/analyze-rules/concepts).",
);
expect(first.link).toBe("https://docs.example/portal-guide/dashboard-and-sessions#search-syntax");
});
it("leaves agent bullets untitled with the sentence as body", () => {
const [a, b] = payload.channels.agent.entries;
expect(a.title).toBeNull();
expect(a.body).toBe("Diagnostics upload events now report how many files the package skipped");
expect(a.link).toBeNull();
expect(b.link).toBe("https://docs.example/changelog/platform-changelog");
});
it("gives every entry a stable id that differs between entries", () => {
const ids = payload.channels.platform.entries.map(e => e.id);
expect(new Set(ids).size).toBe(ids.length);
expect(stableId("x")).toBe(stableId("x"));
expect(stableId("x")).not.toBe(stableId("y"));
});
});
describe("emptyPayload", () => {
it("has the full shape with no entries", () => {
const p = emptyPayload("https://docs.example");
expect(p.channels.platform.entries).toEqual([]);
expect(p.channels.agent.entries).toEqual([]);
expect(p.channels.agent.docsUrl).toBe("https://docs.example/changelog/agent-changelog");
expect(p.docsCommit).toBeNull();
});
});
@@ -0,0 +1,105 @@
/**
* Generates public/whats-new.json — the "What's new" panel's data, served verbatim at
* /whats-new.json by the static export. Runs in prebuild, like generate-version.js.
*
* Source of truth is the customer docs repo (autopilotmonitor-docs): the two changelog
* markdown files plus SUMMARY.md for the GitBook URL shape. `git blame` on the checkout
* dates every bullet, which is what makes "new since you last looked" possible without
* any dates in the authoring format.
*
* Docs checkout location, in order:
* 1. WHATS_NEW_DOCS_DIR env (deploy-web.yml checks the docs repo out and sets this)
* 2. --docs <path> (local runs)
* Without either, a LOCAL build writes an empty payload (valid shape, no entries) so the
* panel degrades to its docs links; a CI build (CI=true) fails — production must never
* ship an empty or stale What's new.
*
* The output is gitignored: addedUtc/generatedUtc are derived from the docs checkout.
*/
const { execFileSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const core = require("./whats-new-core.js");
const WEB_ROOT = path.resolve(__dirname, "..", "..");
const OUTPUT_FILE = path.join(WEB_ROOT, "public", "whats-new.json");
const DOCS_URL = "https://docs.autopilotmonitor.com";
function parseArgs(argv) {
const args = { docs: process.env.WHATS_NEW_DOCS_DIR || null };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--docs" && argv[i + 1]) args.docs = argv[++i];
}
return args;
}
function git(docsDir, gitArgs) {
return execFileSync("git", ["-C", docsDir, ...gitArgs], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
maxBuffer: 32 * 1024 * 1024,
});
}
function readChannel(docsDir, relFile) {
const markdown = fs.readFileSync(path.join(docsDir, relFile), "utf-8");
let blame = "";
try {
blame = git(docsDir, ["blame", "--line-porcelain", "--", relFile]);
} catch (err) {
// A shallow or missing history yields no dates: every bullet then counts as
// published now, which would light the badge for everyone on each deploy.
throw new Error(`git blame failed for ${relFile} in ${docsDir} — is the checkout complete (fetch-depth: 0)? ${err.message}`);
}
return { markdown, blame };
}
function writePayload(payload, note) {
fs.mkdirSync(path.dirname(OUTPUT_FILE), { recursive: true });
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf-8");
const counts = Object.entries(payload.channels)
.map(([name, ch]) => `${name}=${ch.entries.length}`)
.join(", ");
console.log(`Generated ${OUTPUT_FILE} (${note}; ${counts})`);
}
function main() {
const { docs } = parseArgs(process.argv.slice(2));
if (!docs) {
if (process.env.CI) {
console.error("whats-new: WHATS_NEW_DOCS_DIR is not set in CI — the docs checkout step is missing. Refusing to ship an empty What's new.");
process.exit(1);
}
writePayload(core.emptyPayload(DOCS_URL), "no docs checkout — empty payload");
return;
}
const docsDir = path.resolve(docs);
if (!fs.existsSync(path.join(docsDir, "SUMMARY.md"))) {
console.error(`whats-new: ${docsDir} has no SUMMARY.md — not a docs checkout.`);
process.exit(1);
}
let docsCommit = null;
try {
docsCommit = git(docsDir, ["rev-parse", "--short=7", "HEAD"]).trim() || null;
} catch {
// no history → null; blame below will report the real problem if there is one
}
const payload = core.buildPayload({
docsUrl: DOCS_URL,
docsCommit,
summary: fs.readFileSync(path.join(docsDir, "SUMMARY.md"), "utf-8"),
channels: {
platform: readChannel(docsDir, core.CHANNEL_FILES.platform),
agent: readChannel(docsDir, core.CHANNEL_FILES.agent),
},
});
writePayload(payload, `docs @ ${docsCommit ?? "unknown"}`);
}
main();
@@ -0,0 +1,375 @@
/**
* Pure functions behind scripts/whats-new/build-whats-new.js — no I/O, so the
* markdown → payload transformation is unit-tested in isolation.
*
* The two customer changelogs in the docs repo (changelog/platform-changelog.md,
* changelog/agent-changelog.md) are the ONLY authoritative source of "What's new".
* This module reads exactly the authoring format the `/changelog` skill produces —
* `## <Period>` blocks with `* ` bullets — and turns it into the JSON the portal
* panel renders. Nothing here adds meaning the markdown does not carry; the only
* derived facts are the per-bullet publish date (from `git blame`, supplied by the
* caller) and the absolute docs URL of every relative link.
*/
const SCHEMA_VERSION = 1;
/** Newest blocks per channel that make it into the payload. */
const MAX_PERIODS = 3;
/** Hard cap per channel so a busy quarter cannot balloon the file. */
const MAX_ENTRIES = 60;
const CHANNEL_FILES = {
platform: "changelog/platform-changelog.md",
agent: "changelog/agent-changelog.md",
};
// ── Markdown ─────────────────────────────────────────────────
/** CRLF/CR → LF. The docs repo is authored on Windows; `.` never matches `\r`. */
function normalizeNewlines(text) {
return text.replace(/\r\n?/g, "\n");
}
/**
* @typedef {{ text: string, line: number }} Bullet
* @typedef {{ period: string, bullets: Bullet[] }} Block
* @typedef {{ id: string, addedUtc: string, period: string, title: string | null, body: string, link: string | null }} Entry
* @typedef {{ docsUrl: string, entries: Entry[] }} ChannelData
*/
/**
* Parses one changelog file into `{ period, bullets: [{ text, line }] }[]` in file
* order (newest first, as the authoring convention keeps it). `line` is the 1-based
* line number of the bullet's first line in the ORIGINAL file — the key `git blame`
* dates are looked up by. Continuation lines (indented, not a new bullet) are folded
* into the bullet with a single space.
*
* Everything before the first `## ` heading (frontmatter, intro, badges) is skipped.
* @returns {Block[]}
*/
function parseChangelog(markdown) {
const lines = normalizeNewlines(markdown).split("\n");
/** @type {Block[]} */
const blocks = [];
/** @type {Block | null} */
let current = null;
/** @type {Bullet | null} */
let bullet = null;
const flushBullet = () => {
if (bullet && current) current.bullets.push(bullet);
bullet = null;
};
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
const heading = /^##\s+(.+?)\s*$/.exec(raw);
if (heading) {
flushBullet();
current = { period: heading[1], bullets: [] };
blocks.push(current);
continue;
}
if (!current) continue;
const item = /^\*\s+(.*)$/.exec(raw);
if (item) {
flushBullet();
bullet = { text: item[1].trim(), line: i + 1 };
continue;
}
if (bullet && /^\s+\S/.test(raw)) {
bullet.text += " " + raw.trim();
continue;
}
flushBullet();
}
flushBullet();
return blocks;
}
/**
* Platform bullets lead with a bold title and an em dash: `**Title** — Body`.
* Agent bullets are a single sentence. Returns `{ title, body }`, title null when
* the bullet has no bold lead.
*/
function splitTitle(text) {
const m = /^\*\*(.+?)\*\*\s*(?:—|–|-)\s*(.*)$/s.exec(text);
if (m) return { title: m[1].trim(), body: m[2].trim() };
return { title: null, body: text.trim() };
}
// ── GitBook URL map ──────────────────────────────────────────
/**
* GitBook publishes a page under `<group-slug>/<parent-slugs…>/<page-slug>`, where the
* group slug is the slugified `## ` heading of SUMMARY.md (not the folder name:
* `troubleshooting/` → `troubleshooting-and-support`), a page slug is its file name
* without `.md`, and a `README.md` takes its folder's name. `docsPaths.ts` in the
* portal pins the same shape.
*/
function slugifyHeading(heading) {
return heading
.toLowerCase()
.replace(/&/g, " and ")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function pageSlug(filePath) {
const parts = filePath.split("/");
const file = parts[parts.length - 1];
if (/^readme\.md$/i.test(file)) return parts.length >= 2 ? parts[parts.length - 2] : "";
return file.replace(/\.md$/i, "");
}
/**
* SUMMARY.md → Map<repo-relative file path, url path>. Root pages (before the first
* group) map to `/<slug>`, `README.md` to `/`. Nesting follows the list indentation:
* a deeper bullet is published under its parent's URL.
*/
function buildSummaryUrlMap(summaryMarkdown) {
const map = new Map();
let groupSlug = null;
// Slugs of the ancestors at each indentation depth for the current group.
const stack = [];
for (const raw of normalizeNewlines(summaryMarkdown).split("\n")) {
const heading = /^##\s+(.+?)\s*$/.exec(raw);
if (heading) {
groupSlug = slugifyHeading(heading[1]);
stack.length = 0;
continue;
}
const item = /^(\s*)\*\s+\[[^\]]*\]\(([^)]+)\)/.exec(raw);
if (!item) continue;
const depth = Math.floor(item[1].replace(/\t/g, " ").length / 2);
const file = item[2].trim();
const slug = pageSlug(file);
stack.length = depth;
stack[depth] = slug;
let url;
if (groupSlug === null) {
url = /^readme\.md$/i.test(file) ? "/" : `/${slug}`;
} else {
url = "/" + [groupSlug, ...stack.slice(0, depth + 1)].join("/");
}
map.set(file, url);
}
return map;
}
/** Folder-name → group-slug fallback for pages SUMMARY.md does not list. */
function buildFolderSlugMap(urlMap) {
const folders = new Map();
for (const [file, url] of urlMap) {
const slash = file.indexOf("/");
if (slash < 0) continue;
const folder = file.slice(0, slash);
const group = url.split("/")[1];
if (folder && group && !folders.has(folder)) folders.set(folder, group);
}
return folders;
}
/** Resolves `../a/b.md` against `changelog/` without touching the filesystem. */
function resolveRelative(fromDir, href) {
const parts = (fromDir ? fromDir.split("/") : []).filter(Boolean);
for (const seg of href.split("/")) {
if (seg === "..") parts.pop();
else if (seg && seg !== ".") parts.push(seg);
}
return parts.join("/");
}
/**
* Turns one markdown link target from a changelog bullet into an absolute URL, or
* null when the target is neither an https URL nor a known docs page. `#anchor`
* is carried over verbatim (GitBook heading slugs match the markdown anchors).
*/
function resolveLink(href, ctx) {
const trimmed = href.trim();
if (/^https:\/\//i.test(trimmed)) return trimmed;
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return null; // http:, mailto:, etc.
const hashIndex = trimmed.indexOf("#");
const pathPart = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
const anchor = hashIndex >= 0 ? trimmed.slice(hashIndex) : "";
if (!pathPart) return null; // same-page anchor — meaningless outside the docs page
const file = resolveRelative(ctx.fromDir, pathPart);
let urlPath = ctx.urlMap.get(file);
if (!urlPath) {
const slash = file.indexOf("/");
if (slash < 0) return null;
const group = ctx.folderSlugMap.get(file.slice(0, slash));
if (!group) return null;
const rest = file
.slice(slash + 1)
.replace(/\/?readme\.md$/i, "")
.replace(/\.md$/i, "");
urlPath = "/" + [group, rest].filter(Boolean).join("/");
}
return `${ctx.docsUrl}${urlPath}${anchor}`;
}
/**
* Rewrites every `[label](target)` in the bullet to an absolute URL; links that
* cannot be resolved lose their brackets and keep the label. Returns the rewritten
* text and the first resolved URL (the panel's "Read update" target).
*/
function rewriteLinks(text, ctx) {
let first = null;
const rewritten = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label, target) => {
const url = resolveLink(target, ctx);
if (!url) return label;
if (!first) first = url;
return `[${label}](${url})`;
});
return { text: rewritten, link: first };
}
// ── Blame ────────────────────────────────────────────────────
/**
* `git blame --line-porcelain` → Map<1-based line, epoch seconds (author-time)>.
* Uncommitted lines carry the all-zero hash and no usable time; they map to null.
*/
function parseBlamePorcelain(output) {
const map = new Map();
let line = null;
let time = null;
let uncommitted = false;
for (const raw of normalizeNewlines(output).split("\n")) {
const header = /^([0-9a-f]{40}) \d+ (\d+)(?: \d+)?$/.exec(raw);
if (header) {
line = Number(header[2]);
time = null;
uncommitted = /^0+$/.test(header[1]);
continue;
}
if (line === null) continue;
const at = /^author-time (\d+)$/.exec(raw);
if (at) {
time = Number(at[1]);
continue;
}
if (raw.startsWith("\t")) {
map.set(line, uncommitted ? null : time);
line = null;
}
}
return map;
}
// ── Payload ──────────────────────────────────────────────────
/** FNV-1a 32-bit over the normalized text — a stable React key across regenerations. */
function stableId(text) {
let h = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h.toString(16).padStart(8, "0");
}
function toIso(epochSeconds, fallbackIso) {
if (epochSeconds === null || epochSeconds === undefined) return fallbackIso;
return new Date(epochSeconds * 1000).toISOString();
}
/**
* Builds one channel's entry list from markdown + blame dates.
* `nowIso` dates bullets git has not committed yet (local runs on a dirty tree).
* @returns {Entry[]}
*/
function buildChannelEntries(markdown, blameByLine, ctx, nowIso) {
const blocks = parseChangelog(markdown).slice(0, MAX_PERIODS);
/** @type {Entry[]} */
const entries = [];
for (const block of blocks) {
for (const bullet of block.bullets) {
if (entries.length >= MAX_ENTRIES) return entries;
const { title, body } = splitTitle(bullet.text);
const { text, link } = rewriteLinks(body, ctx);
entries.push({
id: stableId(`${block.period}|${bullet.text}`),
addedUtc: toIso(blameByLine.get(bullet.line), nowIso),
period: block.period,
title,
body: text,
link,
});
}
}
return entries;
}
/**
* @param input.docsUrl e.g. "https://docs.autopilotmonitor.com"
* @param input.docsCommit short SHA of the docs checkout, or null
* @param input.summary SUMMARY.md text
* @param input.channels { platform: { markdown, blame }, agent: { markdown, blame } }
* where `blame` is the raw `git blame --line-porcelain` output
* @param input.now Date used for generatedUtc and uncommitted bullets
*/
function buildPayload(input) {
const now = input.now ?? new Date();
const nowIso = now.toISOString();
const urlMap = buildSummaryUrlMap(input.summary);
const ctx = {
docsUrl: input.docsUrl.replace(/\/+$/, ""),
fromDir: "changelog",
urlMap,
folderSlugMap: buildFolderSlugMap(urlMap),
};
/** @param {"platform" | "agent"} channel @returns {ChannelData} */
const buildChannel = channel => {
const source = input.channels[channel];
const file = CHANNEL_FILES[channel];
return {
docsUrl: `${ctx.docsUrl}${urlMap.get(file) ?? "/" + file.replace(/\.md$/i, "")}`,
entries: buildChannelEntries(source.markdown, parseBlamePorcelain(source.blame), ctx, nowIso),
};
};
return {
schemaVersion: SCHEMA_VERSION,
generatedUtc: nowIso,
docsCommit: input.docsCommit ?? null,
channels: { platform: buildChannel("platform"), agent: buildChannel("agent") },
};
}
/** The payload a build without a docs checkout ships: valid shape, no entries. */
function emptyPayload(docsUrl, now = new Date()) {
const base = docsUrl.replace(/\/+$/, "");
return {
schemaVersion: SCHEMA_VERSION,
generatedUtc: now.toISOString(),
docsCommit: null,
channels: {
platform: { docsUrl: `${base}/changelog/platform-changelog`, entries: [] },
agent: { docsUrl: `${base}/changelog/agent-changelog`, entries: [] },
},
};
}
module.exports = {
SCHEMA_VERSION,
CHANNEL_FILES,
normalizeNewlines,
parseChangelog,
splitTitle,
slugifyHeading,
buildSummaryUrlMap,
resolveLink,
rewriteLinks,
parseBlamePorcelain,
stableId,
buildPayload,
emptyPayload,
};
@@ -23,6 +23,7 @@
{ "route": "/roadmap", "redirect": "/", "statusCode": 302 },
{ "route": "/version.json", "headers": { "Cache-Control": "no-store" } },
{ "route": "/whats-new.json", "headers": { "Cache-Control": "public, max-age=300" } },
{ "route": "/sessions/index.txt", "rewrite": "/sessions/index.txt" },
{ "route": "/sessions/network-timeline/index.txt", "rewrite": "/sessions/network-timeline/index.txt" },
@@ -4210,6 +4210,24 @@
"kind": "primitive",
"name": "boolean"
}
},
{
"name": "whatsNewSeenPlatformUtc",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "When this user last viewed the Platform tab of What's new; null = never and the key is omitted (client treats as first visit)."
},
{
"name": "whatsNewSeenAgentUtc",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "Same for the Agent tab; null = never and the key is omitted."
}
]
},
@@ -23885,6 +23903,28 @@
}
}
]
},
"WhatsNewSeenRequest": {
"kind": "object",
"doc": "Body of PUT auth/me/whats-new-seen.",
"fields": [
{
"name": "channel",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "What's-new channel: \"platform\" or \"agent\"."
},
{
"name": "seenUtc",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "UTC timestamp when the caller viewed the channel."
}
]
}
}
}
@@ -758,6 +758,10 @@ export interface AuthMeResponse {
homedApp: string;
bootstrapTokenEnabled: boolean;
unrestrictedModeEnabled: boolean;
/** When this user last viewed the Platform tab of What's new; null = never and the key is omitted (client treats as first visit). */
whatsNewSeenPlatformUtc?: string;
/** Same for the Agent tab; null = never and the key is omitted. */
whatsNewSeenAgentUtc?: string;
}
export interface AutoResolveCpeMappingItem {
@@ -5248,3 +5252,11 @@ export interface VerdictCalibrationTrendWindow {
sessions: number;
sharePct: number;
}
/** Body of PUT auth/me/whats-new-seen. */
export interface WhatsNewSeenRequest {
/** What's-new channel: "platform" or "agent". */
channel: string;
/** UTC timestamp when the caller viewed the channel. */
seenUtc: string;
}