대화 목록을 코덱스형 1줄 카드로 정리하고 상태 배지를 추가

선택된 대화는 전체 배경과 테두리를 테마 리소스로 강조하고 제목·시간을 한 줄로 재배치했습니다.

실행 중 링, 미열람 완료 점, 좌측 목록 재클릭 시 이름 변경 진입 제거를 반영했고 ChatWindowSlashPolicyTests로 상태 표시 조건 회귀를 검증했습니다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_conversation_list_refresh\\ -p:IntermediateOutputPath=obj\\verify_conversation_list_refresh\\ / dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter ChatWindowSlashPolicyTests -p:OutputPath=bin\\verify_conversation_list_refresh_tests\\ -p:IntermediateOutputPath=obj\\verify_conversation_list_refresh_tests\
This commit is contained in:
2026-04-15 20:43:37 +09:00
parent 5f4a52929b
commit 3210440767
7 changed files with 201 additions and 100 deletions

View File

@@ -1,5 +1,13 @@
# AX Commander
- 업데이트: 2026-04-15 20:41 (KST)
- AX Agent 좌측 대화 목록을 Codex 스타일에 가깝게 1줄형 카드로 단순화했습니다. `src/AxCopilot/Views/ChatWindow.xaml``ConversationItemTemplate`는 제목과 시간을 한 줄에 배치하고, 선택된 항목은 전체 배경과 테두리가 현재 테마(`HintBackground`, `AccentColor`)를 따라 강조되도록 바뀌었습니다.
- `src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs`, `src/AxCopilot/ViewModels/ChatWindowViewModel.cs`, `src/AxCopilot/Views/ChatWindow.xaml.cs`를 통해 실행 중인 대화는 앞쪽 링 표시로, 백그라운드 완료 후 아직 열어보지 않은 대화는 테마색 완료 점으로 구분하도록 정리했습니다. 완료 점은 해당 대화를 열면 바로 사라집니다.
- 좌측 목록에서 선택된 대화를 다시 클릭했을 때 이름 편집으로 바로 들어가던 흐름은 제거했고, 우클릭 메뉴 기반 관리 흐름은 유지했습니다.
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_conversation_list_refresh\\ -p:IntermediateOutputPath=obj\\verify_conversation_list_refresh\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ChatWindowSlashPolicyTests" -p:OutputPath=bin\\verify_conversation_list_refresh_tests\\ -p:IntermediateOutputPath=obj\\verify_conversation_list_refresh_tests\\` 통과 59
- 업데이트: 2026-04-15 20:19 (KST)
- AX Agent 내부 설정의 `최대 에이전트 패스` 상한을 100에서 500으로 확장했습니다. `src/AxCopilot/ViewModels/SettingsViewModel.cs`, `src/AxCopilot/Views/SettingsWindow.xaml`, `src/AxCopilot/Views/ChatWindow.xaml`, `src/AxCopilot/Views/ChatWindow.OverlaySettingsPresentation.cs`, `src/AxCopilot/Views/AgentSettingsWindow.xaml.cs`를 함께 조정해 일반 설정창, Code 탭 오버레이, 별도 에이전트 설정창에서 모두 같은 1~500 범위를 사용하도록 맞췄습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_max_agent_iterations_500\\ -p:IntermediateOutputPath=obj\\verify_max_agent_iterations_500\\` 경고 0 / 오류 0

View File

@@ -1548,3 +1548,11 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫
- AX Agent 입력창 위 시간·토큰 표시가 라이브 진행 텍스트 높이에 끌려 올라가던 배치를 수정했습니다. 원인은 `src/AxCopilot/Views/ChatWindow.xaml`에서 `StreamMetricsLabel``PulseDotBar`와 같은 Grid를 공유하고 있어, 왼쪽 진행 상태가 여러 줄로 커질 때 라벨도 같은 행 중앙으로 끌려가던 점이었습니다.
- `StreamMetricsLabel`를 진행 상태 행에서 분리해 입력 영역 바로 앞에 독립 배치했습니다. 이제 `PulseDotBar`의 높이가 바뀌어도 시간·토큰 라벨은 입력창 바로 위 오른쪽에 붙어 있게 됩니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_stream_metrics_anchor\\ -p:IntermediateOutputPath=obj\\verify_stream_metrics_anchor\\` 경고 0 / 오류 0
업데이트: 2026-04-15 20:41 (KST)
- AX Agent 좌측 대화 목록을 Codex 스타일에 가깝게 1줄형 카드로 재구성했습니다. `src/AxCopilot/Views/ChatWindow.xaml``ConversationItemTemplate`를 제목/시간 1줄 구조로 바꾸고, 선택 상태는 얇은 좌측 바 대신 전체 배경 + 테두리 강조로 바꿔 현재 테마(`HintBackground`, `AccentColor`, `ItemHoverBackground`)를 그대로 따르도록 정리했습니다.
- `src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs`에 실행 링/미열람 완료 점 정책을 추가했습니다. 현재 탭의 실제 스트리밍 대화만 실행 중 심볼을 표시하고, 백그라운드 완료 후 아직 열어보지 않은 대화는 완료 점을 붙였다가 해당 대화를 열면 바로 지워지도록 `MarkConversationCompletionSeen(...)`, `ShouldShowConversationRunningIndicator(...)`, `ShouldShowConversationCompletionMarker(...)` 헬퍼를 넣었습니다.
- 좌측 대화 목록에서 같은 항목을 다시 클릭했을 때 바로 이름 편집으로 들어가던 흐름은 제거했습니다. 이름 변경은 더 이상 목록 직접 클릭으로 진입하지 않고, 우클릭 메뉴 기반 관리 흐름만 유지합니다.
- `src/AxCopilot/ViewModels/ChatWindowViewModel.cs``HasUnreadCompletion` 바인딩을 추가했고, `src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs`에 실행 링/완료 점 조건 회귀 테스트를 넣었습니다.
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_conversation_list_refresh\\ -p:IntermediateOutputPath=obj\\verify_conversation_list_refresh\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ChatWindowSlashPolicyTests" -p:OutputPath=bin\\verify_conversation_list_refresh_tests\\ -p:IntermediateOutputPath=obj\\verify_conversation_list_refresh_tests\\` 통과 59

