Fixed project list and recovery view to include unavailable projects

This commit is contained in:
Thorsten Sommer 2026-07-31 18:00:48 +02:00
parent b3bbf4deb2
commit aec8555b9a
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
8 changed files with 338 additions and 65 deletions

View File

@ -13,21 +13,26 @@
<MudList T="Guid"
Color="Color.Primary"
Class="mb-1"
SelectedValue="@(this.selectedBriefing?.BriefingId ?? Guid.Empty)"
SelectedValue="@(this.selectedProject?.BriefingId ?? Guid.Empty)"
SelectedValueChanged="@this.SelectBriefingAsync">
@foreach (var briefing in this.briefings)
@foreach (var project in this.projects)
{
<MudListItem T="Guid"
Value="@briefing.BriefingId"
Icon="@Icons.Material.Filled.Dashboard">
<MudListItem T="Guid" Value="@project.BriefingId" Icon="@(project.IsAvailable ? Icons.Material.Filled.Dashboard : Icons.Material.Filled.WarningAmber)">
<MudStack Spacing="0">
<MudText Typo="Typo.body1">@(briefing.BriefingId == this.selectedBriefing?.BriefingId ? this.projectName : briefing.Name)</MudText>
<MudText Typo="Typo.caption">@briefing.ModifiedAtUtc.ToLocalTime().ToString("g")</MudText>
@if (this.IsGenerating(briefing.BriefingId))
<MudText Typo="Typo.body1">@this.ProjectDisplayName(project)</MudText>
<MudText Typo="Typo.caption">@project.ModifiedAtUtc.ToLocalTime().ToString("g")</MudText>
@if (!project.IsAvailable)
{
<MudText Typo="Typo.caption" Color="Color.Error">@this.ProjectStatusName(project.Status)</MudText>
}
@if (project.IsAvailable && this.IsGenerating(project.BriefingId))
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mt-1"/>
}
<MediaTranscriptionStatus Owner="@MediaImportOwner.ForVisualBriefing(briefing.BriefingId)" Compact="true"/>
@if (project.IsAvailable)
{
<MediaTranscriptionStatus Owner="@MediaImportOwner.ForVisualBriefing(project.BriefingId)" Compact="true"/>
}
</MudStack>
</MudListItem>
}
@ -41,7 +46,31 @@
<MudDivider Style="height: 0.25ch; margin: 1rem 0;" Class="mt-6"/>
<main class="visual-briefing-main">
@if (this.selectedBriefing is null)
@if (this.selectedProject is not null && !this.selectedProject.IsAvailable)
{
<MudPaper Outlined="true" Class="pa-6">
<MudStack Spacing="3">
<MudText Typo="Typo.h4">@this.ProjectDisplayName(this.selectedProject)</MudText>
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined">
@this.ProjectRecoveryMessage(this.selectedProject.Status)
</MudAlert>
<MudText Typo="Typo.body1">@T("AI Studio has left the project files unchanged. A future update may make this visual briefing accessible again.")</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Wrap="Wrap.Wrap">
<MudText Typo="Typo.body2"><strong>@T("Project ID"):</strong> @this.selectedProject.BriefingId.ToString("D")</MudText>
<MudCopyClipboardButton TooltipMessage="@T("Copy project ID")" StringContent="@this.selectedProject.BriefingId.ToString("D")"/>
</MudStack>
<MudText Typo="Typo.body2">
@T("If you need help, report the problem and include the project ID.")
<MudLink Href="https://github.com/MindWorkAI/AI-Studio" Target="_blank">@T("Report a problem?")</MudLink>
</MudText>
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
<MudButton Variant="Variant.Filled" StartIcon="@Icons.Material.Filled.FolderOpen" OnClick="@this.OpenSelectedProjectDirectoryAsync">@T("Open project folder")</MudButton>
<MudButton Variant="Variant.Filled" StartIcon="@Icons.Material.Filled.DeleteForever" Color="Color.Error" OnClick="@this.DeleteAsync">@T("Delete")</MudButton>
</MudStack>
</MudStack>
</MudPaper>
}
else if (this.selectedBriefing is null)
{
<MudPaper Outlined="true" Class="pa-6">
<MudText Typo="Typo.h5">@T("Create or import a visual briefing to begin.")</MudText>

View File

@ -150,12 +150,7 @@ public partial class VisualBriefingAssistant
{
var latest = await this.Store.LoadAsync(briefingId, cancellation.Token);
if (latest is not null)
this.briefings =
[
.. this.briefings
.Select(briefing => briefing.BriefingId == briefingId ? latest : briefing)
.OrderByDescending(briefing => briefing.ModifiedAtUtc)
];
this.UpdateProject(latest);
}
this.Snackbar.Add(T("A new visual briefing version was created."), Severity.Success);
@ -250,12 +245,7 @@ public partial class VisualBriefingAssistant
{
var latest = await this.Store.LoadAsync(briefingId, cancellation.Token);
if (latest is not null)
this.briefings =
[
.. this.briefings
.Select(briefing => briefing.BriefingId == briefingId ? latest : briefing)
.OrderByDescending(briefing => briefing.ModifiedAtUtc)
];
this.UpdateProject(latest);
}
this.Snackbar.Add(T("The briefing was recompiled with the current AI Studio version."), Severity.Success);

View File

@ -5,6 +5,7 @@ using AIStudio.Dialogs;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Tools.Media;
using AIStudio.Tools.Rust;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
using ComponentKind = AIStudio.Tools.Components;
@ -24,19 +25,21 @@ public partial class VisualBriefingAssistant
/// </summary>
private async Task ReloadListAsync(Guid? selectId = null)
{
this.briefings = await this.Store.ListAsync();
this.projects = await this.Store.ListProjectsAsync();
var id = selectId ??
this.selectedBriefing?.BriefingId ??
this.selectedProject?.BriefingId ??
this.Store.LastSelectedBriefingId ??
this.briefings.FirstOrDefault()?.BriefingId;
this.projects.FirstOrDefault()?.BriefingId;
var selected = id is null
? null
: this.briefings.FirstOrDefault(briefing => briefing.BriefingId == id);
: this.projects.FirstOrDefault(project => project.BriefingId == id);
selected ??= this.briefings.FirstOrDefault();
selected ??= this.projects.FirstOrDefault();
if (selected is not null)
await this.ApplySelectedBriefingAsync(selected);
await this.ApplySelectedProjectAsync(selected);
else
this.ClearSelectedProject();
}
/// <summary>
@ -44,15 +47,15 @@ public partial class VisualBriefingAssistant
/// </summary>
private async Task SelectBriefingAsync(Guid briefingId)
{
if (this.selectedBriefing?.BriefingId == briefingId)
if (this.selectedProject?.BriefingId == briefingId)
return;
if (this.selectedBriefing is not null)
await this.SaveCurrentAsync();
var briefing = this.briefings.FirstOrDefault(candidate => candidate.BriefingId == briefingId);
if (briefing is not null)
await this.ApplySelectedBriefingAsync(briefing);
var project = this.projects.FirstOrDefault(candidate => candidate.BriefingId == briefingId);
if (project is not null)
await this.ApplySelectedProjectAsync(project);
}
/// <summary>
@ -116,29 +119,70 @@ public partial class VisualBriefingAssistant
/// </summary>
private async Task DeleteAsync()
{
if (this.selectedBriefing is null)
if (this.selectedProject is null)
return;
var parameters = new DialogParameters<ConfirmDialog>
var parameters = new DialogParameters<ConfirmDialog>();
if (this.selectedProject.IsAvailable)
parameters.Add(dialog => dialog.Message, string.Format(T("Permanently delete the visual briefing '{0}' and all of its versions and transcripts?"), this.selectedProject.Name));
else
{
{ dialog => dialog.Message, string.Format(T("Permanently delete the visual briefing '{0}' and all of its versions and transcripts?"), this.selectedBriefing.Name) },
};
var reportingWarning = T("This visual briefing cannot currently be opened. Consider reporting the problem in the [MindWork AI Studio issue tracker](https://github.com/MindWorkAI/AI-Studio), because a future update may make the briefing accessible again.");
var deletionWarning = T("Permanently delete this visual briefing and all of its versions and transcripts?");
parameters.Add(dialog => dialog.MarkdownBody, $"{reportingWarning}\n\n{deletionWarning}");
}
var reference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Delete visual briefing permanently"), parameters, DialogOptions.FULLSCREEN);
var result = await reference.Result;
if (result is null || result.Canceled)
return;
var id = this.selectedBriefing.BriefingId;
var id = this.selectedProject.BriefingId;
this.MediaTranscriptionService.ClearOwnerState(MediaImportOwner.ForVisualBriefing(id));
await this.Store.DeleteAsync(id);
await this.Store.ForgetSelectionAsync(id);
this.selectedBriefing = null;
this.previewUrl = string.Empty;
this.ClearSelectedProject();
await this.ReloadListAsync();
}
/// <summary>
/// Opens the selected project directory without attempting to read or repair its contents.
/// </summary>
private async Task OpenSelectedProjectDirectoryAsync()
{
if (this.selectedProject is null)
return;
var path = await this.Store.GetProjectDirectoryPathAsync(this.selectedProject.BriefingId);
if (string.IsNullOrWhiteSpace(path))
{
this.Snackbar.Add(T("The visual briefing project folder is not available."), Severity.Warning);
return;
}
OpenPathResponse response;
try
{
response = await this.RustService.TryOpenPathInRuntimeFileManager(path);
}
catch (Exception exception)
{
this.Logger.LogWarning(exception, "Could not open the visual briefing project folder. BriefingId={BriefingId}", this.selectedProject.BriefingId);
this.Snackbar.Add(T("Could not open the visual briefing project folder."), Severity.Error);
return;
}
if (response.Success)
{
this.Snackbar.Add(T("Opened the visual briefing project folder."), Severity.Success);
return;
}
var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue;
this.Snackbar.Add(string.Format(T("Could not open the visual briefing project folder: {0}"), issue), Severity.Error);
}
/// <summary>
/// Defines <c>SaveCurrentAsync</c> for the visual briefing feature.
/// </summary>
@ -188,6 +232,7 @@ public partial class VisualBriefingAssistant
private async Task ApplySelectedBriefingAsync(VisualBriefingManifest briefing)
{
await this.Store.RememberSelectionAsync(briefing.BriefingId);
this.selectedProject = VisualBriefingProjectEntry.FromManifest(briefing);
this.selectedBriefing = briefing;
var resumableBuilds = await this.Store.ListBuildsAsync(briefing.BriefingId);
var persistedDiagnostics = resumableBuilds.FirstOrDefault() is { } latestPersistedBuild
@ -252,6 +297,80 @@ public partial class VisualBriefingAssistant
this.lastPersistedState = this.BuildPersistenceFingerprint();
}
/// <summary>
/// Applies either a normal editor project or a content-free recovery entry.
/// </summary>
private async Task ApplySelectedProjectAsync(VisualBriefingProjectEntry project)
{
if (project.IsAvailable)
{
await this.ApplySelectedBriefingAsync(project.Manifest!);
return;
}
await this.Store.RememberSelectionAsync(project.BriefingId);
this.ClearSelectedProject();
this.selectedProject = project;
}
/// <summary>
/// Clears editor-only state so an unavailable project cannot trigger saves or background work.
/// </summary>
private void ClearSelectedProject()
{
this.selectedProject = null;
this.selectedBriefing = null;
this.sourceMaterial = [];
this.visualAssets = [];
this.selectedRevisionId = Guid.Empty;
this.previewUrl = string.Empty;
this.latestBuild = null;
this.lastBuildDiagnostics = null;
this.reusableContentBuildId = null;
this.lastPersistedState = string.Empty;
}
/// <summary>
/// Replaces an available list entry after a background operation updates its manifest.
/// </summary>
private void UpdateProject(VisualBriefingManifest briefing)
{
var updated = VisualBriefingProjectEntry.FromManifest(briefing);
this.projects = [.. this.projects.Select(project => project.BriefingId == briefing.BriefingId ? updated : project).OrderByDescending(project => project.ModifiedAtUtc)];
if (this.selectedProject?.BriefingId == briefing.BriefingId)
this.selectedProject = updated;
}
/// <summary>
/// Gets a safe list and recovery-view title.
/// </summary>
private string ProjectDisplayName(VisualBriefingProjectEntry project)
{
if (project.BriefingId == this.selectedBriefing?.BriefingId)
return this.projectName;
return string.IsNullOrWhiteSpace(project.Name) ? T("Unavailable visual briefing") : project.Name;
}
/// <summary>
/// Gets the concise project-list status.
/// </summary>
private string ProjectStatusName(VisualBriefingProjectLoadStatus status) => status switch
{
VisualBriefingProjectLoadStatus.NEWER_VERSION => T("Requires a newer AI Studio version"),
_ => T("Cannot be opened"),
};
/// <summary>
/// Gets the recovery explanation for an unavailable project.
/// </summary>
private string ProjectRecoveryMessage(VisualBriefingProjectLoadStatus status) => status switch
{
VisualBriefingProjectLoadStatus.NEWER_VERSION => T("This visual briefing was created by a newer AI Studio version and cannot be opened by this version."),
_ => T("AI Studio cannot read this visual briefing. Its files may be incompatible or damaged."),
};
/// <summary>
/// Defines <c>ProtectionLevelName</c> for the visual briefing feature.
/// </summary>

