diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml
index 76fec90..e1b35a0 100644
--- a/Controls/LeftPanel.xaml
+++ b/Controls/LeftPanel.xaml
@@ -913,69 +913,97 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Controls/index.md b/Controls/index.md
index 70c12d3..610e378 100644
--- a/Controls/index.md
+++ b/Controls/index.md
@@ -19,7 +19,9 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
The Add Layer menu exposes one standalone recent-alert Ticker per scene. The
properties panel edits its independent `Ticker` settings; Stream Alerts retains
-its separate current-event ticker controls.
+its separate current-event ticker controls. Outside edit mode, the left panel
+offers Live Chat first and Logs second; Logs shows this process's newest AppLog
+entries first and excludes earlier file contents.
## Window ↔ control contract
diff --git a/HANDOFF.md b/HANDOFF.md
index 412e06d..b678930 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,27 +1,24 @@
-# HANDOFF — 2026-11-02 (scene-local alert ticker)
+# HANDOFF — 2026-11-02 (current-launch Logs tab)
## State
-- `main`; ticker work is local and not pushed.
-- Standalone `SourceType.Ticker` is addable once per scene, has per-source settings, and shows the
- in-memory current + queued alert feed. It does not persist event history.
-- Stream Alerts keeps one box per layout; its ticker shares `Ticker` settings but shows only the
- active event. Existing per-Source columns persist the settings without a schema migration.
-- Focused build succeeded; the new integration test and all `AlertTickerPreviewDisplayTests` pass.
+- `main`; scene-local ticker commit `20f0594` is local, not pushed.
+- Logs tab work is in progress: non-edit panel has Live Chat first/default and Logs second. Logs
+ reads only the process-local AppLog buffer (newest 500, newest first); file logging and rotation
+ remain unchanged, and prior-launch file contents are not loaded.
+- The Logs-panel integration test passes; clean app build is 0 warnings and scope check passes.
## Next action
-Next queued unit: Day 1 system-message strip; also check the queued Debug-only test-access rule.
-Keep one integration test per change. Do not push absent a milestone/checkpoint request.
+Run the declared-scope verification and commit the Logs-tab unit. Then resolve the outstanding
+UI/content test failures one issue at a time, with a separate local commit for each resolved task.
+Do not push absent a milestone/checkpoint request.
## Landmines
- Build/test with Windows `dotnet.exe`; the full suite can hang in WASAPI teardown.
-- Focused ticker tests pass; clean app build is 0 warnings. Full suite reports 13–14 UI/content
- failures (508/521 or 507/521 pass), matching the prior baseline note; none is a ticker test. Their
- relationship to this change is unconfirmed, and `verify.sh` currently only allows two other tests.
-- The integration assertion is in `ytLive.Tests/AlertTickerPreviewDisplayTests.cs` and must use
- `LayoutPathOverride`/clear it in `finally` if it constructs `MainWindow`.
-- Scene tickers are dynamic per-element sources; only the AlertBox ticker uses the pump's global
- ticker overlay callback.
+- Full suite after Logs changes: 13 failed / 509 passed of 522; the same UI/content failures remain
+ (none in Logs or ticker tests). They are the next queued work, not to be masked or added here.
+- Any integration test constructing `MainWindow` must set `LayoutPathOverride` to a temporary DB
+ and clear it in `finally`; AppLog path overrides are also process-wide and are serialized here.
- Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`).
diff --git a/Helpers/AppLog.cs b/Helpers/AppLog.cs
index 53e8a81..c60bd31 100644
--- a/Helpers/AppLog.cs
+++ b/Helpers/AppLog.cs
@@ -12,6 +12,7 @@ public static class AppLog
{
/// The cap for the live log file. Past this, it rolls to startup.1.log.
private const long DefaultMaxBytes = 5 * 1024 * 1024;
+ private const int CurrentLaunchEntryLimit = 500;
/// How many rolled copies to keep (startup.1.log, startup.2.log, …).
private const int RetainedFiles = 1;
@@ -20,6 +21,8 @@ public static class AppLog
InstanceProfile.DataRoot, "startup.log");
private static readonly object Gate = new();
+ private static readonly List CurrentLaunchEntries = new();
+ private static long _nextSequence;
/// Test seam: point the log somewhere else. Null = the real profile's log.
internal static string? PathOverride { get; set; }
@@ -29,16 +32,29 @@ public static class AppLog
private static string LogPath => PathOverride ?? DefaultLogPath;
+ internal static IReadOnlyList SnapshotCurrentLaunch()
+ {
+ lock (Gate)
+ return CurrentLaunchEntries.ToArray();
+ }
+
public static void Write(string message)
{
try
{
lock (Gate)
{
+ var entry = new AppLogEntry(
+ ++_nextSequence,
+ $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}");
+ CurrentLaunchEntries.Add(entry);
+ if (CurrentLaunchEntries.Count > CurrentLaunchEntryLimit)
+ CurrentLaunchEntries.RemoveAt(0);
+
var path = LogPath;
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
LogRotation.RollIfNeeded(path, MaxBytes, RetainedFiles);
- File.AppendAllText(path, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
+ File.AppendAllText(path, $"{entry.Text}{Environment.NewLine}");
}
}
catch
@@ -52,3 +68,5 @@ public static class AppLog
Write($"{message}: {exception}");
}
}
+
+public sealed record AppLogEntry(long Sequence, string Text);
diff --git a/Helpers/index.md b/Helpers/index.md
index 9fa3204..5fef211 100644
--- a/Helpers/index.md
+++ b/Helpers/index.md
@@ -6,7 +6,7 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
|------|---------|
| `ViewModelBase.cs` | `INotifyPropertyChanged` + `SetProperty()` — base for all ViewModels |
| `RelayCommand.cs` | `ICommand` implementation for all button actions |
-| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture (the crash this map is named for). Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55) |
+| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture. Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55). Also keeps the newest 500 formatted entries in a process-local buffer for the Logs tab; it never loads prior-launch file contents |
| `LogRotation.cs` | The size-capped-rotation policy as a pure function over a path: roll `startup.log` → `startup.1.log` when it passes the cap, keeping N rolled copies. Separate from `AppLog` so the logger's 156-call-site surface never changes and the rule is testable alone (TASK 55) |
| `FocusPreservingListBox.cs` | `ListBox` subclass that keeps selection/focus coherent when the selected item is deleted; `IsSelectable` predicate skips hidden scenes; drag-reorder guard |
| `OAuthCredentials.cs` | Baked-in Google OAuth client ID/secret (desktop app; loopback callback) |
diff --git a/ViewModels/MainViewModel.Logs.cs b/ViewModels/MainViewModel.Logs.cs
new file mode 100644
index 0000000..d91c1d7
--- /dev/null
+++ b/ViewModels/MainViewModel.Logs.cs
@@ -0,0 +1,95 @@
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Windows.Threading;
+using ytLive.Helpers;
+
+namespace ytLive.ViewModels;
+
+public partial class MainViewModel
+{
+ private const int VisibleLogEntryLimit = 500;
+ private readonly ObservableCollection _currentLaunchLogEntries = new();
+ private DispatcherTimer? _currentLaunchLogTimer;
+ private long _lastCurrentLaunchLogSequence;
+ private bool _isLogsTabSelected;
+
+ public ObservableCollection CurrentLaunchLogEntries => _currentLaunchLogEntries;
+
+ public bool IsLiveChatTabSelected
+ {
+ get => !_isLogsTabSelected;
+ set
+ {
+ if (value)
+ SelectLogsTab(false);
+ else if (!_isLogsTabSelected)
+ OnPropertyChanged(nameof(IsLiveChatTabSelected));
+ }
+ }
+
+ public bool IsLogsTabSelected
+ {
+ get => _isLogsTabSelected;
+ set
+ {
+ if (value)
+ SelectLogsTab(true);
+ else if (_isLogsTabSelected)
+ OnPropertyChanged(nameof(IsLogsTabSelected));
+ }
+ }
+
+ private void InitializeCurrentLaunchLogs()
+ {
+ foreach (var entry in AppLog.SnapshotCurrentLaunch()
+ .OrderByDescending(entry => entry.Sequence)
+ .Take(VisibleLogEntryLimit))
+ _currentLaunchLogEntries.Add(entry);
+
+ _lastCurrentLaunchLogSequence = _currentLaunchLogEntries.FirstOrDefault()?.Sequence ?? 0;
+ _currentLaunchLogTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
+ _currentLaunchLogTimer.Tick += (_, _) => RefreshCurrentLaunchLogs();
+ }
+
+ private void SelectLogsTab(bool selected)
+ {
+ if (_isLogsTabSelected == selected)
+ {
+ OnPropertyChanged(nameof(IsLiveChatTabSelected));
+ OnPropertyChanged(nameof(IsLogsTabSelected));
+ return;
+ }
+
+ _isLogsTabSelected = selected;
+ OnPropertyChanged(nameof(IsLiveChatTabSelected));
+ OnPropertyChanged(nameof(IsLogsTabSelected));
+ if (selected)
+ {
+ RefreshCurrentLaunchLogs();
+ _currentLaunchLogTimer?.Start();
+ }
+ else
+ {
+ _currentLaunchLogTimer?.Stop();
+ }
+ }
+
+ internal void RefreshCurrentLaunchLogs()
+ {
+ var newEntries = AppLog.SnapshotCurrentLaunch()
+ .Where(entry => entry.Sequence > _lastCurrentLaunchLogSequence)
+ .OrderBy(entry => entry.Sequence)
+ .ToArray();
+
+ foreach (var entry in newEntries)
+ {
+ _currentLaunchLogEntries.Insert(0, entry);
+ if (_currentLaunchLogEntries.Count > VisibleLogEntryLimit)
+ _currentLaunchLogEntries.RemoveAt(_currentLaunchLogEntries.Count - 1);
+ _lastCurrentLaunchLogSequence = entry.Sequence;
+ }
+ }
+
+ private void StopCurrentLaunchLogFeed()
+ => _currentLaunchLogTimer?.Stop();
+}
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index b576baa..9d63af4 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -192,6 +192,7 @@ public partial class MainViewModel : ViewModelBase
public MainViewModel()
{
AppLog.Write("MainViewModel ctor begin");
+ InitializeCurrentLaunchLogs();
IsAppOwned = _entitlement.IsAppOwned;
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret,
sessionChanged: ch => TokenStore.Save(ch));
@@ -524,6 +525,7 @@ public partial class MainViewModel : ViewModelBase
public void Shutdown()
{
+ StopCurrentLaunchLogFeed();
_saveDebounce?.Stop();
SaveLayoutNow();
_fullScreenDetector.StopWatching();
diff --git a/ViewModels/index.md b/ViewModels/index.md
index 8ba42f3..ae6f343 100644
--- a/ViewModels/index.md
+++ b/ViewModels/index.md
@@ -21,6 +21,7 @@ areas' docs in their own rows keeps this index small to read.
| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** mode switch `RecordPillOn`/`OnAirPillOn`, top-bar worlds (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`/`ShowSignInButton`/`ShowAvatarButton`, reality line `RunningVisible`/`RunningText`/`RunningDotBrush`, Test gate `ShowTestButton`/`CanStartTest` — `RaiseTopBarModes()`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master; **TASK 57:** the tier change also resolves every scene's camera placement (the vertical tier brings the vertical placements in, 16:9 restores the desktop layout — see `MainViewModel.Webcam.cs`). **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. **TASK 57 (Unit 2):** the tier change also raises `CanToggleClipShape` (the circle option disappears in the vertical Chat layout). |
| `MainViewModel.Overlays.cs` | Settings/bug/feature/about panels + creator-hub links: panel state `IsSettingsOpen`/`IsBugOpen`/`IsFeatureOpen`/`IsAboutOpen` (via `SetPanel` → `IsAnyOverlayOpen`), `IsLicensesOpen` (flips `IsHubVisible`), `OverlayTitle`, `ShowOverlay` (one-panel exclusivity), `ShowAbout` (returns to the About hub) + `ShowLicenses` (loads shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` in-app); bug/feature report `BugReportText`/`BugReportEmail`/`FeatureRequestText`/`FeatureRequestEmail` + `SubmitBug`/`SubmitFeature`/`ComposeEmail`/`OpenUrl`; About hub consts `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` + `PremiumUrl` (`https://llamacasty.com`, opens only when explicitly clicked) + `IsPremiumAvailable` + `SupportEmail` + `AppVersionLabel`; commands `OpenSettingsCommand`/`OpenBugCommand`/`OpenFeatureCommand`/`OpenAboutCommand`/`CloseOverlayCommand`/`SubmitBugCommand`/`SubmitFeatureCommand`/`OpenChannelCommand`/`OpenMastodonCommand`/`OpenCoffeeCommand`/`OpenPremiumCommand`/`OpenLicensesCommand`. A locked quality selection opens About without changing the tier. Ctor command assignments stay in core. |
| `MainViewModel.Chat.cs` | Chat overlay + preview pipeline: `ChatMessages` (500-message cap; clear-on-fade), `OnChatMessageReceived` (marshal `_youtubeChat.MessageReceived` onto the dispatcher, switch previews mock→real, then feed **both** component layers), fade `_chatFadeTimer`/`ResetChatFadeTimer` (shortest `ChatFadeSeconds` across chat layers); live render `RenderChatBox` (called from `ResolveOutputFrame`, stays in core) + preview render `UpdateChatBoxPreview` (bitmap onto `chatBox.VideoImageSource`); mock preview `MockChatMessages`/`StartMockChatPreview`/`StopMockChatPreview`/`RunMockChatPreviewAsync`/`_mockChatCts`; `UpdateActiveChatBoxPreviews` (wires Chat* property-change subscriptions + first render); `CanAddYouTubeChat` (one-per-layout gate), `ShowChatInactiveMessage` (`!IsLive`). **Alerts (TASK 43):** same facade for the native alert box — `_alertLayer` (`AlertOverlayLayer`), `RenderAlertBox`/`UpdateAlertBoxPreview`, `UpdateActiveAlertBoxPreviews`, `CanAddAlerts` (one alert zone per layout). Ctor wiring (`MessageReceived` hookup, `new ChatBoxRenderer()`, `new AlertRenderer()`, `LoadLayout` preview start, `_youtubeChat.Dispose` + `_alertLayer.Dispose`) stays in core. |
+| `MainViewModel.Logs.cs` | Non-edit left-panel tab state and the bounded current-launch log collection; refreshes the process-local AppLog snapshot while Logs is selected and stops its dispatcher timer on shutdown. |
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests |
| **About hub (TASK 11, in `MainViewModel.Overlays.cs`)** | the About overlay is the in-app creator hub: `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public consts, live) + `PremiumUrl` (tabled billing seam — TASK 12 → the greyed "Unlock Premium" button lights up when filled); `OpenLicensesCommand` → `ShowLicenses()` loads the shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` and sets `IsLicensesOpen` (in-app scrolling panel, never the OS viewer); `BackToAbout_Click` in `MainWindow.xaml.cs` flips it off. |
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md
index 17ef93a..5e2b862 100644
--- a/docs/ai/ui-surface.md
+++ b/docs/ai/ui-surface.md
@@ -103,7 +103,7 @@ The app is a **director's control surface**, not an editor. This replaces the OB
**Current layout (2026-10-26 redesign commits):** `MainWindow` has a top bar and health banner, then a two-column work area with a fixed 360px left column and preview column. Its top row holds stream readouts/resolution on the left and the five-scene strip above the preview; the lower row holds the left panel and central preview. The bottom bar is now only a height-preserving spacer.
-- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = YouTube chat. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target; `LiveScene` is the program output.
+- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = a two-tab panel with **Live Chat** first/default and **Logs** second. Logs is a read-only, newest-first view of up to 500 `AppLog` entries captured in this process; it does not load older `startup.log` contents or change file logging/rotation. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target; `LiveScene` is the program output.
- **Settings is a right-edge drawer, not a modal (2026-11-01):** the gear rail tab opens `Controls/SettingsDrawer` in the same animated-width stack as Stream Settings / YPP / Socials / TEST (`SettingsDrawerOpen`, exclusive with the other four). Categories are section headings with their controls indented under them — Zoom holds *Zoom view shape* and *Zoom box follows*. The app menu (Report Bug / Feature Request / About) hangs off a **right-click on the LlamaCasty wordmark**; left-click still opens About. The modal shell keeps only Bug / Feature / About, which is why it no longer needs a scroll viewer.
- **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand and account surfaces remain at the sides. Its **right edge aligns with the layers panel below** (`TopBar` column 0 is a fixed **352** = work-area margin 8 + 360px column − the bar's own 16px padding), so the button and the panel share an edge. The **settings gear moved to the preview rail** (2026-11-01): first tab, orange (`Brush.Attention`), gear icon only — left-click opens the app menu (Settings / Bug / Feature / About), right-click goes straight to App Settings.
- **Preview gestures (2026-11-01):** *edit mode* — left selects/drags/resizes, right opens the context menu, **Ctrl+right toggles the zoom box**. *Preview mode* — **left toggles the zoom box**, right opens the context menu; **Ctrl+right does nothing at all** (no chord while live or recording — one hand, always). ⚠️ **Both the Down and the Up event must be handled for a Ctrl+right-click**: WPF's `PopupControlService` opens a context menu on right-button-**up**, so suppressing only the down event leaves the menu appearing under the gesture (found 2026-11-01 in edit mode). Menus exist on the webcam layer and the backdrop; every other layer right-clicks through to the backdrop menu. Left-dragging with the zoom box open moves it, in both modes. **Ctrl-down closes it**: pressing Ctrl while the box is open dismisses it, so the same hand that opened it can put it away (`MainWindow_PreviewKeyDown`; the box cannot cancel itself on creation because Ctrl is already down before it exists). **Follow mode** is a Settings pill, default **off = Anchor**: with Follow on, dragging re-anchors the capture to the box centre (`ZoomFollowsBox`, persisted as the `Zoom.FollowBox` setting). **The gesture activates the box at the cursor**, not merely dismisses it: `ActivateZoom` no longer refuses when idle (it used to return unless `IsLive || IsRecording`, and `ToggleZoom` only ever dismissed — so the gesture did nothing at all outside a session until 2026-11-01). The social bar consumes its own click (toggle top ⇄ bottom), and the preview's tunnelling handler steps aside for it — otherwise the bar is unclickable in preview mode and drags the layer beneath it in edit mode.
diff --git a/ytLive.Tests/AppLogRotationTests.cs b/ytLive.Tests/AppLogRotationTests.cs
index 9230a7a..f7252b5 100644
--- a/ytLive.Tests/AppLogRotationTests.cs
+++ b/ytLive.Tests/AppLogRotationTests.cs
@@ -13,6 +13,7 @@ namespace ytLive.Tests;
/// startup.1.log, the live file starts fresh with the newest entry, and the SET of
/// files stays bounded — a long stream cannot grow the log without limit. (The old
/// behaviour appended forever; the file was 23 MB and growing every few seconds.)
+[Collection("RealApp")]
public class AppLogRotationTests
{
[Fact]
diff --git a/ytLive.Tests/LogsPanelTests.cs b/ytLive.Tests/LogsPanelTests.cs
new file mode 100644
index 0000000..8babbb5
--- /dev/null
+++ b/ytLive.Tests/LogsPanelTests.cs
@@ -0,0 +1,84 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using Microsoft.Data.Sqlite;
+using Xunit;
+using ytLive.Helpers;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+[Collection("RealApp")]
+public sealed class LogsPanelTests
+{
+ private readonly RealAppHost _app;
+
+ public LogsPanelTests(RealAppHost app) => _app = app;
+
+ [Fact]
+ public void NonEditPanel_DefaultsToLiveChat_AndShowsCurrentLaunchLogsNewestFirst()
+ {
+ var directory = Path.Combine(Path.GetTempPath(), $"ytlive-logs-panel-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(directory);
+ var databasePath = Path.Combine(directory, "layout.db");
+ var logPath = Path.Combine(directory, "startup.log");
+ File.WriteAllText(logPath, "previous-launch-sentinel" + Environment.NewLine);
+ AppLog.PathOverride = logPath;
+ MainViewModel.LayoutPathOverride = databasePath;
+
+ try
+ {
+ _app.Run(() =>
+ {
+ var window = new MainWindow();
+ try
+ {
+ window.Show();
+ window.UpdateLayout();
+ var vm = (MainViewModel)window.DataContext;
+ var panel = (UserControl)window.FindName("LeftPanel")!;
+ var chatTab = (ToggleButton)panel.FindName("LiveChatTabButton")!;
+ var logsTab = (ToggleButton)panel.FindName("LogsTabButton")!;
+ var logList = (ListBox)panel.FindName("LogListBox")!;
+
+ Assert.True(vm.IsLiveChatTabSelected);
+ Assert.True(chatTab.IsChecked);
+ Assert.False(logsTab.IsChecked);
+ Assert.Equal(System.Windows.Visibility.Collapsed, logList.Visibility);
+
+ vm.IsLogsTabSelected = true;
+ window.UpdateLayout();
+ Assert.True(logsTab.IsChecked);
+ Assert.False(chatTab.IsChecked);
+ Assert.Equal(System.Windows.Visibility.Visible, logList.Visibility);
+
+ AppLog.Write("LogsPanel older marker");
+ AppLog.Write("LogsPanel newer marker");
+ vm.RefreshCurrentLaunchLogs();
+ window.UpdateLayout();
+
+ var entries = vm.CurrentLaunchLogEntries.Select(entry => entry.Text).ToArray();
+ var newerIndex = Array.FindIndex(entries, text => text.Contains("LogsPanel newer marker", StringComparison.Ordinal));
+ var olderIndex = Array.FindIndex(entries, text => text.Contains("LogsPanel older marker", StringComparison.Ordinal));
+ Assert.True(newerIndex >= 0 && olderIndex >= 0, "new log writes did not reach the Logs tab");
+ Assert.True(newerIndex < olderIndex, "the Logs tab must put the newest entry first");
+ Assert.DoesNotContain(entries, text => text.Contains("previous-launch-sentinel", StringComparison.Ordinal));
+ Assert.Same(vm.CurrentLaunchLogEntries, logList.ItemsSource);
+ }
+ finally
+ {
+ window.Close();
+ }
+ });
+ }
+ finally
+ {
+ MainViewModel.LayoutPathOverride = null;
+ AppLog.PathOverride = null;
+ SqliteConnection.ClearAllPools();
+ try { Directory.Delete(directory, recursive: true); } catch { }
+ }
+ }
+}