View File

@@ -215,4 +215,41 @@ public class ChatWindowSlashPolicyTests
center.Should().Be(expectedCenter);
radius.Should().Be(expectedRadius);
}
[Theory]
[InlineData("conv-1", "conv-1", "run-1", "running", true)]
[InlineData("conv-1", "conv-2", "run-1", "running", false)]
[InlineData("conv-1", "conv-1", "", "running", false)]
[InlineData("conv-1", "conv-1", "run-1", "completed", false)]
[InlineData("conv-1", "conv-1", "run-1", "paused", false)]
public void ShouldShowConversationRunningIndicator_ShouldRequireActiveRun(
string conversationId,
string streamingConversationId,
string runId,
string status,
bool expected)
{
var method = typeof(ChatWindow).GetMethod(
"ShouldShowConversationRunningIndicator",
BindingFlags.NonPublic | BindingFlags.Static);
method.Should().NotBeNull();
var result = method!.Invoke(null, new object?[] { conversationId, streamingConversationId, runId, status });
result.Should().Be(expected);
}
[Fact]
public void ShouldShowConversationCompletionMarker_ShouldHideWhenAlreadySeenOrSelected()
{
var method = typeof(ChatWindow).GetMethod(
"ShouldShowConversationCompletionMarker",
BindingFlags.NonPublic | BindingFlags.Static);
method.Should().NotBeNull();
var completedAt = new DateTime(2026, 4, 15, 20, 30, 0, DateTimeKind.Local);
method!.Invoke(null, new object?[] { completedAt, null, false, false }).Should().Be(true);
method.Invoke(null, new object?[] { completedAt, completedAt, false, false }).Should().Be(false);
method.Invoke(null, new object?[] { completedAt, null, true, false }).Should().Be(false);
method.Invoke(null, new object?[] { completedAt, null, false, true }).Should().Be(false);
}
}

View File

@@ -260,6 +260,7 @@ public class ConversationItemViewModel : ViewModelBase
public int FailedAgentRunCount { get; init; }
public string LastAgentRunSummary { get; init; } = "";
public string WorkFolder { get; init; } = "";
public bool HasUnreadCompletion { get; init; }
// ── 그룹 ──
public string Group { get; init; } = "오늘";

View File