View File

@ -135,12 +135,7 @@ public partial class VisualBriefingAssistant
var latest = await this.Store.LoadAsync(briefingId);
if (latest is not null)
{
this.briefings =
[
.. this.briefings
.Select(briefing => briefing.BriefingId == briefingId ? latest : briefing)
.OrderByDescending(briefing => briefing.ModifiedAtUtc)
];
this.UpdateProject(latest);
if (this.selectedBriefing?.BriefingId == briefingId)
await this.ApplySelectedBriefingAsync(latest);

View File

@ -91,8 +91,11 @@ public partial class VisualBriefingAssistant : MSGComponentBase
/// <summary>Stops the background source-status monitor.</summary>
private readonly CancellationTokenSource sourceMonitorCancellation = new();
/// <summary>Stores projects ordered by most recent modification.</summary>
private IReadOnlyList<VisualBriefingManifest> briefings = [];
/// <summary>Stores available and recoverable projects ordered by most recent modification.</summary>
private IReadOnlyList<VisualBriefingProjectEntry> projects = [];
/// <summary>Stores the project entry currently selected in the list.</summary>
private VisualBriefingProjectEntry? selectedProject;
/// <summary>Stores the project currently displayed by the editor.</summary>
private VisualBriefingManifest? selectedBriefing;

View File

@ -0,0 +1,13 @@
namespace AIStudio.Assistants.VisualBriefing;
/// <summary>
/// Provides safe list metadata even when the persisted manifest cannot be deserialized.
/// </summary>
internal sealed record VisualBriefingProjectEntry(Guid BriefingId, string Name, DateTimeOffset ModifiedAtUtc, VisualBriefingProjectLoadStatus Status, VisualBriefingManifest? Manifest)
{
/// <summary>Gets whether the project can be opened normally.</summary>
public bool IsAvailable => this.Status is VisualBriefingProjectLoadStatus.AVAILABLE && this.Manifest is not null;
/// <summary>Creates an available project entry from a validated manifest.</summary>
public static VisualBriefingProjectEntry FromManifest(VisualBriefingManifest manifest) => new(manifest.BriefingId, manifest.Name, manifest.ModifiedAtUtc, VisualBriefingProjectLoadStatus.AVAILABLE, manifest);
}

View File

@ -0,0 +1,11 @@
namespace AIStudio.Assistants.VisualBriefing;
/// <summary>
/// Describes whether a persisted visual briefing can be opened by this AI Studio version.
/// </summary>
internal enum VisualBriefingProjectLoadStatus
{
AVAILABLE,
NEWER_VERSION,
UNAVAILABLE,
}

View File

@ -121,21 +121,142 @@ public sealed partial class VisualBriefingStore
/// Defines <c>ListAsync</c> for the visual briefing feature.
/// </summary>
public async Task<IReadOnlyList<VisualBriefingManifest>> ListAsync(CancellationToken token = default)
{
var projects = await this.ListProjectsAsync(token);
return [.. projects.Where(project => project.IsAvailable).Select(project => project.Manifest!)];
}
/// <summary>
/// Lists every project directory, including projects whose manifests cannot be opened.
/// </summary>
internal async Task<IReadOnlyList<VisualBriefingProjectEntry>> ListProjectsAsync(CancellationToken token = default)
{
await this.InitializeAsync(token);
List<VisualBriefingManifest> manifests = [];
List<VisualBriefingProjectEntry> projects = [];
foreach (var directory in Directory.EnumerateDirectories(this.RootDirectory))
{
token.ThrowIfCancellationRequested();
if (!Guid.TryParse(Path.GetFileName(directory), out var briefingId))
continue;
var manifest = await this.LoadAsync(briefingId, token);
if (manifest is not null)
manifests.Add(manifest);
projects.Add(await this.LoadProjectEntryAsync(briefingId, directory, token));
}
return manifests.OrderByDescending(manifest => manifest.ModifiedAtUtc).ToArray();
return projects.OrderByDescending(project => project.ModifiedAtUtc).ToArray();
}
/// <summary>
/// Gets the exact project directory without interpreting or modifying its contents.
/// </summary>
internal async Task<string?> GetProjectDirectoryPathAsync(Guid briefingId, CancellationToken token = default)
{
await this.InitializeAsync(token);
var path = this.BriefingDirectory(briefingId);
return Directory.Exists(path) ? path : null;
}
/// <summary>
/// Loads a normal manifest or returns a recovery entry with best-effort display metadata.
/// </summary>
private async Task<VisualBriefingProjectEntry> LoadProjectEntryAsync(Guid briefingId, string directory, CancellationToken token)
{
var path = this.ManifestPath(briefingId);
var modifiedAtUtc = ProjectModifiedAtUtc(path, directory);
if (!File.Exists(path))
return new(briefingId, string.Empty, modifiedAtUtc, VisualBriefingProjectLoadStatus.UNAVAILABLE, null);
string json;
try
{
json = await File.ReadAllTextAsync(path, token);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
this.LogUnavailableManifest(briefingId, exception);
return new(briefingId, string.Empty, modifiedAtUtc, VisualBriefingProjectLoadStatus.UNAVAILABLE, null);
}
try
{
var manifest = JsonSerializer.Deserialize<VisualBriefingManifest>(json, JSON_OPTIONS);
if (manifest is not null && IsValidManifest(manifest, briefingId))
{
RefreshSourceStatuses(manifest);
return VisualBriefingProjectEntry.FromManifest(manifest);
}
}
catch (JsonException exception)
{
this.LogUnavailableManifest(briefingId, exception);
}
var (name, persistedModifiedAtUtc, manifestVersion) = ReadProjectMetadata(json);
var status = manifestVersion is > VisualBriefingVersions.MANIFEST ? VisualBriefingProjectLoadStatus.NEWER_VERSION : VisualBriefingProjectLoadStatus.UNAVAILABLE;
return new(briefingId, name, persistedModifiedAtUtc ?? modifiedAtUtc, status, null);
}
/// <summary>
/// Reads only non-authoritative display metadata from an otherwise unusable manifest.
/// </summary>
private static (string Name, DateTimeOffset? ModifiedAtUtc, int? ManifestVersion) ReadProjectMetadata(string json)
{
try
{
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind is not JsonValueKind.Object)
return (string.Empty, null, null);
var root = document.RootElement;
var name = root.TryGetProperty("name", out var nameElement) && nameElement.ValueKind is JsonValueKind.String ? SanitizeProjectName(nameElement.GetString()) : string.Empty;
DateTimeOffset? modifiedAtUtc = root.TryGetProperty("modifiedAtUtc", out var modifiedElement) && modifiedElement.ValueKind is JsonValueKind.String &&
modifiedElement.TryGetDateTimeOffset(out var parsedModifiedAtUtc) ? parsedModifiedAtUtc : null;
int? manifestVersion = root.TryGetProperty("manifestVersion", out var versionElement) && versionElement.ValueKind is JsonValueKind.Number &&
versionElement.TryGetInt32(out var parsedManifestVersion) ? parsedManifestVersion : null;
return (name, modifiedAtUtc, manifestVersion);
}
catch (JsonException)
{
return (string.Empty, null, null);
}
}
/// <summary>
/// Removes control characters and bounds untrusted recovery-list text.
/// </summary>
private static string SanitizeProjectName(string? name)
{
if (string.IsNullOrWhiteSpace(name))
return string.Empty;
var sanitized = new string(name.Where(character => !char.IsControl(character)).ToArray()).Trim();
return sanitized.Length <= 200 ? sanitized : sanitized[..200];
}
/// <summary>
/// Gets a stable fallback timestamp from the manifest or project directory.
/// </summary>
private static DateTimeOffset ProjectModifiedAtUtc(string manifestPath, string directory)
{
try
{
var timestamp = File.Exists(manifestPath) ? File.GetLastWriteTimeUtc(manifestPath) : Directory.GetLastWriteTimeUtc(directory);
return new DateTimeOffset(timestamp);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return DateTimeOffset.UnixEpoch;
}
}
/// <summary>
/// Records why a manifest was exposed through the recovery lane.
/// </summary>
private void LogUnavailableManifest(Guid briefingId, Exception exception)
{
logger.LogWarning(new EventId((int)VisualBriefingLogEventId.STORE_REJECTED, nameof(VisualBriefingLogEventId.STORE_REJECTED)), exception,
"Could not load visual briefing manifest. BriefingId={BriefingId} ExceptionType={ExceptionType}", briefingId, exception.GetType().Name);
}
/// <summary>
@ -161,7 +282,7 @@ public sealed partial class VisualBriefingStore
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException)
{
logger.LogWarning(
new EventId((int)VisualBriefingLogEventId.STORE_REJECTED, VisualBriefingLogEventId.STORE_REJECTED.ToString()),
new EventId((int)VisualBriefingLogEventId.STORE_REJECTED, nameof(VisualBriefingLogEventId.STORE_REJECTED)),
exception,
"Could not load visual briefing manifest. BriefingId={BriefingId} ExceptionType={ExceptionType}",
briefingId,
@ -210,6 +331,7 @@ public sealed partial class VisualBriefingStore
ModifiedAtUtc = now,
Settings = settings,
};
await this.StoreManifestAtomicAsync(manifest, token);
return manifest;
}
@ -237,13 +359,7 @@ public sealed partial class VisualBriefingStore
/// <summary>
/// Defines <c>SaveProjectAsync</c> for the visual briefing feature.
/// </summary>
public async Task SaveProjectAsync(
Guid briefingId,
string name,
string author,
VisualBriefingLocalSettings settings,
IEnumerable<(string Path, VisualBriefingSourceKind Kind)> sources,
CancellationToken token = default)
public async Task SaveProjectAsync(Guid briefingId, string name, string author, VisualBriefingLocalSettings settings, IEnumerable<(string Path, VisualBriefingSourceKind Kind)> sources, CancellationToken token = default)
{
await this.MutateManifestAsync(briefingId, manifest =>
{
@ -309,16 +425,13 @@ public sealed partial class VisualBriefingStore
/// <summary>
/// Defines <c>LoadRequiredWithoutInitializeAsync</c> for the visual briefing feature.
/// </summary>
private async Task<VisualBriefingManifest> LoadRequiredWithoutInitializeAsync(
Guid briefingId,
CancellationToken token)
private async Task<VisualBriefingManifest> LoadRequiredWithoutInitializeAsync(Guid briefingId, CancellationToken token)
{
var path = this.ManifestPath(briefingId);
if (!File.Exists(path))
throw new FileNotFoundException("The visual briefing does not exist.", path);
return await this.LoadWithoutInitializeAsync(briefingId, token)
?? throw new InvalidDataException("The visual briefing manifest is invalid.");
return await this.LoadWithoutInitializeAsync(briefingId, token) ?? throw new InvalidDataException("The visual briefing manifest is invalid.");
}
/// <summary>