@@ -17,6 +17,7 @@ public partial class ChatWindow
{
private const int ConversationPageSize = 50;
private List<ConversationMeta>? _pendingConversations;
private readonly Dictionary<string, DateTime> _conversationCompletionSeenAt = new(StringComparer.OrdinalIgnoreCase);
// ── A-1: 이벤트 위임 필드 ──
/// <summary>현재 마우스가 올라가 있는 대화 항목 Border.</summary>
@@ -92,9 +93,11 @@ public partial class ChatWindow
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
SyncTabConversationIdsFromSession();
}
MarkConversationCompletionSeen(conv);
SaveLastConversations();
UpdateChatTitle();
RenderMessages();
RefreshConversationList();
RefreshDraftQueueUi();
}
}
@@ -108,12 +111,6 @@ public partial class ChatWindow
if (isSelected)
{
// 선택된 항목 클릭 → 이름 변경 모드
var titleBlock = FindConversationTitleBlock(id);
if (titleBlock != null)
{
var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
EnterTitleEditMode(titleBlock, id, titleColor);
}
return;
}
@@ -134,11 +131,13 @@ public partial class ChatWindow
SyncTabConversationIdsFromSession();
}
MarkConversationCompletionSeen(conv);
SaveLastConversations();
UpdateChatTitle();
ClearTranscriptElements(); // 이전 대화의 UI 요소 완전 제거
InvalidateTimelineCache(); // 타임라인 캐시 무효화 — 새 대화 데이터 반영 보장
RenderMessages();
RefreshConversationList();
EnsureEmptyStateConsistency(); // EmptyState 일관성 강제 검사
RefreshConversationList();
RefreshStreamingControlsForActiveTab();
@@ -162,6 +161,64 @@ public partial class ChatWindow
return null;
}
private void MarkConversationCompletionSeen(ChatConversation? conversation)
{
if (conversation == null || string.IsNullOrWhiteSpace(conversation.Id))
return;
var summary = _appState.GetConversationRunSummary(conversation.AgentRunHistory);
MarkConversationCompletionSeen(conversation.Id, summary.LastCompletedAt);
}
private void MarkConversationCompletionSeen(string conversationId, DateTime? lastCompletedAt)
{
if (string.IsNullOrWhiteSpace(conversationId) || !lastCompletedAt.HasValue)
return;
if (_conversationCompletionSeenAt.TryGetValue(conversationId, out var seenAt)
&& seenAt >= lastCompletedAt.Value)
{
return;
}
_conversationCompletionSeenAt[conversationId] = lastCompletedAt.Value;
}
private static bool ShouldShowConversationRunningIndicator(
string conversationId,
string? streamingConversationId,
string? runId,
string? runStatus)
{
if (string.IsNullOrWhiteSpace(conversationId)
|| string.IsNullOrWhiteSpace(streamingConversationId)
|| string.IsNullOrWhiteSpace(runId))
{
return false;
}
if (!string.Equals(conversationId, streamingConversationId, StringComparison.Ordinal))
return false;
return !string.Equals(runStatus, "completed", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(runStatus, "failed", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(runStatus, "paused", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(runStatus, "canceled", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(runStatus, "cancelled", StringComparison.OrdinalIgnoreCase);
}
private static bool ShouldShowConversationCompletionMarker(
DateTime? lastCompletedAt,
DateTime? lastSeenCompletedAt,
bool isSelected,
bool isRunning)
{
if (isSelected || isRunning || !lastCompletedAt.HasValue)
return false;
return !lastSeenCompletedAt.HasValue || lastCompletedAt.Value > lastSeenCompletedAt.Value;
}
private void ConversationPanel_DelegatedMouseMove(object sender, MouseEventArgs e)
{
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
@@ -230,11 +287,7 @@ public partial class ChatWindow
try
{
if (tag.IsSelected)
{
if (tag.TitleBlock != null && tag.TitleColor != null)
EnterTitleEditMode(tag.TitleBlock, tag.Id, tag.TitleColor);
return;
}
// 스트리밍 중이면 포괄적 정리 (ViewModel.IsStreaming, 글로우, 타이머 등 모두 리셋)
var conv = _storage.Load(tag.Id);
@@ -254,6 +307,7 @@ public partial class ChatWindow
SyncTabConversationIdsFromSession();
}
MarkConversationCompletionSeen(conv);
SaveLastConversations();
UpdateChatTitle();
RenderMessages();
@@ -281,9 +335,11 @@ public partial class ChatWindow
_currentConversation.ShowExecutionHistory = true;
SyncTabConversationIdsFromSession();
}
MarkConversationCompletionSeen(conv);
SaveLastConversations();
UpdateChatTitle();
RenderMessages();
RefreshConversationList();
RefreshDraftQueueUi();
}
}
@@ -301,6 +357,11 @@ public partial class ChatWindow
foreach (var p in allPresets)
presetMap.TryAdd(p.Category, (p.Symbol, p.Color));
var currentConversationId = "";
lock (_convLock)
currentConversationId = _currentConversation?.Id ?? "";
var streamingConversationId = GetStreamingConversation(_activeTab)?.Id;
var items = metas.Select(c =>
{
var symbol = ChatCategory.GetSymbol(c.Category);
@@ -315,6 +376,17 @@ public partial class ChatWindow
}
var runSummary = _appState.GetConversationRunSummary(c.AgentRunHistory);
var isSelectedConversation = string.Equals(currentConversationId, c.Id, StringComparison.Ordinal);
if (isSelectedConversation)
MarkConversationCompletionSeen(c.Id, runSummary.LastCompletedAt);
_conversationCompletionSeenAt.TryGetValue(c.Id, out var seenCompletedAt);
var isRunning = ShouldShowConversationRunningIndicator(
c.Id,
streamingConversationId,
_appState.AgentRun.RunId,
_appState.AgentRun.Status);
return new ConversationMeta
{
Id = c.Id,
@@ -333,12 +405,14 @@ public partial class ChatWindow
LastAgentRunSummary = runSummary.LastAgentRunSummary,
LastFailedAt = runSummary.LastFailedAt,
LastCompletedAt = runSummary.LastCompletedAt,
HasUnreadCompletion = ShouldShowConversationCompletionMarker(
runSummary.LastCompletedAt,
seenCompletedAt == default ? null : seenCompletedAt,
isSelectedConversation,
isRunning),
WorkFolder = c.WorkFolder ?? "",
Archived = c.Archived,
IsRunning = _currentConversation?.Id == c.Id
&& !string.IsNullOrWhiteSpace(_appState.AgentRun.RunId)
&& !string.Equals(_appState.AgentRun.Status, "completed", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(_appState.AgentRun.Status, "failed", StringComparison.OrdinalIgnoreCase),
IsRunning = isRunning,
};
}).ToList();
@@ -548,6 +622,7 @@ public partial class ChatWindow
FailedAgentRunCount = item.FailedAgentRunCount,
LastAgentRunSummary = item.LastAgentRunSummary,
WorkFolder = item.WorkFolder,
HasUnreadCompletion = item.HasUnreadCompletion,
Group = group,
GroupOrder = groupOrder,
};

View File

@@ -163,120 +163,91 @@
<!-- ── 대화 목록 항목 DataTemplate ── -->
<DataTemplate x:Key="ConversationItemTemplate" DataType="{x:Type vm:ConversationItemViewModel}">
<Border x:Name="ConvItemBorder"
CornerRadius="5"
Padding="7,4.5,7,4.5"
CornerRadius="10"
Padding="10,7"
Cursor="Hand"
Background="Transparent">
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="1">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Margin" Value="0,1,0,1"/>
<Setter Property="Margin" Value="0,2,0,2"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsBranch}" Value="True">
<Setter Property="Margin" Value="10,1,0,1"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsSelected}" Value="True">
<Setter Property="Background" Value="#104B5EFC"/>
<Setter Property="Background" Value="{DynamicResource HintBackground}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AccentColor}"/>
<Setter Property="BorderThickness" Value="1.25,0,0,0"/>
<Setter Property="BorderThickness" Value="1"/>
</DataTrigger>
<!-- 호버 효과 (선택 안 된 항목만) -->
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsSelected}" Value="False"/>
<Condition Binding="{Binding RelativeSource={RelativeSource Self}, Path=IsMouseOver}" Value="True"/>
</MultiDataTrigger.Conditions>
<Setter Property="Background" Value="#08FFFFFF"/>
<Setter Property="Background" Value="{DynamicResource ItemHoverBackground}"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Grid>
<Grid VerticalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="20"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 아이콘 -->
<TextBlock Grid.Column="0"
Text="{Binding IconText}"
FontFamily="Segoe MDL2 Assets"
<Grid Grid.Column="0"
Width="12"
Height="12"
Margin="0,0,8,0"
VerticalAlignment="Center">
<Ellipse x:Name="ConvRunningRing"
Visibility="Collapsed"
Stroke="{DynamicResource SecondaryText}"
StrokeThickness="1.4"
Fill="Transparent"/>
<Grid x:Name="ConvUnreadBadge"
Visibility="Collapsed">
<Ellipse Fill="{DynamicResource AccentColor}"/>
<Ellipse Width="4.2"
Height="4.2"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Margin="1.2,1.2,0,0"
Fill="{DynamicResource PrimaryText}"
Opacity="0.24"/>
</Grid>
</Grid>
<TextBlock x:Name="ConvTitleBlock"
Grid.Column="1"
Text="{Binding Title}"
FontSize="12.25"
Foreground="{DynamicResource PrimaryText}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
<TextBlock x:Name="ConvTimeBlock"
Grid.Column="2"
Text="{Binding UpdatedAtText}"
FontSize="10.5"
VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{Binding ColorHex, Converter={StaticResource HexToBrush}}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Pinned}" Value="True">
<Setter Property="Foreground" Value="Orange"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsBranch}" Value="True">
<Setter Property="Foreground" Value="#8B5CF6"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<!-- 제목 + 날짜 + 상태 -->
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock x:Name="ConvTitleBlock"
Text="{Binding Title}"
FontSize="11.75"
Foreground="{DynamicResource PrimaryText}"
TextTrimming="CharacterEllipsis">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding IsSelected}" Value="True">
<Setter Property="FontWeight" Value="SemiBold"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Text="{Binding UpdatedAtText}"
FontSize="9"
Foreground="{DynamicResource HintText}"
Margin="0,1.5,0,0"/>
<TextBlock Text="진행 중"
FontSize="8.8" FontWeight="Medium"
Foreground="#4F46E5"
Margin="0,1.5,0,0"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVis}}"/>
<TextBlock FontSize="8.9"
Margin="0,1.5,0,0"
TextTrimming="CharacterEllipsis"
Visibility="{Binding HasRunStatus, Converter={StaticResource BoolToVis}}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="{Binding RunStatusText}"/>
<Setter Property="Foreground" Value="{DynamicResource SecondaryText}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding HasFailed}" Value="True">
<Setter Property="Foreground" Value="#B91C1C"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
<!-- 카테고리 변경 버튼 (호버 시 표시) -->
<Button x:Name="ConvCatBtn" Grid.Column="2"
Background="Transparent" BorderThickness="0"
Cursor="Hand" Width="20" Height="20" Padding="0"
Opacity="0.72" Visibility="Collapsed"
VerticalAlignment="Center">
<TextBlock Text="&#xE70F;" FontFamily="Segoe MDL2 Assets" FontSize="9"
Foreground="{DynamicResource SecondaryText}"/>
</Button>
Foreground="{DynamicResource SecondaryText}"
Margin="10,0,0,0"
VerticalAlignment="Center"/>
</Grid>
</Border>
<DataTemplate.Triggers>
<!-- 호버 시 카테고리 버튼 표시 -->
<DataTrigger Binding="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType=Border}, Path=IsMouseOver}" Value="True">
<Setter TargetName="ConvCatBtn" Property="Visibility" Value="Visible"/>
<DataTrigger Binding="{Binding IsSelected}" Value="True">
<Setter TargetName="ConvTitleBlock" Property="FontWeight" Value="SemiBold"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsRunning}" Value="True">
<Setter TargetName="ConvRunningRing" Property="Visibility" Value="Visible"/>
<Setter TargetName="ConvRunningRing" Property="Stroke" Value="{DynamicResource AccentColor}"/>
</DataTrigger>
<DataTrigger Binding="{Binding HasUnreadCompletion}" Value="True">
<Setter TargetName="ConvUnreadBadge" Property="Visibility" Value="Visible"/>
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>

View File

@@ -222,6 +222,7 @@ public partial class ChatWindow : Window
public DateTime? LastFailedAt { get; init; }
public DateTime? LastCompletedAt { get; init; }
public bool IsRunning { get; init; }
public bool HasUnreadCompletion { get; init; }
public string WorkFolder { get; init; } = "";
public bool Archived { get; init; }
}