From b73ebe07dd5754fc32e8c8bd672364d444505bd7 Mon Sep 17 00:00:00 2001
From: Dean Sheather <dean@deansheather.com>
Date: Wed, 19 Mar 2025 21:09:42 +1100
Subject: [PATCH 1/5] feat: add mock UI for file syncing listing

---
 App/App.csproj                                |   1 +
 App/App.xaml                                  |   8 +-
 App/Controls/SizedFrame.cs                    |   5 +-
 App/Converters/AgentStatusToColorConverter.cs |  33 --
 App/Converters/DependencyObjectSelector.cs    | 155 +++++++++
 App/Converters/FriendlyByteConverter.cs       |  43 +++
 App/Converters/InverseBoolConverter.cs        |  17 +
 .../InverseBoolToVisibilityConverter.cs       |  12 +
 App/Models/MutagenSessionModel.cs             | 310 ++++++++++++++++++
 App/ViewModels/FileSyncListViewModel.cs       | 188 +++++++++++
 App/ViewModels/TrayWindowViewModel.cs         |  21 +-
 App/Views/FileSyncListWindow.xaml             |  20 ++
 App/Views/FileSyncListWindow.xaml.cs          |  33 ++
 App/Views/Pages/FileSyncListMainPage.xaml     | 269 +++++++++++++++
 App/Views/Pages/FileSyncListMainPage.xaml.cs  |  40 +++
 App/Views/Pages/TrayWindowMainPage.xaml       |  35 +-
 App/packages.lock.json                        |   9 +
 17 files changed, 1155 insertions(+), 44 deletions(-)
 delete mode 100644 App/Converters/AgentStatusToColorConverter.cs
 create mode 100644 App/Converters/DependencyObjectSelector.cs
 create mode 100644 App/Converters/FriendlyByteConverter.cs
 create mode 100644 App/Converters/InverseBoolConverter.cs
 create mode 100644 App/Converters/InverseBoolToVisibilityConverter.cs
 create mode 100644 App/Models/MutagenSessionModel.cs
 create mode 100644 App/ViewModels/FileSyncListViewModel.cs
 create mode 100644 App/Views/FileSyncListWindow.xaml
 create mode 100644 App/Views/FileSyncListWindow.xaml.cs
 create mode 100644 App/Views/Pages/FileSyncListMainPage.xaml
 create mode 100644 App/Views/Pages/FileSyncListMainPage.xaml.cs

diff --git a/App/App.csproj b/App/App.csproj
index 8b7e810..2a15166 100644
--- a/App/App.csproj
+++ b/App/App.csproj
@@ -65,6 +65,7 @@
         <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.1" />
         <PackageReference Include="Microsoft.Extensions.Options" Version="9.0.1" />
         <PackageReference Include="Microsoft.WindowsAppSDK" Version="1.6.250108002" />
+        <PackageReference Include="WinUIEx" Version="2.5.1" />
     </ItemGroup>
 
     <ItemGroup>
diff --git a/App/App.xaml b/App/App.xaml
index a5b6d8b..c614e0e 100644
--- a/App/App.xaml
+++ b/App/App.xaml
@@ -3,12 +3,18 @@
 <Application
     x:Class="Coder.Desktop.App.App"
     xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
-    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
+    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+    xmlns:converters="using:Coder.Desktop.App.Converters">
     <Application.Resources>
         <ResourceDictionary>
             <ResourceDictionary.MergedDictionaries>
                 <XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
             </ResourceDictionary.MergedDictionaries>
+
+            <converters:InverseBoolConverter x:Key="InverseBoolConverter" />
+            <converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
+            <converters:InverseBoolToVisibilityConverter x:Key="InverseBoolToVisibilityConverter" />
+            <converters:FriendlyByteConverter x:Key="FriendlyByteConverter" />
         </ResourceDictionary>
     </Application.Resources>
 </Application>
diff --git a/App/Controls/SizedFrame.cs b/App/Controls/SizedFrame.cs
index a666c55..bd2462b 100644
--- a/App/Controls/SizedFrame.cs
+++ b/App/Controls/SizedFrame.cs
@@ -12,9 +12,8 @@ public class SizedFrameEventArgs : EventArgs
 
 /// <summary>
 ///     SizedFrame extends Frame by adding a SizeChanged event, which will be triggered when:
-///       - The contained Page's content's size changes
-///       - We switch to a different page.
-///
+///     - The contained Page's content's size changes
+///     - We switch to a different page.
 ///     Sadly this is necessary because Window.Content.SizeChanged doesn't trigger when the Page's content changes.
 /// </summary>
 public class SizedFrame : Frame
diff --git a/App/Converters/AgentStatusToColorConverter.cs b/App/Converters/AgentStatusToColorConverter.cs
deleted file mode 100644
index ebcabdd..0000000
--- a/App/Converters/AgentStatusToColorConverter.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using System;
-using Windows.UI;
-using Coder.Desktop.App.ViewModels;
-using Microsoft.UI.Xaml.Data;
-using Microsoft.UI.Xaml.Media;
-
-namespace Coder.Desktop.App.Converters;
-
-public class AgentStatusToColorConverter : IValueConverter
-{
-    private static readonly SolidColorBrush Green = new(Color.FromArgb(255, 52, 199, 89));
-    private static readonly SolidColorBrush Yellow = new(Color.FromArgb(255, 255, 204, 1));
-    private static readonly SolidColorBrush Red = new(Color.FromArgb(255, 255, 59, 48));
-    private static readonly SolidColorBrush Gray = new(Color.FromArgb(255, 142, 142, 147));
-
-    public object Convert(object value, Type targetType, object parameter, string language)
-    {
-        if (value is not AgentConnectionStatus status) return Gray;
-
-        return status switch
-        {
-            AgentConnectionStatus.Green => Green,
-            AgentConnectionStatus.Yellow => Yellow,
-            AgentConnectionStatus.Red => Red,
-            _ => Gray,
-        };
-    }
-
-    public object ConvertBack(object value, Type targetType, object parameter, string language)
-    {
-        throw new NotImplementedException();
-    }
-}
diff --git a/App/Converters/DependencyObjectSelector.cs b/App/Converters/DependencyObjectSelector.cs
new file mode 100644
index 0000000..740c7a6
--- /dev/null
+++ b/App/Converters/DependencyObjectSelector.cs
@@ -0,0 +1,155 @@
+using System;
+using System.Linq;
+using Windows.Foundation.Collections;
+using Windows.UI.Xaml.Markup;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Data;
+using Microsoft.UI.Xaml.Media;
+
+namespace Coder.Desktop.App.Converters;
+
+// This file uses manual DependencyProperty properties rather than
+// DependencyPropertyGenerator since it doesn't seem to work properly with
+// generics.
+
+public class DependencyObjectSelectorItem<TK, TV> : DependencyObject
+    where TK : IEquatable<TK>
+{
+    public static readonly DependencyProperty KeyProperty =
+        DependencyProperty.Register(nameof(Key),
+            typeof(TK?),
+            typeof(DependencyObjectSelectorItem<TK, TV>),
+            new PropertyMetadata(null));
+
+    public static readonly DependencyProperty ValueProperty =
+        DependencyProperty.Register(nameof(Value),
+            typeof(TV?),
+            typeof(DependencyObjectSelectorItem<TK, TV>),
+            new PropertyMetadata(null));
+
+    public TK? Key
+    {
+        get => (TK?)GetValue(KeyProperty);
+        set => SetValue(KeyProperty, value);
+    }
+
+    public TV? Value
+    {
+        get => (TV?)GetValue(ValueProperty);
+        set => SetValue(ValueProperty, value);
+    }
+}
+
+[ContentProperty(Name = nameof(References))]
+public class DependencyObjectSelector<TK, TV> : DependencyObject
+    where TK : IEquatable<TK>
+{
+    public static readonly DependencyProperty ReferencesProperty =
+        DependencyProperty.Register(nameof(References),
+            typeof(DependencyObjectCollection),
+            typeof(DependencyObjectSelector<TK, TV>),
+            new PropertyMetadata(null, ReferencesPropertyChanged));
+
+    public static readonly DependencyProperty SelectedKeyProperty =
+        DependencyProperty.Register(nameof(SelectedKey),
+            typeof(TK?),
+            typeof(DependencyObjectSelector<TK, TV>),
+            new PropertyMetadata(null, SelectedPropertyChanged));
+
+    public static readonly DependencyProperty SelectedObjectProperty =
+        DependencyProperty.Register(nameof(SelectedObject),
+            typeof(TV?),
+            typeof(DependencyObjectSelector<TK, TV>),
+            new PropertyMetadata(null));
+
+    public DependencyObjectCollection? References
+    {
+        get => (DependencyObjectCollection?)GetValue(ReferencesProperty);
+        set
+        {
+            // Ensure unique keys and that the values are DependencyObjectSelectorItem<K, V>.
+            if (value != null)
+            {
+                var items = value.OfType<DependencyObjectSelectorItem<TK, TV>>().ToArray();
+                var keys = items.Select(i => i.Key).Distinct().ToArray();
+                if (keys.Length != value.Count)
+                    throw new ArgumentException("ObservableCollection Keys must be unique.");
+            }
+
+            SetValue(ReferencesProperty, value);
+        }
+    }
+
+    public TK? SelectedKey
+    {
+        get => (TK?)GetValue(SelectedKeyProperty);
+        set => SetValue(SelectedKeyProperty, value);
+    }
+
+    public TV? SelectedObject
+    {
+        get => (TV?)GetValue(SelectedObjectProperty);
+        set => SetValue(SelectedObjectProperty, value);
+    }
+
+    public DependencyObjectSelector()
+    {
+        References = [];
+    }
+
+    private void OnVectorChangedReferences(IObservableVector<DependencyObject> sender, IVectorChangedEventArgs args)
+    {
+        UpdateSelectedObject();
+    }
+
+    private void UpdateSelectedObject()
+    {
+        if (References != null)
+        {
+            var references = References.OfType<DependencyObjectSelectorItem<TK, TV>>().ToArray();
+            var item = references
+                           .FirstOrDefault(i =>
+                               (i.Key == null && SelectedKey == null) ||
+                               (i.Key != null && SelectedKey != null && i.Key!.Equals(SelectedKey!)))
+                       ?? references.FirstOrDefault(i => i.Key == null);
+            if (item is not null)
+            {
+                BindingOperations.SetBinding
+                (
+                    this,
+                    SelectedObjectProperty,
+                    new Binding
+                    {
+                        Source = item,
+                        Path = new PropertyPath(nameof(DependencyObjectSelectorItem<TK, TV>.Value)),
+                    }
+                );
+                return;
+            }
+        }
+
+        ClearValue(SelectedObjectProperty);
+    }
+
+    private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
+    {
+        var self = obj as DependencyObjectSelector<TK, TV>;
+        if (self == null) return;
+        var oldValue = args.OldValue as DependencyObjectCollection;
+        if (oldValue != null)
+            oldValue.VectorChanged -= self.OnVectorChangedReferences;
+        var newValue = args.NewValue as DependencyObjectCollection;
+        if (newValue != null)
+            newValue.VectorChanged += self.OnVectorChangedReferences;
+    }
+
+    private static void SelectedPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
+    {
+        var self = obj as DependencyObjectSelector<TK, TV>;
+        self?.UpdateSelectedObject();
+    }
+}
+
+public sealed class StringToBrushSelectorItem : DependencyObjectSelectorItem<string, Brush>;
+
+public sealed class StringToBrushSelector : DependencyObjectSelector<string, Brush>;
diff --git a/App/Converters/FriendlyByteConverter.cs b/App/Converters/FriendlyByteConverter.cs
new file mode 100644
index 0000000..c2bce4e
--- /dev/null
+++ b/App/Converters/FriendlyByteConverter.cs
@@ -0,0 +1,43 @@
+using System;
+using Microsoft.UI.Xaml.Data;
+
+namespace Coder.Desktop.App.Converters;
+
+public class FriendlyByteConverter : IValueConverter
+{
+    private static readonly string[] Suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
+
+    public object Convert(object value, Type targetType, object parameter, string language)
+    {
+        switch (value)
+        {
+            case int i:
+                if (i < 0) i = 0;
+                return FriendlyBytes((ulong)i);
+            case uint ui:
+                return FriendlyBytes(ui);
+            case long l:
+                if (l < 0) l = 0;
+                return FriendlyBytes((ulong)l);
+            case ulong ul:
+                return FriendlyBytes(ul);
+            default:
+                return FriendlyBytes(0);
+        }
+    }
+
+    public object ConvertBack(object value, Type targetType, object parameter, string language)
+    {
+        throw new NotImplementedException();
+    }
+
+    public static string FriendlyBytes(ulong bytes)
+    {
+        if (bytes == 0)
+            return $"0 {Suffixes[0]}";
+
+        var place = System.Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024)));
+        var num = Math.Round(bytes / Math.Pow(1024, place), 1);
+        return $"{num} {Suffixes[place]}";
+    }
+}
diff --git a/App/Converters/InverseBoolConverter.cs b/App/Converters/InverseBoolConverter.cs
new file mode 100644
index 0000000..927b420
--- /dev/null
+++ b/App/Converters/InverseBoolConverter.cs
@@ -0,0 +1,17 @@
+using System;
+using Microsoft.UI.Xaml.Data;
+
+namespace Coder.Desktop.App.Converters;
+
+public class InverseBoolConverter : IValueConverter
+{
+    public object Convert(object value, Type targetType, object parameter, string language)
+    {
+        return value is false;
+    }
+
+    public object ConvertBack(object value, Type targetType, object parameter, string language)
+    {
+        throw new NotImplementedException();
+    }
+}
diff --git a/App/Converters/InverseBoolToVisibilityConverter.cs b/App/Converters/InverseBoolToVisibilityConverter.cs
new file mode 100644
index 0000000..dd9c864
--- /dev/null
+++ b/App/Converters/InverseBoolToVisibilityConverter.cs
@@ -0,0 +1,12 @@
+using Microsoft.UI.Xaml;
+
+namespace Coder.Desktop.App.Converters;
+
+public partial class InverseBoolToVisibilityConverter : BoolToObjectConverter
+{
+    public InverseBoolToVisibilityConverter()
+    {
+        TrueValue = Visibility.Collapsed;
+        FalseValue = Visibility.Visible;
+    }
+}
diff --git a/App/Models/MutagenSessionModel.cs b/App/Models/MutagenSessionModel.cs
new file mode 100644
index 0000000..5e1dc37
--- /dev/null
+++ b/App/Models/MutagenSessionModel.cs
@@ -0,0 +1,310 @@
+using System;
+using Coder.Desktop.App.Converters;
+using Coder.Desktop.MutagenSdk.Proto.Synchronization;
+using Coder.Desktop.MutagenSdk.Proto.Url;
+
+namespace Coder.Desktop.App.Models;
+
+// This is a much slimmer enum than the original enum from Mutagen and only
+// contains the overarching states that we care about from a code perspective.
+// We still store the original state in the model for rendering purposes.
+public enum MutagenSessionStatus
+{
+    Unknown,
+    Paused,
+    Error,
+    NeedsAttention,
+    Working,
+    Ok,
+}
+
+public sealed class MutagenSessionModelEndpointSize
+{
+    public ulong SizeBytes { get; init; }
+    public ulong FileCount { get; init; }
+    public ulong DirCount { get; init; }
+    public ulong SymlinkCount { get; init; }
+
+    public string Description(string linePrefix)
+    {
+        var str =
+            $"{linePrefix}{FriendlyByteConverter.FriendlyBytes(SizeBytes)}\n" +
+            $"{linePrefix}{FileCount:N0} files\n" +
+            $"{linePrefix}{DirCount:N0} directories";
+        if (SymlinkCount > 0) str += $"\n{linePrefix} {SymlinkCount:N0} symlinks";
+
+        return str;
+    }
+
+    public bool Equals(MutagenSessionModelEndpointSize other)
+    {
+        return SizeBytes == other.SizeBytes &&
+               FileCount == other.FileCount &&
+               DirCount == other.DirCount &&
+               SymlinkCount == other.SymlinkCount;
+    }
+}
+
+public class MutagenSessionModel
+{
+    public readonly string Identifier;
+    public readonly string Name;
+
+    public readonly string LocalPath = "Unknown";
+    public readonly string RemoteName = "unknown";
+    public readonly string RemotePath = "Unknown";
+
+    public readonly MutagenSessionStatus Status;
+    public readonly string StatusString;
+    public readonly string StatusDescription;
+
+    public readonly MutagenSessionModelEndpointSize MaxSize;
+    public readonly MutagenSessionModelEndpointSize LocalSize;
+    public readonly MutagenSessionModelEndpointSize RemoteSize;
+
+    public readonly string[] Errors = [];
+
+    public string StatusDetails
+    {
+        get
+        {
+            var str = $"{StatusString} ({Status})\n\n{StatusDescription}";
+            foreach (var err in Errors) str += $"\n\n{err}";
+            return str;
+        }
+    }
+
+    public string SizeDetails
+    {
+        get
+        {
+            var str = "";
+            if (!LocalSize.Equals(RemoteSize)) str = "Maximum:\n" + MaxSize.Description("  ") + "\n\n";
+
+            str += "Local:\n" + LocalSize.Description("  ") + "\n\n" +
+                   "Remote:\n" + RemoteSize.Description("  ");
+            return str;
+        }
+    }
+
+    // TODO: remove once we process sessions from the mutagen RPC
+    public MutagenSessionModel(string localPath, string remoteName, string remotePath, MutagenSessionStatus status,
+        string statusString, string statusDescription, string[] errors)
+    {
+        Identifier = "TODO";
+        Name = "TODO";
+
+        LocalPath = localPath;
+        RemoteName = remoteName;
+        RemotePath = remotePath;
+        Status = status;
+        StatusString = statusString;
+        StatusDescription = statusDescription;
+        LocalSize = new MutagenSessionModelEndpointSize
+        {
+            SizeBytes = (ulong)new Random().Next(0, 1000000000),
+            FileCount = (ulong)new Random().Next(0, 10000),
+            DirCount = (ulong)new Random().Next(0, 10000),
+        };
+        RemoteSize = new MutagenSessionModelEndpointSize
+        {
+            SizeBytes = (ulong)new Random().Next(0, 1000000000),
+            FileCount = (ulong)new Random().Next(0, 10000),
+            DirCount = (ulong)new Random().Next(0, 10000),
+        };
+        MaxSize = new MutagenSessionModelEndpointSize
+        {
+            SizeBytes = ulong.Max(LocalSize.SizeBytes, RemoteSize.SizeBytes),
+            FileCount = ulong.Max(LocalSize.FileCount, RemoteSize.FileCount),
+            DirCount = ulong.Max(LocalSize.DirCount, RemoteSize.DirCount),
+            SymlinkCount = ulong.Max(LocalSize.SymlinkCount, RemoteSize.SymlinkCount),
+        };
+
+        Errors = errors;
+    }
+
+    public MutagenSessionModel(State state)
+    {
+        Identifier = state.Session.Identifier;
+        Name = state.Session.Name;
+
+        // If the protocol isn't what we expect for alpha or beta, show
+        // "unknown".
+        if (state.Session.Alpha.Protocol == Protocol.Local && !string.IsNullOrWhiteSpace(state.Session.Alpha.Path))
+            LocalPath = state.Session.Alpha.Path;
+        if (state.Session.Beta.Protocol == Protocol.Ssh)
+        {
+            if (string.IsNullOrWhiteSpace(state.Session.Beta.Host))
+            {
+                var name = state.Session.Beta.Host;
+                // TODO: this will need to be compatible with custom hostname
+                //       suffixes
+                if (name.EndsWith(".coder")) name = name[..^6];
+                RemoteName = name;
+            }
+
+            if (string.IsNullOrWhiteSpace(state.Session.Beta.Path)) RemotePath = state.Session.Beta.Path;
+        }
+
+        if (state.Session.Paused)
+        {
+            // Disregard any status if it's paused.
+            Status = MutagenSessionStatus.Paused;
+            StatusString = "Paused";
+            StatusDescription = "The session is paused.";
+        }
+        else
+        {
+            Status = MutagenSessionModelUtils.StatusFromProtoStatus(state.Status);
+            StatusString = MutagenSessionModelUtils.ProtoStatusToDisplayString(state.Status);
+            StatusDescription = MutagenSessionModelUtils.ProtoStatusToDescription(state.Status);
+        }
+
+        // If there are any conflicts, set the status to NeedsAttention.
+        if (state.Conflicts.Count > 0 && Status > MutagenSessionStatus.NeedsAttention)
+        {
+            Status = MutagenSessionStatus.NeedsAttention;
+            StatusString = "Conflicts";
+            StatusDescription = "The session has conflicts that need to be resolved.";
+        }
+
+        LocalSize = new MutagenSessionModelEndpointSize
+        {
+            SizeBytes = state.AlphaState.TotalFileSize,
+            FileCount = state.AlphaState.Files,
+            DirCount = state.AlphaState.Directories,
+            SymlinkCount = state.AlphaState.SymbolicLinks,
+        };
+        RemoteSize = new MutagenSessionModelEndpointSize
+        {
+            SizeBytes = state.BetaState.TotalFileSize,
+            FileCount = state.BetaState.Files,
+            DirCount = state.BetaState.Directories,
+            SymlinkCount = state.BetaState.SymbolicLinks,
+        };
+        MaxSize = new MutagenSessionModelEndpointSize
+        {
+            SizeBytes = ulong.Max(LocalSize.SizeBytes, RemoteSize.SizeBytes),
+            FileCount = ulong.Max(LocalSize.FileCount, RemoteSize.FileCount),
+            DirCount = ulong.Max(LocalSize.DirCount, RemoteSize.DirCount),
+            SymlinkCount = ulong.Max(LocalSize.SymlinkCount, RemoteSize.SymlinkCount),
+        };
+
+        // TODO: accumulate errors, there seems to be multiple fields they can
+        //       come from
+        if (!string.IsNullOrWhiteSpace(state.LastError)) Errors = [state.LastError];
+    }
+}
+
+public static class MutagenSessionModelUtils
+{
+    public static MutagenSessionStatus StatusFromProtoStatus(Status protoStatus)
+    {
+        switch (protoStatus)
+        {
+            case Status.Disconnected:
+            case Status.HaltedOnRootEmptied:
+            case Status.HaltedOnRootDeletion:
+            case Status.HaltedOnRootTypeChange:
+            case Status.WaitingForRescan:
+                return MutagenSessionStatus.Error;
+            case Status.ConnectingAlpha:
+            case Status.ConnectingBeta:
+            case Status.Scanning:
+            case Status.Reconciling:
+            case Status.StagingAlpha:
+            case Status.StagingBeta:
+            case Status.Transitioning:
+            case Status.Saving:
+                return MutagenSessionStatus.Working;
+            case Status.Watching:
+                return MutagenSessionStatus.Ok;
+            default:
+                return MutagenSessionStatus.Unknown;
+        }
+    }
+
+    public static string ProtoStatusToDisplayString(Status protoStatus)
+    {
+        switch (protoStatus)
+        {
+            case Status.Disconnected:
+                return "Disconnected";
+            case Status.HaltedOnRootEmptied:
+                return "Halted on root emptied";
+            case Status.HaltedOnRootDeletion:
+                return "Halted on root deletion";
+            case Status.HaltedOnRootTypeChange:
+                return "Halted on root type change";
+            case Status.ConnectingAlpha:
+                // This string was changed from "alpha" to "local".
+                return "Connecting (local)";
+            case Status.ConnectingBeta:
+                // This string was changed from "beta" to "remote".
+                return "Connecting (remote)";
+            case Status.Watching:
+                return "Watching";
+            case Status.Scanning:
+                return "Scanning";
+            case Status.WaitingForRescan:
+                return "Waiting for rescan";
+            case Status.Reconciling:
+                return "Reconciling";
+            case Status.StagingAlpha:
+                // This string was changed from "alpha" to "local".
+                return "Staging (local)";
+            case Status.StagingBeta:
+                // This string was changed from "beta" to "remote".
+                return "Staging (remote)";
+            case Status.Transitioning:
+                return "Transitioning";
+            case Status.Saving:
+                return "Saving";
+            default:
+                return protoStatus.ToString();
+        }
+    }
+
+    public static string ProtoStatusToDescription(Status protoStatus)
+    {
+        // These descriptions were mostly taken from the protobuf.
+        switch (protoStatus)
+        {
+            case Status.Disconnected:
+                return "The session is unpaused but not currently connected or connecting to either endpoint.";
+            case Status.HaltedOnRootEmptied:
+                return "The session is halted due to the root emptying safety check.";
+            case Status.HaltedOnRootDeletion:
+                return "The session is halted due to the root deletion safety check.";
+            case Status.HaltedOnRootTypeChange:
+                return "The session is halted due to the root type change safety check.";
+            case Status.ConnectingAlpha:
+                // This string was changed from "alpha" to "local".
+                return "The session is attempting to connect to the local endpoint.";
+            case Status.ConnectingBeta:
+                // This string was changed from "beta" to "remote".
+                return "The session is attempting to connect to the remote endpoint.";
+            case Status.Watching:
+                return "The session is watching for filesystem changes.";
+            case Status.Scanning:
+                return "The session is scanning the filesystem on each endpoint.";
+            case Status.WaitingForRescan:
+                return
+                    "The session is waiting to retry scanning after an error during the previous scanning operation.";
+            case Status.Reconciling:
+                return "The session is performing reconciliation.";
+            case Status.StagingAlpha:
+                // This string was changed from "on alpha" to "locally".
+                return "The session is staging files locally.";
+            case Status.StagingBeta:
+                // This string was changed from "beta" to "the remote".
+                return "The session is staging files on the remote.";
+            case Status.Transitioning:
+                return "The session is performing transition operations on each endpoint.";
+            case Status.Saving:
+                return "The session is recording synchronization history to disk.";
+            default:
+                return "Unknown status message.";
+        }
+    }
+}
diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs
new file mode 100644
index 0000000..6de170e
--- /dev/null
+++ b/App/ViewModels/FileSyncListViewModel.cs
@@ -0,0 +1,188 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Windows.Storage.Pickers;
+using Coder.Desktop.App.Models;
+using Coder.Desktop.App.Services;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using WinRT.Interop;
+
+namespace Coder.Desktop.App.ViewModels;
+
+public partial class FileSyncListViewModel : ObservableObject
+{
+    public delegate void OnFileSyncListStaleDelegate();
+
+    // Triggered when the window should be closed.
+    public event OnFileSyncListStaleDelegate? OnFileSyncListStale;
+
+    private DispatcherQueue? _dispatcherQueue;
+
+    private readonly IRpcController _rpcController;
+    private readonly ICredentialManager _credentialManager;
+
+    [ObservableProperty] public partial List<MutagenSessionModel> Sessions { get; set; } = [];
+
+    [ObservableProperty] public partial bool CreatingNewSession { get; set; } = false;
+
+    [ObservableProperty]
+    [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
+    public partial string NewSessionLocalPath { get; set; } = "";
+
+    [ObservableProperty]
+    [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
+    public partial bool NewSessionLocalPathDialogOpen { get; set; } = false;
+
+    [ObservableProperty]
+    [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
+    public partial string NewSessionRemoteName { get; set; } = "";
+
+    [ObservableProperty]
+    [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
+    public partial string NewSessionRemotePath { get; set; } = "";
+    // TODO: NewSessionRemotePathDialogOpen for remote path
+
+    public bool NewSessionCreateEnabled
+    {
+        get
+        {
+            if (string.IsNullOrWhiteSpace(NewSessionLocalPath)) return false;
+            if (NewSessionLocalPathDialogOpen) return false;
+            if (string.IsNullOrWhiteSpace(NewSessionRemoteName)) return false;
+            if (string.IsNullOrWhiteSpace(NewSessionRemotePath)) return false;
+            return true;
+        }
+    }
+
+    public FileSyncListViewModel(IRpcController rpcController, ICredentialManager credentialManager)
+    {
+        _rpcController = rpcController;
+        _credentialManager = credentialManager;
+
+        Sessions =
+        [
+            new MutagenSessionModel(@"C:\Users\dean\git\coder-desktop-windows", "pog", "~/repos/coder-desktop-windows",
+                MutagenSessionStatus.Ok, "Watching", "Some description", []),
+            new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.Paused, "Paused",
+                "Some description", []),
+            new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.NeedsAttention,
+                "Conflicts", "Some description", []),
+            new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.Error,
+                "Halted on root emptied", "Some description", []),
+            new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.Unknown,
+                "Unknown", "Some description", []),
+            new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.Working,
+                "Reconciling", "Some description", []),
+        ];
+    }
+
+    public void Initialize(DispatcherQueue dispatcherQueue)
+    {
+        _dispatcherQueue = dispatcherQueue;
+
+        _rpcController.StateChanged += (_, rpcModel) => UpdateFromRpcModel(rpcModel);
+        _credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialsModel(credentialModel);
+
+        var rpcModel = _rpcController.GetState();
+        var credentialModel = _credentialManager.GetCachedCredentials();
+        MaybeSendStaleEvent(rpcModel, credentialModel);
+    }
+
+    private void UpdateFromRpcModel(RpcModel rpcModel)
+    {
+        // Ensure we're on the UI thread.
+        if (_dispatcherQueue == null) return;
+        if (!_dispatcherQueue.HasThreadAccess)
+        {
+            _dispatcherQueue.TryEnqueue(() => UpdateFromRpcModel(rpcModel));
+            return;
+        }
+
+        var credentialModel = _credentialManager.GetCachedCredentials();
+        MaybeSendStaleEvent(rpcModel, credentialModel);
+    }
+
+    private void UpdateFromCredentialsModel(CredentialModel credentialModel)
+    {
+        // Ensure we're on the UI thread.
+        if (_dispatcherQueue == null) return;
+        if (!_dispatcherQueue.HasThreadAccess)
+        {
+            _dispatcherQueue.TryEnqueue(() => UpdateFromCredentialsModel(credentialModel));
+            return;
+        }
+
+        var rpcModel = _rpcController.GetState();
+        MaybeSendStaleEvent(rpcModel, credentialModel);
+    }
+
+    private void MaybeSendStaleEvent(RpcModel rpcModel, CredentialModel credentialModel)
+    {
+        var ok = rpcModel.RpcLifecycle is RpcLifecycle.Connected
+                 && rpcModel.VpnLifecycle is VpnLifecycle.Started
+                 && credentialModel.State == CredentialState.Valid;
+
+        if (!ok) OnFileSyncListStale?.Invoke();
+    }
+
+    private void ClearNewForm()
+    {
+        CreatingNewSession = false;
+        NewSessionLocalPath = "";
+        // TODO: close the dialog somehow
+        NewSessionRemoteName = "";
+        NewSessionRemotePath = "";
+    }
+
+    [RelayCommand]
+    private void StartCreatingNewSession()
+    {
+        ClearNewForm();
+        CreatingNewSession = true;
+    }
+
+    public async Task OpenLocalPathSelectDialog(Window window)
+    {
+        var picker = new FolderPicker
+        {
+            SuggestedStartLocation = PickerLocationId.ComputerFolder,
+            // TODO: Needed?
+            //FileTypeFilter = { "*" },
+        };
+
+        var hwnd = WindowNative.GetWindowHandle(window);
+        InitializeWithWindow.Initialize(picker, hwnd);
+
+        NewSessionLocalPathDialogOpen = true;
+        try
+        {
+            var path = await picker.PickSingleFolderAsync();
+            if (path == null) return;
+            NewSessionLocalPath = path.Path;
+        }
+        catch
+        {
+            // ignored
+        }
+        finally
+        {
+            NewSessionLocalPathDialogOpen = false;
+        }
+    }
+
+    [RelayCommand]
+    private void CancelNewSession()
+    {
+        ClearNewForm();
+    }
+
+    [RelayCommand]
+    private void ConfirmNewSession()
+    {
+        // TODO: implement
+        ClearNewForm();
+    }
+}
diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs
index 62cf692..f4c4484 100644
--- a/App/ViewModels/TrayWindowViewModel.cs
+++ b/App/ViewModels/TrayWindowViewModel.cs
@@ -4,6 +4,7 @@
 using System.Threading.Tasks;
 using Coder.Desktop.App.Models;
 using Coder.Desktop.App.Services;
+using Coder.Desktop.App.Views;
 using Coder.Desktop.Vpn.Proto;
 using CommunityToolkit.Mvvm.ComponentModel;
 using CommunityToolkit.Mvvm.Input;
@@ -204,6 +205,14 @@ private string WorkspaceUri(Uri? baseUri, string? workspaceName)
 
     private void UpdateFromCredentialsModel(CredentialModel credentialModel)
     {
+        // Ensure we're on the UI thread.
+        if (_dispatcherQueue == null) return;
+        if (!_dispatcherQueue.HasThreadAccess)
+        {
+            _dispatcherQueue.TryEnqueue(() => UpdateFromCredentialsModel(credentialModel));
+            return;
+        }
+
         // HACK: the HyperlinkButton crashes the whole app if the initial URI
         // or this URI is invalid. CredentialModel.CoderUrl should never be
         // null while the Page is active as the Page is only displayed when
@@ -234,7 +243,7 @@ private async Task StartVpn()
         }
         catch (Exception e)
         {
-            VpnFailedMessage = "Failed to start Coder Connect: " + MaybeUnwrapTunnelError(e);
+            VpnFailedMessage = "Failed to start CoderVPN: " + MaybeUnwrapTunnelError(e);
         }
     }
 
@@ -246,7 +255,7 @@ private async Task StopVpn()
         }
         catch (Exception e)
         {
-            VpnFailedMessage = "Failed to stop Coder Connect: " + MaybeUnwrapTunnelError(e);
+            VpnFailedMessage = "Failed to stop CoderVPN: " + MaybeUnwrapTunnelError(e);
         }
     }
 
@@ -265,6 +274,14 @@ public void ToggleShowAllAgents()
     [RelayCommand]
     public void SignOut()
     {
+        // TODO: Remove this debug workaround once we have a real UI to open
+        //       the sync window. This lets us open the file sync list window
+        //       in debug builds.
+#if DEBUG
+        new FileSyncListWindow(new FileSyncListViewModel(_rpcController, _credentialManager)).Activate();
+        return;
+#endif
+
         if (VpnLifecycle is not VpnLifecycle.Stopped)
             return;
         _credentialManager.ClearCredentials();
diff --git a/App/Views/FileSyncListWindow.xaml b/App/Views/FileSyncListWindow.xaml
new file mode 100644
index 0000000..ae95e8b
--- /dev/null
+++ b/App/Views/FileSyncListWindow.xaml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<winuiex:WindowEx
+    x:Class="Coder.Desktop.App.Views.FileSyncListWindow"
+    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+    xmlns:winuiex="using:WinUIEx"
+    mc:Ignorable="d"
+    Title="Coder Desktop"
+    Width="1000" Height="300"
+    MinWidth="1000" MinHeight="300">
+
+    <Window.SystemBackdrop>
+        <DesktopAcrylicBackdrop />
+    </Window.SystemBackdrop>
+
+    <Frame x:Name="RootFrame" />
+</winuiex:WindowEx>
diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs
new file mode 100644
index 0000000..0e784dc
--- /dev/null
+++ b/App/Views/FileSyncListWindow.xaml.cs
@@ -0,0 +1,33 @@
+using Coder.Desktop.App.ViewModels;
+using Coder.Desktop.App.Views.Pages;
+using Microsoft.UI.Xaml.Media;
+using WinUIEx;
+
+namespace Coder.Desktop.App.Views;
+
+public sealed partial class FileSyncListWindow : WindowEx
+{
+    public readonly FileSyncListViewModel ViewModel;
+
+    public FileSyncListWindow(FileSyncListViewModel viewModel)
+    {
+        ViewModel = viewModel;
+        ViewModel.OnFileSyncListStale += ViewModel_OnFileSyncListStale;
+
+        InitializeComponent();
+        SystemBackdrop = new DesktopAcrylicBackdrop();
+
+        ViewModel.Initialize(DispatcherQueue);
+        RootFrame.Content = new FileSyncListMainPage(ViewModel, this);
+
+        this.CenterOnScreen();
+    }
+
+    private void ViewModel_OnFileSyncListStale()
+    {
+        // TODO: Fix this. I got a weird memory corruption exception when it
+        //       fired immediately on start. Maybe we should schedule it for
+        //       next frame or something.
+        //Close()
+    }
+}
diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml
new file mode 100644
index 0000000..e6b7db3
--- /dev/null
+++ b/App/Views/Pages/FileSyncListMainPage.xaml
@@ -0,0 +1,269 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<Page
+    x:Class="Coder.Desktop.App.Views.Pages.FileSyncListMainPage"
+    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+    xmlns:models="using:Coder.Desktop.App.Models"
+    xmlns:converters="using:Coder.Desktop.App.Converters"
+    mc:Ignorable="d"
+    Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
+
+    <ScrollView>
+        <StackPanel Orientation="Vertical" Padding="30,15">
+            <!--
+                We use separate grids for the header and each child because WinUI 3
+                doesn't support having a dynamic row count.
+
+                This unfortunately means we need to copy the resources and the
+                column definitions to each Grid.
+            -->
+            <Grid Margin="0,0,0,5">
+                <Grid.Resources>
+                    <Style TargetType="TextBlock">
+                        <Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" />
+                    </Style>
+                    <Style TargetType="Border">
+                        <Setter Property="Padding" Value="40,0,0,0" />
+                    </Style>
+                </Grid.Resources>
+
+                <!-- Cannot use "Auto" as it won't work for multiple Grids. -->
+                <Grid.ColumnDefinitions>
+                    <!-- Icon column: 14 + 5 padding + 14 + 10 padding -->
+                    <ColumnDefinition Width="43" />
+                    <ColumnDefinition Width="2*" MinWidth="200" />
+                    <ColumnDefinition Width="1*" MinWidth="120" />
+                    <ColumnDefinition Width="2*" MinWidth="200" />
+                    <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
+                    <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
+                </Grid.ColumnDefinitions>
+
+                <Border Grid.Column="1" Padding="10,0,0,0">
+                    <TextBlock Text="Local Path" />
+                </Border>
+                <Border Grid.Column="2">
+                    <TextBlock Text="Workspace" />
+                </Border>
+                <Border Grid.Column="3">
+                    <TextBlock Text="Remote Path" />
+                </Border>
+                <Border Grid.Column="4">
+                    <TextBlock Text="Status" />
+                </Border>
+                <Border Grid.Column="5">
+                    <TextBlock Text="Size" />
+                </Border>
+            </Grid>
+
+            <Border
+                Height="1"
+                Margin="-30,0,-30,5"
+                Background="{ThemeResource ControlElevationBorderBrush}" />
+
+            <ItemsRepeater ItemsSource="{x:Bind ViewModel.Sessions, Mode=OneWay}">
+                <ItemsRepeater.Layout>
+                    <StackLayout Orientation="Vertical" />
+                </ItemsRepeater.Layout>
+
+                <ItemsRepeater.ItemTemplate>
+                    <DataTemplate x:DataType="models:MutagenSessionModel">
+                        <Grid Margin="0,10">
+                            <!-- These are (mostly) from the header Grid and should be copied here -->
+                            <Grid.Resources>
+                                <Style TargetType="Border">
+                                    <Setter Property="Padding" Value="40,0,0,0" />
+                                </Style>
+                            </Grid.Resources>
+                            <Grid.ColumnDefinitions>
+                                <ColumnDefinition Width="43" />
+                                <ColumnDefinition Width="2*" MinWidth="200" />
+                                <ColumnDefinition Width="1*" MinWidth="120" />
+                                <ColumnDefinition Width="2*" MinWidth="200" />
+                                <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
+                                <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
+                            </Grid.ColumnDefinitions>
+
+                            <Border Grid.Column="0" Padding="0" HorizontalAlignment="Right">
+                                <StackPanel Orientation="Horizontal">
+                                    <HyperlinkButton Padding="0" Margin="0,0,5,0">
+                                        <FontIcon Glyph="&#xE769;" FontSize="15"
+                                                  Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
+                                    </HyperlinkButton>
+                                    <HyperlinkButton Padding="0">
+                                        <FontIcon Glyph="&#xF140;" FontSize="15"
+                                                  Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
+                                    </HyperlinkButton>
+                                </StackPanel>
+                            </Border>
+                            <Border Grid.Column="1" Padding="10,0,0,0">
+                                <TextBlock
+                                    Text="{x:Bind LocalPath}"
+                                    TextTrimming="CharacterEllipsis"
+                                    IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" />
+                            </Border>
+                            <Border Grid.Column="2">
+                                <TextBlock
+                                    Text="{x:Bind RemoteName}"
+                                    TextTrimming="CharacterEllipsis"
+                                    IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" />
+                            </Border>
+                            <Border Grid.Column="3">
+                                <TextBlock
+                                    Text="{x:Bind RemotePath}"
+                                    TextTrimming="CharacterEllipsis"
+                                    IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" />
+                            </Border>
+                            <Border Grid.Column="4">
+                                <Border.Resources>
+                                    <converters:StringToBrushSelector
+                                        x:Key="StatusColor"
+                                        SelectedKey="{x:Bind Path=Status}">
+
+                                        <converters:StringToBrushSelectorItem
+                                            Value="{ThemeResource SystemFillColorCriticalBrush}" />
+                                        <converters:StringToBrushSelectorItem
+                                            Key="Paused"
+                                            Value="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
+                                        <converters:StringToBrushSelectorItem
+                                            Key="Error"
+                                            Value="{ThemeResource SystemFillColorCriticalBrush}" />
+                                        <converters:StringToBrushSelectorItem
+                                            Key="NeedsAttention"
+                                            Value="{ThemeResource SystemFillColorCautionBrush}" />
+                                        <converters:StringToBrushSelectorItem
+                                            Key="Working"
+                                            Value="{ThemeResource SystemFillColorAttentionBrush}" />
+                                        <converters:StringToBrushSelectorItem
+                                            Key="Ok"
+                                            Value="{ThemeResource DefaultTextForegroundThemeBrush}" />
+                                    </converters:StringToBrushSelector>
+                                </Border.Resources>
+                                <TextBlock
+                                    Text="{x:Bind StatusString}"
+                                    TextTrimming="CharacterEllipsis"
+                                    Foreground="{Binding Source={StaticResource StatusColor}, Path=SelectedObject}"
+                                    ToolTipService.ToolTip="{x:Bind StatusDetails}" />
+                            </Border>
+                            <Border Grid.Column="5">
+                                <TextBlock
+                                    Text="{x:Bind MaxSize.SizeBytes, Converter={StaticResource FriendlyByteConverter}}"
+                                    ToolTipService.ToolTip="{x:Bind SizeDetails}" />
+                            </Border>
+                        </Grid>
+                    </DataTemplate>
+                </ItemsRepeater.ItemTemplate>
+            </ItemsRepeater>
+
+            <!-- "New Sync" button -->
+            <!--
+                HACK: this has some random numbers for padding and margins. Since
+                we need to align the icon and the text to the two grid columns
+                above (but still have it be within the same button), this is the
+                best solution I could come up with.
+            -->
+            <HyperlinkButton
+                Margin="13,5,0,0"
+                Command="{x:Bind ViewModel.StartCreatingNewSessionCommand}"
+                Visibility="{x:Bind ViewModel.CreatingNewSession, Converter={StaticResource InverseBoolToVisibilityConverter}, Mode=OneWay}">
+
+                <StackPanel Orientation="Horizontal">
+                    <FontIcon
+                        FontSize="18"
+                        Margin="0,0,10,0"
+                        Glyph="&#xE710;"
+                        Foreground="{ThemeResource SystemFillColorSuccessBrush}" />
+                    <TextBlock
+                        Text="New Sync"
+                        Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
+                </StackPanel>
+            </HyperlinkButton>
+
+            <!-- New item Grid -->
+            <Grid
+                Margin="0,10"
+                Visibility="{x:Bind ViewModel.CreatingNewSession, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}">
+
+                <!-- These are (mostly) from the header Grid and should be copied here -->
+                <Grid.Resources>
+                    <Style TargetType="Border">
+                        <Setter Property="Padding" Value="40,0,0,0" />
+                    </Style>
+                </Grid.Resources>
+                <Grid.ColumnDefinitions>
+                    <ColumnDefinition Width="43" />
+                    <ColumnDefinition Width="2*" MinWidth="200" />
+                    <ColumnDefinition Width="1*" MinWidth="120" />
+                    <ColumnDefinition Width="2*" MinWidth="200" />
+                    <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
+                    <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
+                </Grid.ColumnDefinitions>
+
+                <Border Grid.Column="0" Padding="0">
+                    <StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
+                        <!-- TODO: gray out the button if the form is not filled out correctly -->
+                        <HyperlinkButton
+                            Padding="0"
+                            Margin="0,0,5,0"
+                            Command="{x:Bind ViewModel.ConfirmNewSessionCommand}">
+
+                            <FontIcon Glyph="&#xE930;" FontSize="15"
+                                      Foreground="{ThemeResource SystemFillColorSuccessBrush}" />
+                        </HyperlinkButton>
+                        <HyperlinkButton
+                            Padding="0"
+                            Command="{x:Bind ViewModel.CancelNewSessionCommand}">
+
+                            <FontIcon Glyph="&#xF096;" FontSize="15"
+                                      Foreground="{ThemeResource SystemFillColorCriticalBrush}" />
+                        </HyperlinkButton>
+                    </StackPanel>
+                </Border>
+                <Border Grid.Column="1" Padding="10,0,0,0">
+                    <Grid>
+                        <Grid.ColumnDefinitions>
+                            <ColumnDefinition Width="*" />
+                            <ColumnDefinition Width="Auto" />
+                        </Grid.ColumnDefinitions>
+
+                        <TextBox
+                            Grid.Column="0"
+                            Margin="0,0,5,0"
+                            VerticalAlignment="Stretch"
+                            Text="{x:Bind ViewModel.NewSessionLocalPath, Mode=TwoWay}" />
+
+                        <Button
+                            Grid.Column="1"
+                            IsEnabled="{x:Bind ViewModel.NewSessionLocalPathDialogOpen, Converter={StaticResource InverseBoolConverter}, Mode=OneWay}"
+                            Command="{x:Bind OpenLocalPathSelectDialogCommand}"
+                            VerticalAlignment="Stretch">
+
+                            <FontIcon Glyph="&#xE838;" FontSize="13" />
+                        </Button>
+                    </Grid>
+                </Border>
+                <Border Grid.Column="2">
+                    <!-- TODO: use a combo box for workspace agents -->
+                    <!--
+                    <ComboBox
+                        ItemsSource="{x:Bind WorkspaceAgents}"
+                        VerticalAlignment="Stretch"
+                        HorizontalAlignment="Stretch" />
+                    -->
+                    <TextBox
+                        VerticalAlignment="Stretch"
+                        HorizontalAlignment="Stretch"
+                        Text="{x:Bind ViewModel.NewSessionRemoteName, Mode=TwoWay}" />
+                </Border>
+                <Border Grid.Column="3">
+                    <TextBox
+                        VerticalAlignment="Stretch"
+                        HorizontalAlignment="Stretch"
+                        Text="{x:Bind ViewModel.NewSessionRemotePath, Mode=TwoWay}" />
+                </Border>
+            </Grid>
+        </StackPanel>
+    </ScrollView>
+</Page>
diff --git a/App/Views/Pages/FileSyncListMainPage.xaml.cs b/App/Views/Pages/FileSyncListMainPage.xaml.cs
new file mode 100644
index 0000000..c54c29e
--- /dev/null
+++ b/App/Views/Pages/FileSyncListMainPage.xaml.cs
@@ -0,0 +1,40 @@
+using System.Threading.Tasks;
+using Coder.Desktop.App.ViewModels;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Coder.Desktop.App.Views.Pages;
+
+public sealed partial class FileSyncListMainPage : Page
+{
+    public FileSyncListViewModel ViewModel;
+
+    private readonly Window _window;
+
+    public FileSyncListMainPage(FileSyncListViewModel viewModel, Window window)
+    {
+        ViewModel = viewModel; // already initialized
+        _window = window;
+        InitializeComponent();
+    }
+
+    // Adds a tooltip with the full text when it's ellipsized.
+    private void TooltipText_IsTextTrimmedChanged(TextBlock sender, IsTextTrimmedChangedEventArgs e)
+    {
+        ToolTipService.SetToolTip(sender, null);
+        if (!sender.IsTextTrimmed) return;
+
+        var toolTip = new ToolTip
+        {
+            Content = sender.Text,
+        };
+        ToolTipService.SetToolTip(sender, toolTip);
+    }
+
+    [RelayCommand]
+    public async Task OpenLocalPathSelectDialog()
+    {
+        await ViewModel.OpenLocalPathSelectDialog(_window);
+    }
+}
diff --git a/App/Views/Pages/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml
index cedf006..94c80b3 100644
--- a/App/Views/Pages/TrayWindowMainPage.xaml
+++ b/App/Views/Pages/TrayWindowMainPage.xaml
@@ -12,14 +12,11 @@
     mc:Ignorable="d">
 
     <Page.Resources>
-        <converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
-
         <converters:VpnLifecycleToBoolConverter x:Key="ConnectingBoolConverter" Unknown="true" Starting="true"
                                                 Stopping="true" />
         <converters:VpnLifecycleToBoolConverter x:Key="NotConnectingBoolConverter" Started="true" Stopped="true" />
         <converters:VpnLifecycleToBoolConverter x:Key="StoppedBoolConverter" Stopped="true" />
 
-        <converters:AgentStatusToColorConverter x:Key="AgentStatusToColorConverter" />
         <converters:BoolToObjectConverter x:Key="ShowMoreLessTextConverter" TrueValue="Show less"
                                           FalseValue="Show more" />
     </Page.Resources>
@@ -118,6 +115,34 @@
                                 HorizontalAlignment="Stretch"
                                 Spacing="10">
 
+                                <StackPanel.Resources>
+                                    <converters:StringToBrushSelector
+                                        x:Key="StatusColor"
+                                        SelectedKey="{x:Bind Path=ConnectionStatus, Mode=OneWay}">
+
+                                        <converters:StringToBrushSelectorItem>
+                                            <converters:StringToBrushSelectorItem.Value>
+                                                <SolidColorBrush Color="#8e8e93" />
+                                            </converters:StringToBrushSelectorItem.Value>
+                                        </converters:StringToBrushSelectorItem>
+                                        <converters:StringToBrushSelectorItem Key="Red">
+                                            <converters:StringToBrushSelectorItem.Value>
+                                                <SolidColorBrush Color="#ff3b30" />
+                                            </converters:StringToBrushSelectorItem.Value>
+                                        </converters:StringToBrushSelectorItem>
+                                        <converters:StringToBrushSelectorItem Key="Yellow">
+                                            <converters:StringToBrushSelectorItem.Value>
+                                                <SolidColorBrush Color="#ffcc01" />
+                                            </converters:StringToBrushSelectorItem.Value>
+                                        </converters:StringToBrushSelectorItem>
+                                        <converters:StringToBrushSelectorItem Key="Green">
+                                            <converters:StringToBrushSelectorItem.Value>
+                                                <SolidColorBrush Color="#34c759" />
+                                            </converters:StringToBrushSelectorItem.Value>
+                                        </converters:StringToBrushSelectorItem>
+                                    </converters:StringToBrushSelector>
+                                </StackPanel.Resources>
+
                                 <Canvas
                                     HorizontalAlignment="Center"
                                     VerticalAlignment="Center"
@@ -125,7 +150,7 @@
                                     Margin="0,1,0,0">
 
                                     <Ellipse
-                                        Fill="{x:Bind ConnectionStatus, Converter={StaticResource AgentStatusToColorConverter}, Mode=OneWay}"
+                                        Fill="{Binding Source={StaticResource StatusColor}, Path=SelectedObject}"
                                         Opacity="0.2"
                                         Width="14"
                                         Height="14"
@@ -133,7 +158,7 @@
                                         Canvas.Top="0" />
 
                                     <Ellipse
-                                        Fill="{x:Bind ConnectionStatus, Converter={StaticResource AgentStatusToColorConverter}, Mode=OneWay}"
+                                        Fill="{Binding Source={StaticResource StatusColor}, Path=SelectedObject}"
                                         Width="8"
                                         Height="8"
                                         VerticalAlignment="Center"
diff --git a/App/packages.lock.json b/App/packages.lock.json
index 8988638..405ea61 100644
--- a/App/packages.lock.json
+++ b/App/packages.lock.json
@@ -85,6 +85,15 @@
           "Microsoft.Windows.SDK.BuildTools": "10.0.22621.756"
         }
       },
+      "WinUIEx": {
+        "type": "Direct",
+        "requested": "[2.5.1, )",
+        "resolved": "2.5.1",
+        "contentHash": "ihW4bA2quKbwWBOl5Uu80jBZagc4cT4E6CdExmvSZ05Qwz0jgoGyZuSTKcU9Uz7lZlQij3KxNor0dGXNUyIV9Q==",
+        "dependencies": {
+          "Microsoft.WindowsAppSDK": "1.6.240829007"
+        }
+      },
       "Google.Protobuf": {
         "type": "Transitive",
         "resolved": "3.29.3",

From 5ddda6f7f6a9b64d58be22debde1e5f3361fafc2 Mon Sep 17 00:00:00 2001
From: Dean Sheather <dean@deansheather.com>
Date: Mon, 24 Mar 2025 19:23:10 +1100
Subject: [PATCH 2/5] PR comments

---
 App/App.xaml.cs                             |   5 +
 App/Converters/DependencyObjectSelector.cs  |  47 +-
 App/Models/MutagenSessionModel.cs           | 310 ------------
 App/Models/SyncSessionModel.cs              | 249 ++++++++++
 App/Services/MutagenController.cs           |  37 +-
 App/ViewModels/FileSyncListViewModel.cs     |  85 +++-
 App/ViewModels/TrayWindowViewModel.cs       |  32 +-
 App/Views/FileSyncListWindow.xaml           |   2 +-
 App/Views/Pages/FileSyncListMainPage.xaml   | 516 +++++++++++---------
 App/Views/Pages/TrayWindowMainPage.xaml     |  12 +
 Tests.App/Services/MutagenControllerTest.cs |   8 +-
 11 files changed, 699 insertions(+), 604 deletions(-)
 delete mode 100644 App/Models/MutagenSessionModel.cs
 create mode 100644 App/Models/SyncSessionModel.cs

diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index e1c5cb4..0b159a9 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -47,6 +47,11 @@ public App()
         services.AddTransient<SignInViewModel>();
         services.AddTransient<SignInWindow>();
 
+        // FileSyncListWindow views and view models
+        services.AddTransient<FileSyncListViewModel>();
+        // FileSyncListMainPage is created by FileSyncListWindow.
+        services.AddTransient<FileSyncListWindow>();
+
         // TrayWindow views and view models
         services.AddTransient<TrayWindowLoadingPage>();
         services.AddTransient<TrayWindowDisconnectedViewModel>();
diff --git a/App/Converters/DependencyObjectSelector.cs b/App/Converters/DependencyObjectSelector.cs
index 740c7a6..8c1570f 100644
--- a/App/Converters/DependencyObjectSelector.cs
+++ b/App/Converters/DependencyObjectSelector.cs
@@ -12,6 +12,13 @@ namespace Coder.Desktop.App.Converters;
 // DependencyPropertyGenerator since it doesn't seem to work properly with
 // generics.
 
+/// <summary>
+///     An item in a DependencyObjectSelector. Each item has a key and a value.
+///     The default item in a DependencyObjectSelector will be the only item
+///     with a null key.
+/// </summary>
+/// <typeparam name="TK">Key type</typeparam>
+/// <typeparam name="TV">Value type</typeparam>
 public class DependencyObjectSelectorItem<TK, TV> : DependencyObject
     where TK : IEquatable<TK>
 {
@@ -40,6 +47,14 @@ public TV? Value
     }
 }
 
+/// <summary>
+///     Allows selecting between multiple value references based on a selected
+///     key. This allows for dynamic mapping of model values to other objects.
+///     The main use case is for selecting between other bound values, which
+///     you cannot do with a simple ValueConverter.
+/// </summary>
+/// <typeparam name="TK">Key type</typeparam>
+/// <typeparam name="TV">Value type</typeparam>
 [ContentProperty(Name = nameof(References))]
 public class DependencyObjectSelector<TK, TV> : DependencyObject
     where TK : IEquatable<TK>
@@ -54,7 +69,7 @@ public class DependencyObjectSelector<TK, TV> : DependencyObject
         DependencyProperty.Register(nameof(SelectedKey),
             typeof(TK?),
             typeof(DependencyObjectSelector<TK, TV>),
-            new PropertyMetadata(null, SelectedPropertyChanged));
+            new PropertyMetadata(null, SelectedKeyPropertyChanged));
 
     public static readonly DependencyProperty SelectedObjectProperty =
         DependencyProperty.Register(nameof(SelectedObject),
@@ -80,12 +95,22 @@ public DependencyObjectCollection? References
         }
     }
 
+    /// <summary>
+    ///     The key of the selected item. This should be bound to a property on
+    ///     the model.
+    /// </summary>
     public TK? SelectedKey
     {
         get => (TK?)GetValue(SelectedKeyProperty);
         set => SetValue(SelectedKeyProperty, value);
     }
 
+    /// <summary>
+    ///     The selected object. This can be read from to get the matching
+    ///     object for the selected key. If the selected key doesn't match any
+    ///     object, this will be the value of the null key. If there is no null
+    ///     key, this will be null.
+    /// </summary>
     public TV? SelectedObject
     {
         get => (TV?)GetValue(SelectedObjectProperty);
@@ -97,15 +122,12 @@ public DependencyObjectSelector()
         References = [];
     }
 
-    private void OnVectorChangedReferences(IObservableVector<DependencyObject> sender, IVectorChangedEventArgs args)
-    {
-        UpdateSelectedObject();
-    }
-
     private void UpdateSelectedObject()
     {
         if (References != null)
         {
+            // Look for a matching item a matching key, or fallback to the null
+            // key.
             var references = References.OfType<DependencyObjectSelectorItem<TK, TV>>().ToArray();
             var item = references
                            .FirstOrDefault(i =>
@@ -114,6 +136,9 @@ private void UpdateSelectedObject()
                        ?? references.FirstOrDefault(i => i.Key == null);
             if (item is not null)
             {
+                // Bind the SelectedObject property to the reference's Value.
+                // If the underlying Value changes, it will propagate to the
+                // SelectedObject.
                 BindingOperations.SetBinding
                 (
                     this,
@@ -131,6 +156,7 @@ private void UpdateSelectedObject()
         ClearValue(SelectedObjectProperty);
     }
 
+    // Called when the References property is replaced.
     private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
     {
         var self = obj as DependencyObjectSelector<TK, TV>;
@@ -143,7 +169,14 @@ private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPr
             newValue.VectorChanged += self.OnVectorChangedReferences;
     }
 
-    private static void SelectedPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
+    // Called when the References collection changes without being replaced.
+    private void OnVectorChangedReferences(IObservableVector<DependencyObject> sender, IVectorChangedEventArgs args)
+    {
+        UpdateSelectedObject();
+    }
+
+    // Called when SelectedKey changes.
+    private static void SelectedKeyPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
     {
         var self = obj as DependencyObjectSelector<TK, TV>;
         self?.UpdateSelectedObject();
diff --git a/App/Models/MutagenSessionModel.cs b/App/Models/MutagenSessionModel.cs
deleted file mode 100644
index 5e1dc37..0000000
--- a/App/Models/MutagenSessionModel.cs
+++ /dev/null
@@ -1,310 +0,0 @@
-using System;
-using Coder.Desktop.App.Converters;
-using Coder.Desktop.MutagenSdk.Proto.Synchronization;
-using Coder.Desktop.MutagenSdk.Proto.Url;
-
-namespace Coder.Desktop.App.Models;
-
-// This is a much slimmer enum than the original enum from Mutagen and only
-// contains the overarching states that we care about from a code perspective.
-// We still store the original state in the model for rendering purposes.
-public enum MutagenSessionStatus
-{
-    Unknown,
-    Paused,
-    Error,
-    NeedsAttention,
-    Working,
-    Ok,
-}
-
-public sealed class MutagenSessionModelEndpointSize
-{
-    public ulong SizeBytes { get; init; }
-    public ulong FileCount { get; init; }
-    public ulong DirCount { get; init; }
-    public ulong SymlinkCount { get; init; }
-
-    public string Description(string linePrefix)
-    {
-        var str =
-            $"{linePrefix}{FriendlyByteConverter.FriendlyBytes(SizeBytes)}\n" +
-            $"{linePrefix}{FileCount:N0} files\n" +
-            $"{linePrefix}{DirCount:N0} directories";
-        if (SymlinkCount > 0) str += $"\n{linePrefix} {SymlinkCount:N0} symlinks";
-
-        return str;
-    }
-
-    public bool Equals(MutagenSessionModelEndpointSize other)
-    {
-        return SizeBytes == other.SizeBytes &&
-               FileCount == other.FileCount &&
-               DirCount == other.DirCount &&
-               SymlinkCount == other.SymlinkCount;
-    }
-}
-
-public class MutagenSessionModel
-{
-    public readonly string Identifier;
-    public readonly string Name;
-
-    public readonly string LocalPath = "Unknown";
-    public readonly string RemoteName = "unknown";
-    public readonly string RemotePath = "Unknown";
-
-    public readonly MutagenSessionStatus Status;
-    public readonly string StatusString;
-    public readonly string StatusDescription;
-
-    public readonly MutagenSessionModelEndpointSize MaxSize;
-    public readonly MutagenSessionModelEndpointSize LocalSize;
-    public readonly MutagenSessionModelEndpointSize RemoteSize;
-
-    public readonly string[] Errors = [];
-
-    public string StatusDetails
-    {
-        get
-        {
-            var str = $"{StatusString} ({Status})\n\n{StatusDescription}";
-            foreach (var err in Errors) str += $"\n\n{err}";
-            return str;
-        }
-    }
-
-    public string SizeDetails
-    {
-        get
-        {
-            var str = "";
-            if (!LocalSize.Equals(RemoteSize)) str = "Maximum:\n" + MaxSize.Description("  ") + "\n\n";
-
-            str += "Local:\n" + LocalSize.Description("  ") + "\n\n" +
-                   "Remote:\n" + RemoteSize.Description("  ");
-            return str;
-        }
-    }
-
-    // TODO: remove once we process sessions from the mutagen RPC
-    public MutagenSessionModel(string localPath, string remoteName, string remotePath, MutagenSessionStatus status,
-        string statusString, string statusDescription, string[] errors)
-    {
-        Identifier = "TODO";
-        Name = "TODO";
-
-        LocalPath = localPath;
-        RemoteName = remoteName;
-        RemotePath = remotePath;
-        Status = status;
-        StatusString = statusString;
-        StatusDescription = statusDescription;
-        LocalSize = new MutagenSessionModelEndpointSize
-        {
-            SizeBytes = (ulong)new Random().Next(0, 1000000000),
-            FileCount = (ulong)new Random().Next(0, 10000),
-            DirCount = (ulong)new Random().Next(0, 10000),
-        };
-        RemoteSize = new MutagenSessionModelEndpointSize
-        {
-            SizeBytes = (ulong)new Random().Next(0, 1000000000),
-            FileCount = (ulong)new Random().Next(0, 10000),
-            DirCount = (ulong)new Random().Next(0, 10000),
-        };
-        MaxSize = new MutagenSessionModelEndpointSize
-        {
-            SizeBytes = ulong.Max(LocalSize.SizeBytes, RemoteSize.SizeBytes),
-            FileCount = ulong.Max(LocalSize.FileCount, RemoteSize.FileCount),
-            DirCount = ulong.Max(LocalSize.DirCount, RemoteSize.DirCount),
-            SymlinkCount = ulong.Max(LocalSize.SymlinkCount, RemoteSize.SymlinkCount),
-        };
-
-        Errors = errors;
-    }
-
-    public MutagenSessionModel(State state)
-    {
-        Identifier = state.Session.Identifier;
-        Name = state.Session.Name;
-
-        // If the protocol isn't what we expect for alpha or beta, show
-        // "unknown".
-        if (state.Session.Alpha.Protocol == Protocol.Local && !string.IsNullOrWhiteSpace(state.Session.Alpha.Path))
-            LocalPath = state.Session.Alpha.Path;
-        if (state.Session.Beta.Protocol == Protocol.Ssh)
-        {
-            if (string.IsNullOrWhiteSpace(state.Session.Beta.Host))
-            {
-                var name = state.Session.Beta.Host;
-                // TODO: this will need to be compatible with custom hostname
-                //       suffixes
-                if (name.EndsWith(".coder")) name = name[..^6];
-                RemoteName = name;
-            }
-
-            if (string.IsNullOrWhiteSpace(state.Session.Beta.Path)) RemotePath = state.Session.Beta.Path;
-        }
-
-        if (state.Session.Paused)
-        {
-            // Disregard any status if it's paused.
-            Status = MutagenSessionStatus.Paused;
-            StatusString = "Paused";
-            StatusDescription = "The session is paused.";
-        }
-        else
-        {
-            Status = MutagenSessionModelUtils.StatusFromProtoStatus(state.Status);
-            StatusString = MutagenSessionModelUtils.ProtoStatusToDisplayString(state.Status);
-            StatusDescription = MutagenSessionModelUtils.ProtoStatusToDescription(state.Status);
-        }
-
-        // If there are any conflicts, set the status to NeedsAttention.
-        if (state.Conflicts.Count > 0 && Status > MutagenSessionStatus.NeedsAttention)
-        {
-            Status = MutagenSessionStatus.NeedsAttention;
-            StatusString = "Conflicts";
-            StatusDescription = "The session has conflicts that need to be resolved.";
-        }
-
-        LocalSize = new MutagenSessionModelEndpointSize
-        {
-            SizeBytes = state.AlphaState.TotalFileSize,
-            FileCount = state.AlphaState.Files,
-            DirCount = state.AlphaState.Directories,
-            SymlinkCount = state.AlphaState.SymbolicLinks,
-        };
-        RemoteSize = new MutagenSessionModelEndpointSize
-        {
-            SizeBytes = state.BetaState.TotalFileSize,
-            FileCount = state.BetaState.Files,
-            DirCount = state.BetaState.Directories,
-            SymlinkCount = state.BetaState.SymbolicLinks,
-        };
-        MaxSize = new MutagenSessionModelEndpointSize
-        {
-            SizeBytes = ulong.Max(LocalSize.SizeBytes, RemoteSize.SizeBytes),
-            FileCount = ulong.Max(LocalSize.FileCount, RemoteSize.FileCount),
-            DirCount = ulong.Max(LocalSize.DirCount, RemoteSize.DirCount),
-            SymlinkCount = ulong.Max(LocalSize.SymlinkCount, RemoteSize.SymlinkCount),
-        };
-
-        // TODO: accumulate errors, there seems to be multiple fields they can
-        //       come from
-        if (!string.IsNullOrWhiteSpace(state.LastError)) Errors = [state.LastError];
-    }
-}
-
-public static class MutagenSessionModelUtils
-{
-    public static MutagenSessionStatus StatusFromProtoStatus(Status protoStatus)
-    {
-        switch (protoStatus)
-        {
-            case Status.Disconnected:
-            case Status.HaltedOnRootEmptied:
-            case Status.HaltedOnRootDeletion:
-            case Status.HaltedOnRootTypeChange:
-            case Status.WaitingForRescan:
-                return MutagenSessionStatus.Error;
-            case Status.ConnectingAlpha:
-            case Status.ConnectingBeta:
-            case Status.Scanning:
-            case Status.Reconciling:
-            case Status.StagingAlpha:
-            case Status.StagingBeta:
-            case Status.Transitioning:
-            case Status.Saving:
-                return MutagenSessionStatus.Working;
-            case Status.Watching:
-                return MutagenSessionStatus.Ok;
-            default:
-                return MutagenSessionStatus.Unknown;
-        }
-    }
-
-    public static string ProtoStatusToDisplayString(Status protoStatus)
-    {
-        switch (protoStatus)
-        {
-            case Status.Disconnected:
-                return "Disconnected";
-            case Status.HaltedOnRootEmptied:
-                return "Halted on root emptied";
-            case Status.HaltedOnRootDeletion:
-                return "Halted on root deletion";
-            case Status.HaltedOnRootTypeChange:
-                return "Halted on root type change";
-            case Status.ConnectingAlpha:
-                // This string was changed from "alpha" to "local".
-                return "Connecting (local)";
-            case Status.ConnectingBeta:
-                // This string was changed from "beta" to "remote".
-                return "Connecting (remote)";
-            case Status.Watching:
-                return "Watching";
-            case Status.Scanning:
-                return "Scanning";
-            case Status.WaitingForRescan:
-                return "Waiting for rescan";
-            case Status.Reconciling:
-                return "Reconciling";
-            case Status.StagingAlpha:
-                // This string was changed from "alpha" to "local".
-                return "Staging (local)";
-            case Status.StagingBeta:
-                // This string was changed from "beta" to "remote".
-                return "Staging (remote)";
-            case Status.Transitioning:
-                return "Transitioning";
-            case Status.Saving:
-                return "Saving";
-            default:
-                return protoStatus.ToString();
-        }
-    }
-
-    public static string ProtoStatusToDescription(Status protoStatus)
-    {
-        // These descriptions were mostly taken from the protobuf.
-        switch (protoStatus)
-        {
-            case Status.Disconnected:
-                return "The session is unpaused but not currently connected or connecting to either endpoint.";
-            case Status.HaltedOnRootEmptied:
-                return "The session is halted due to the root emptying safety check.";
-            case Status.HaltedOnRootDeletion:
-                return "The session is halted due to the root deletion safety check.";
-            case Status.HaltedOnRootTypeChange:
-                return "The session is halted due to the root type change safety check.";
-            case Status.ConnectingAlpha:
-                // This string was changed from "alpha" to "local".
-                return "The session is attempting to connect to the local endpoint.";
-            case Status.ConnectingBeta:
-                // This string was changed from "beta" to "remote".
-                return "The session is attempting to connect to the remote endpoint.";
-            case Status.Watching:
-                return "The session is watching for filesystem changes.";
-            case Status.Scanning:
-                return "The session is scanning the filesystem on each endpoint.";
-            case Status.WaitingForRescan:
-                return
-                    "The session is waiting to retry scanning after an error during the previous scanning operation.";
-            case Status.Reconciling:
-                return "The session is performing reconciliation.";
-            case Status.StagingAlpha:
-                // This string was changed from "on alpha" to "locally".
-                return "The session is staging files locally.";
-            case Status.StagingBeta:
-                // This string was changed from "beta" to "the remote".
-                return "The session is staging files on the remote.";
-            case Status.Transitioning:
-                return "The session is performing transition operations on each endpoint.";
-            case Status.Saving:
-                return "The session is recording synchronization history to disk.";
-            default:
-                return "Unknown status message.";
-        }
-    }
-}
diff --git a/App/Models/SyncSessionModel.cs b/App/Models/SyncSessionModel.cs
new file mode 100644
index 0000000..7953720
--- /dev/null
+++ b/App/Models/SyncSessionModel.cs
@@ -0,0 +1,249 @@
+using System;
+using Coder.Desktop.App.Converters;
+using Coder.Desktop.MutagenSdk.Proto.Synchronization;
+using Coder.Desktop.MutagenSdk.Proto.Url;
+
+namespace Coder.Desktop.App.Models;
+
+// This is a much slimmer enum than the original enum from Mutagen and only
+// contains the overarching states that we care about from a code perspective.
+// We still store the original state in the model for rendering purposes.
+public enum SyncSessionStatusCategory
+{
+    Unknown,
+    Paused,
+    Error,
+    Conflicts,
+    Working,
+    Ok,
+}
+
+public sealed class SyncSessionModelEndpointSize
+{
+    public ulong SizeBytes { get; init; }
+    public ulong FileCount { get; init; }
+    public ulong DirCount { get; init; }
+    public ulong SymlinkCount { get; init; }
+
+    public string Description(string linePrefix = "")
+    {
+        var str =
+            $"{linePrefix}{FriendlyByteConverter.FriendlyBytes(SizeBytes)}\n" +
+            $"{linePrefix}{FileCount:N0} files\n" +
+            $"{linePrefix}{DirCount:N0} directories";
+        if (SymlinkCount > 0) str += $"\n{linePrefix} {SymlinkCount:N0} symlinks";
+
+        return str;
+    }
+}
+
+public class SyncSessionModel
+{
+    public readonly string Identifier;
+    public readonly string Name;
+
+    public readonly string LocalPath = "Unknown";
+    public readonly string RemoteName = "Unknown";
+    public readonly string RemotePath = "Unknown";
+
+    public readonly SyncSessionStatusCategory StatusCategory;
+    public readonly string StatusString;
+    public readonly string StatusDescription;
+
+    public readonly SyncSessionModelEndpointSize LocalSize;
+    public readonly SyncSessionModelEndpointSize RemoteSize;
+
+    public readonly string[] Errors = [];
+
+    public string StatusDetails
+    {
+        get
+        {
+            var str = $"{StatusString} ({StatusCategory})\n\n{StatusDescription}";
+            foreach (var err in Errors) str += $"\n\n{err}";
+            return str;
+        }
+    }
+
+    public string SizeDetails
+    {
+        get
+        {
+            var str = "Local:\n" + LocalSize.Description("  ") + "\n\n" +
+                      "Remote:\n" + RemoteSize.Description("  ");
+            return str;
+        }
+    }
+
+    // TODO: remove once we process sessions from the mutagen RPC
+    public SyncSessionModel(string localPath, string remoteName, string remotePath,
+        SyncSessionStatusCategory statusCategory,
+        string statusString, string statusDescription, string[] errors)
+    {
+        Identifier = "TODO";
+        Name = "TODO";
+
+        LocalPath = localPath;
+        RemoteName = remoteName;
+        RemotePath = remotePath;
+        StatusCategory = statusCategory;
+        StatusString = statusString;
+        StatusDescription = statusDescription;
+        LocalSize = new SyncSessionModelEndpointSize
+        {
+            SizeBytes = (ulong)new Random().Next(0, 1000000000),
+            FileCount = (ulong)new Random().Next(0, 10000),
+            DirCount = (ulong)new Random().Next(0, 10000),
+        };
+        RemoteSize = new SyncSessionModelEndpointSize
+        {
+            SizeBytes = (ulong)new Random().Next(0, 1000000000),
+            FileCount = (ulong)new Random().Next(0, 10000),
+            DirCount = (ulong)new Random().Next(0, 10000),
+        };
+
+        Errors = errors;
+    }
+
+    public SyncSessionModel(State state)
+    {
+        Identifier = state.Session.Identifier;
+        Name = state.Session.Name;
+
+        // If the protocol isn't what we expect for alpha or beta, show
+        // "unknown".
+        if (state.Session.Alpha.Protocol == Protocol.Local && !string.IsNullOrWhiteSpace(state.Session.Alpha.Path))
+            LocalPath = state.Session.Alpha.Path;
+        if (state.Session.Beta.Protocol == Protocol.Ssh)
+        {
+            if (string.IsNullOrWhiteSpace(state.Session.Beta.Host))
+            {
+                var name = state.Session.Beta.Host;
+                // TODO: this will need to be compatible with custom hostname
+                //       suffixes
+                if (name.EndsWith(".coder")) name = name[..^6];
+                RemoteName = name;
+            }
+
+            if (string.IsNullOrWhiteSpace(state.Session.Beta.Path)) RemotePath = state.Session.Beta.Path;
+        }
+
+        if (state.Session.Paused)
+        {
+            // Disregard any status if it's paused.
+            StatusCategory = SyncSessionStatusCategory.Paused;
+            StatusString = "Paused";
+            StatusDescription = "The session is paused.";
+        }
+        else
+        {
+            switch (state.Status)
+            {
+                case Status.Disconnected:
+                    StatusCategory = SyncSessionStatusCategory.Error;
+                    StatusString = "Disconnected";
+                    StatusDescription =
+                        "The session is unpaused but not currently connected or connecting to either endpoint.";
+                    break;
+                case Status.HaltedOnRootEmptied:
+                    StatusCategory = SyncSessionStatusCategory.Error;
+                    StatusString = "Halted on root emptied";
+                    StatusDescription = "The session is halted due to the root emptying safety check.";
+                    break;
+                case Status.HaltedOnRootDeletion:
+                    StatusCategory = SyncSessionStatusCategory.Error;
+                    StatusString = "Halted on root deletion";
+                    StatusDescription = "The session is halted due to the root deletion safety check.";
+                    break;
+                case Status.HaltedOnRootTypeChange:
+                    StatusCategory = SyncSessionStatusCategory.Error;
+                    StatusString = "Halted on root type change";
+                    StatusDescription = "The session is halted due to the root type change safety check.";
+                    break;
+                case Status.ConnectingAlpha:
+                    StatusCategory = SyncSessionStatusCategory.Working;
+                    StatusString = "Connecting (alpha)";
+                    StatusDescription = "The session is attempting to connect to the alpha endpoint.";
+                    break;
+                case Status.ConnectingBeta:
+                    StatusCategory = SyncSessionStatusCategory.Working;
+                    StatusString = "Connecting (beta)";
+                    StatusDescription = "The session is attempting to connect to the beta endpoint.";
+                    break;
+                case Status.Watching:
+                    StatusCategory = SyncSessionStatusCategory.Ok;
+                    StatusString = "Watching";
+                    StatusDescription = "The session is watching for filesystem changes.";
+                    break;
+                case Status.Scanning:
+                    StatusCategory = SyncSessionStatusCategory.Working;
+                    StatusString = "Scanning";
+                    StatusDescription = "The session is scanning the filesystem on each endpoint.";
+                    break;
+                case Status.WaitingForRescan:
+                    StatusCategory = SyncSessionStatusCategory.Working;
+                    StatusString = "Waiting for rescan";
+                    StatusDescription =
+                        "The session is waiting to retry scanning after an error during the previous scanning operation.";
+                    break;
+                case Status.Reconciling:
+                    StatusCategory = SyncSessionStatusCategory.Working;
+                    StatusString = "Reconciling";
+                    StatusDescription = "The session is performing reconciliation.";
+                    break;
+                case Status.StagingAlpha:
+                    StatusCategory = SyncSessionStatusCategory.Working;
+                    StatusString = "Staging (alpha)";
+                    StatusDescription = "The session is staging files on alpha.";
+                    break;
+                case Status.StagingBeta:
+                    StatusCategory = SyncSessionStatusCategory.Working;
+                    StatusString = "Staging (beta)";
+                    StatusDescription = "The session is staging files on beta.";
+                    break;
+                case Status.Transitioning:
+                    StatusCategory = SyncSessionStatusCategory.Working;
+                    StatusString = "Transitioning";
+                    StatusDescription = "The session is performing transition operations on each endpoint.";
+                    break;
+                case Status.Saving:
+                    StatusCategory = SyncSessionStatusCategory.Working;
+                    StatusString = "Saving";
+                    StatusDescription = "The session is recording synchronization history to disk.";
+                    break;
+                default:
+                    StatusCategory = SyncSessionStatusCategory.Unknown;
+                    StatusString = state.Status.ToString();
+                    StatusDescription = "Unknown status message.";
+                    break;
+            }
+        }
+
+        // If there are any conflicts, set the status to Conflicts.
+        if (state.Conflicts.Count > 0 && StatusCategory > SyncSessionStatusCategory.Conflicts)
+        {
+            StatusCategory = SyncSessionStatusCategory.Conflicts;
+            StatusString = "Conflicts";
+            StatusDescription = "The session has conflicts that need to be resolved.";
+        }
+
+        LocalSize = new SyncSessionModelEndpointSize
+        {
+            SizeBytes = state.AlphaState.TotalFileSize,
+            FileCount = state.AlphaState.Files,
+            DirCount = state.AlphaState.Directories,
+            SymlinkCount = state.AlphaState.SymbolicLinks,
+        };
+        RemoteSize = new SyncSessionModelEndpointSize
+        {
+            SizeBytes = state.BetaState.TotalFileSize,
+            FileCount = state.BetaState.Files,
+            DirCount = state.BetaState.Directories,
+            SymlinkCount = state.BetaState.SymbolicLinks,
+        };
+
+        // TODO: accumulate errors, there seems to be multiple fields they can
+        //       come from
+        if (!string.IsNullOrWhiteSpace(state.LastError)) Errors = [state.LastError];
+    }
+}
diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs
index 7f48426..fc6546e 100644
--- a/App/Services/MutagenController.cs
+++ b/App/Services/MutagenController.cs
@@ -5,6 +5,7 @@
 using System.IO;
 using System.Threading;
 using System.Threading.Tasks;
+using Coder.Desktop.App.Models;
 using Coder.Desktop.MutagenSdk;
 using Coder.Desktop.MutagenSdk.Proto.Selection;
 using Coder.Desktop.MutagenSdk.Proto.Service.Daemon;
@@ -15,28 +16,17 @@
 
 namespace Coder.Desktop.App.Services;
 
-// <summary>
-// A file synchronization session to a Coder workspace agent.
-// </summary>
-// <remarks>
-// This implementation is a placeholder while implementing the daemon lifecycle. It's implementation
-// will be backed by the MutagenSDK eventually.
-// </remarks>
-public class SyncSession
+public class CreateSyncSessionRequest
 {
-    public string name { get; init; } = "";
-    public string localPath { get; init; } = "";
-    public string workspace { get; init; } = "";
-    public string agent { get; init; } = "";
-    public string remotePath { get; init; } = "";
+    // TODO: this
 }
 
 public interface ISyncSessionController
 {
-    Task<List<SyncSession>> ListSyncSessions(CancellationToken ct);
-    Task<SyncSession> CreateSyncSession(SyncSession session, CancellationToken ct);
+    Task<IEnumerable<SyncSessionModel>> ListSyncSessions(CancellationToken ct);
+    Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct);
 
-    Task TerminateSyncSession(SyncSession session, CancellationToken ct);
+    Task TerminateSyncSession(string identifier, CancellationToken ct);
 
     // <summary>
     // Initializes the controller; running the daemon if there are any saved sessions. Must be called and
@@ -121,7 +111,7 @@ public async ValueTask DisposeAsync()
     }
 
 
-    public async Task<SyncSession> CreateSyncSession(SyncSession session, CancellationToken ct)
+    public async Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct)
     {
         // reads of _sessionCount are atomic, so don't bother locking for this quick check.
         if (_sessionCount == -1) throw new InvalidOperationException("Controller must be Initialized first");
@@ -132,11 +122,10 @@ public async Task<SyncSession> CreateSyncSession(SyncSession session, Cancellati
             _sessionCount += 1;
         }
 
-        return session;
+        throw new NotImplementedException();
     }
 
-
-    public async Task<List<SyncSession>> ListSyncSessions(CancellationToken ct)
+    public async Task<IEnumerable<SyncSessionModel>> ListSyncSessions(CancellationToken ct)
     {
         // reads of _sessionCount are atomic, so don't bother locking for this quick check.
         switch (_sessionCount)
@@ -146,12 +135,10 @@ public async Task<List<SyncSession>> ListSyncSessions(CancellationToken ct)
             case 0:
                 // If we already know there are no sessions, don't start up the daemon
                 // again.
-                return new List<SyncSession>();
+                return [];
         }
 
-        var client = await EnsureDaemon(ct);
-        // TODO: implement
-        return new List<SyncSession>();
+        throw new NotImplementedException();
     }
 
     public async Task Initialize(CancellationToken ct)
@@ -190,7 +177,7 @@ public async Task Initialize(CancellationToken ct)
         }
     }
 
-    public async Task TerminateSyncSession(SyncSession session, CancellationToken ct)
+    public async Task TerminateSyncSession(string identifier, CancellationToken ct)
     {
         if (_sessionCount < 0) throw new InvalidOperationException("Controller must be Initialized first");
         var client = await EnsureDaemon(ct);
diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs
index 6de170e..0521e48 100644
--- a/App/ViewModels/FileSyncListViewModel.cs
+++ b/App/ViewModels/FileSyncListViewModel.cs
@@ -1,5 +1,7 @@
 using System;
 using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
 using System.Threading.Tasks;
 using Windows.Storage.Pickers;
 using Coder.Desktop.App.Models;
@@ -21,10 +23,23 @@ public partial class FileSyncListViewModel : ObservableObject
 
     private DispatcherQueue? _dispatcherQueue;
 
+    private readonly ISyncSessionController _syncSessionController;
     private readonly IRpcController _rpcController;
     private readonly ICredentialManager _credentialManager;
 
-    [ObservableProperty] public partial List<MutagenSessionModel> Sessions { get; set; } = [];
+    [ObservableProperty]
+    [NotifyPropertyChangedFor(nameof(ShowLoading))]
+    [NotifyPropertyChangedFor(nameof(ShowError))]
+    [NotifyPropertyChangedFor(nameof(ShowSessions))]
+    public partial bool Loading { get; set; } = true;
+
+    [ObservableProperty]
+    [NotifyPropertyChangedFor(nameof(ShowLoading))]
+    [NotifyPropertyChangedFor(nameof(ShowError))]
+    [NotifyPropertyChangedFor(nameof(ShowSessions))]
+    public partial string? Error { get; set; } = null;
+
+    [ObservableProperty] public partial List<SyncSessionModel> Sessions { get; set; } = [];
 
     [ObservableProperty] public partial bool CreatingNewSession { get; set; } = false;
 
@@ -57,24 +72,31 @@ public bool NewSessionCreateEnabled
         }
     }
 
-    public FileSyncListViewModel(IRpcController rpcController, ICredentialManager credentialManager)
+    public bool ShowLoading => Loading && Error == null;
+    public bool ShowError => Error != null;
+    public bool ShowSessions => !Loading && Error == null;
+
+    public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcController rpcController,
+        ICredentialManager credentialManager)
     {
+        _syncSessionController = syncSessionController;
         _rpcController = rpcController;
         _credentialManager = credentialManager;
 
         Sessions =
         [
-            new MutagenSessionModel(@"C:\Users\dean\git\coder-desktop-windows", "pog", "~/repos/coder-desktop-windows",
-                MutagenSessionStatus.Ok, "Watching", "Some description", []),
-            new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.Paused, "Paused",
+            new SyncSessionModel(@"C:\Users\dean\git\coder-desktop-windows", "pog", "~/repos/coder-desktop-windows",
+                SyncSessionStatusCategory.Ok, "Watching", "Some description", []),
+            new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Paused,
+                "Paused",
                 "Some description", []),
-            new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.NeedsAttention,
+            new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Conflicts,
                 "Conflicts", "Some description", []),
-            new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.Error,
+            new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Error,
                 "Halted on root emptied", "Some description", []),
-            new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.Unknown,
+            new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Unknown,
                 "Unknown", "Some description", []),
-            new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.Working,
+            new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Working,
                 "Reconciling", "Some description", []),
         ];
     }
@@ -88,7 +110,11 @@ public void Initialize(DispatcherQueue dispatcherQueue)
 
         var rpcModel = _rpcController.GetState();
         var credentialModel = _credentialManager.GetCachedCredentials();
-        MaybeSendStaleEvent(rpcModel, credentialModel);
+        // TODO: fix this
+        //if (MaybeSendStaleEvent(rpcModel, credentialModel)) return;
+
+        // TODO: Simulate loading until we have real data.
+        Task.Delay(TimeSpan.FromSeconds(3)).ContinueWith(_ => _dispatcherQueue.TryEnqueue(() => Loading = false));
     }
 
     private void UpdateFromRpcModel(RpcModel rpcModel)
@@ -119,24 +145,57 @@ private void UpdateFromCredentialsModel(CredentialModel credentialModel)
         MaybeSendStaleEvent(rpcModel, credentialModel);
     }
 
-    private void MaybeSendStaleEvent(RpcModel rpcModel, CredentialModel credentialModel)
+    private bool MaybeSendStaleEvent(RpcModel rpcModel, CredentialModel credentialModel)
     {
         var ok = rpcModel.RpcLifecycle is RpcLifecycle.Connected
                  && rpcModel.VpnLifecycle is VpnLifecycle.Started
                  && credentialModel.State == CredentialState.Valid;
 
         if (!ok) OnFileSyncListStale?.Invoke();
+        return !ok;
     }
 
     private void ClearNewForm()
     {
         CreatingNewSession = false;
         NewSessionLocalPath = "";
-        // TODO: close the dialog somehow
         NewSessionRemoteName = "";
         NewSessionRemotePath = "";
     }
 
+    [RelayCommand]
+    private void ReloadSessions()
+    {
+        Loading = true;
+        Error = null;
+        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+        _ = _syncSessionController.ListSyncSessions(cts.Token).ContinueWith(HandleList, cts.Token);
+    }
+
+    private void HandleList(Task<IEnumerable<SyncSessionModel>> t)
+    {
+        // Ensure we're on the UI thread.
+        if (_dispatcherQueue == null) return;
+        if (!_dispatcherQueue.HasThreadAccess)
+        {
+            _dispatcherQueue.TryEnqueue(() => HandleList(t));
+            return;
+        }
+
+        if (t.IsCompletedSuccessfully)
+        {
+            Sessions = t.Result.ToList();
+            Loading = false;
+            return;
+        }
+
+        Error = "Could not list sync sessions: ";
+        if (t.IsCanceled) Error += new TaskCanceledException();
+        else if (t.IsFaulted) Error += t.Exception;
+        else Error += "no successful result or error";
+        Loading = false;
+    }
+
     [RelayCommand]
     private void StartCreatingNewSession()
     {
@@ -149,8 +208,6 @@ public async Task OpenLocalPathSelectDialog(Window window)
         var picker = new FolderPicker
         {
             SuggestedStartLocation = PickerLocationId.ComputerFolder,
-            // TODO: Needed?
-            //FileTypeFilter = { "*" },
         };
 
         var hwnd = WindowNative.GetWindowHandle(window);
diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs
index f4c4484..532bfe4 100644
--- a/App/ViewModels/TrayWindowViewModel.cs
+++ b/App/ViewModels/TrayWindowViewModel.cs
@@ -9,6 +9,7 @@
 using CommunityToolkit.Mvvm.ComponentModel;
 using CommunityToolkit.Mvvm.Input;
 using Google.Protobuf;
+using Microsoft.Extensions.DependencyInjection;
 using Microsoft.UI.Dispatching;
 using Microsoft.UI.Xaml;
 using Microsoft.UI.Xaml.Controls;
@@ -21,9 +22,12 @@ public partial class TrayWindowViewModel : ObservableObject
     private const int MaxAgents = 5;
     private const string DefaultDashboardUrl = "https://coder.com";
 
+    private readonly IServiceProvider _services;
     private readonly IRpcController _rpcController;
     private readonly ICredentialManager _credentialManager;
 
+    private FileSyncListWindow? _fileSyncListWindow;
+
     private DispatcherQueue? _dispatcherQueue;
 
     [ObservableProperty]
@@ -74,8 +78,10 @@ public partial class TrayWindowViewModel : ObservableObject
 
     [ObservableProperty] public partial string DashboardUrl { get; set; } = "https://coder.com";
 
-    public TrayWindowViewModel(IRpcController rpcController, ICredentialManager credentialManager)
+    public TrayWindowViewModel(IServiceProvider services, IRpcController rpcController,
+        ICredentialManager credentialManager)
     {
+        _services = services;
         _rpcController = rpcController;
         _credentialManager = credentialManager;
     }
@@ -272,16 +278,24 @@ public void ToggleShowAllAgents()
     }
 
     [RelayCommand]
-    public void SignOut()
+    public void ShowFileSyncListWindow()
     {
-        // TODO: Remove this debug workaround once we have a real UI to open
-        //       the sync window. This lets us open the file sync list window
-        //       in debug builds.
-#if DEBUG
-        new FileSyncListWindow(new FileSyncListViewModel(_rpcController, _credentialManager)).Activate();
-        return;
-#endif
+        // This is safe against concurrent access since it all happens in the
+        // UI thread.
+        if (_fileSyncListWindow != null)
+        {
+            _fileSyncListWindow.Activate();
+            return;
+        }
 
+        _fileSyncListWindow = _services.GetRequiredService<FileSyncListWindow>();
+        _fileSyncListWindow.Closed += (_, _) => _fileSyncListWindow = null;
+        _fileSyncListWindow.Activate();
+    }
+
+    [RelayCommand]
+    public void SignOut()
+    {
         if (VpnLifecycle is not VpnLifecycle.Stopped)
             return;
         _credentialManager.ClearCredentials();
diff --git a/App/Views/FileSyncListWindow.xaml b/App/Views/FileSyncListWindow.xaml
index ae95e8b..070efd2 100644
--- a/App/Views/FileSyncListWindow.xaml
+++ b/App/Views/FileSyncListWindow.xaml
@@ -8,7 +8,7 @@
     xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
     xmlns:winuiex="using:WinUIEx"
     mc:Ignorable="d"
-    Title="Coder Desktop"
+    Title="Coder File Sync"
     Width="1000" Height="300"
     MinWidth="1000" MinHeight="300">
 
diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml
index e6b7db3..8080b79 100644
--- a/App/Views/Pages/FileSyncListMainPage.xaml
+++ b/App/Views/Pages/FileSyncListMainPage.xaml
@@ -11,259 +11,307 @@
     mc:Ignorable="d"
     Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
 
-    <ScrollView>
-        <StackPanel Orientation="Vertical" Padding="30,15">
-            <!--
-                We use separate grids for the header and each child because WinUI 3
-                doesn't support having a dynamic row count.
+    <Grid>
+        <Grid
+            Visibility="{x:Bind ViewModel.ShowLoading, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
+            Padding="60,60"
+            HorizontalAlignment="Center"
+            VerticalAlignment="Center">
 
-                This unfortunately means we need to copy the resources and the
-                column definitions to each Grid.
-            -->
-            <Grid Margin="0,0,0,5">
-                <Grid.Resources>
-                    <Style TargetType="TextBlock">
-                        <Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" />
-                    </Style>
-                    <Style TargetType="Border">
-                        <Setter Property="Padding" Value="40,0,0,0" />
-                    </Style>
-                </Grid.Resources>
+            <ProgressRing
+                Width="32"
+                Height="32"
+                Margin="0,30"
+                HorizontalAlignment="Center" />
 
-                <!-- Cannot use "Auto" as it won't work for multiple Grids. -->
-                <Grid.ColumnDefinitions>
-                    <!-- Icon column: 14 + 5 padding + 14 + 10 padding -->
-                    <ColumnDefinition Width="43" />
-                    <ColumnDefinition Width="2*" MinWidth="200" />
-                    <ColumnDefinition Width="1*" MinWidth="120" />
-                    <ColumnDefinition Width="2*" MinWidth="200" />
-                    <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
-                    <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
-                </Grid.ColumnDefinitions>
+            <TextBlock HorizontalAlignment="Center" Text="Loading sync sessions..." />
+        </Grid>
 
-                <Border Grid.Column="1" Padding="10,0,0,0">
-                    <TextBlock Text="Local Path" />
-                </Border>
-                <Border Grid.Column="2">
-                    <TextBlock Text="Workspace" />
-                </Border>
-                <Border Grid.Column="3">
-                    <TextBlock Text="Remote Path" />
-                </Border>
-                <Border Grid.Column="4">
-                    <TextBlock Text="Status" />
-                </Border>
-                <Border Grid.Column="5">
-                    <TextBlock Text="Size" />
-                </Border>
-            </Grid>
+        <StackPanel
+            Visibility="{x:Bind ViewModel.ShowError, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
+            Orientation="Vertical"
+            Padding="20">
 
-            <Border
-                Height="1"
-                Margin="-30,0,-30,5"
-                Background="{ThemeResource ControlElevationBorderBrush}" />
+            <TextBlock
+                Margin="0,0,0,20"
+                Foreground="Red"
+                TextWrapping="Wrap"
+                Text="{x:Bind ViewModel.Error, Mode=OneWay}" />
 
-            <ItemsRepeater ItemsSource="{x:Bind ViewModel.Sessions, Mode=OneWay}">
-                <ItemsRepeater.Layout>
-                    <StackLayout Orientation="Vertical" />
-                </ItemsRepeater.Layout>
+            <Button Command="{x:Bind ViewModel.ReloadSessionsCommand, Mode=OneWay}">
+                <TextBlock Text="Reload" />
+            </Button>
+        </StackPanel>
 
-                <ItemsRepeater.ItemTemplate>
-                    <DataTemplate x:DataType="models:MutagenSessionModel">
-                        <Grid Margin="0,10">
-                            <!-- These are (mostly) from the header Grid and should be copied here -->
-                            <Grid.Resources>
-                                <Style TargetType="Border">
-                                    <Setter Property="Padding" Value="40,0,0,0" />
-                                </Style>
-                            </Grid.Resources>
-                            <Grid.ColumnDefinitions>
-                                <ColumnDefinition Width="43" />
-                                <ColumnDefinition Width="2*" MinWidth="200" />
-                                <ColumnDefinition Width="1*" MinWidth="120" />
-                                <ColumnDefinition Width="2*" MinWidth="200" />
-                                <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
-                                <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
-                            </Grid.ColumnDefinitions>
+        <!-- This grid lets us fix the header and only scroll the content. -->
+        <Grid
+            Visibility="{x:Bind ViewModel.ShowSessions, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}">
+            <Grid.RowDefinitions>
+                <RowDefinition Height="Auto" />
+                <RowDefinition Height="*" />
+            </Grid.RowDefinitions>
 
-                            <Border Grid.Column="0" Padding="0" HorizontalAlignment="Right">
-                                <StackPanel Orientation="Horizontal">
-                                    <HyperlinkButton Padding="0" Margin="0,0,5,0">
-                                        <FontIcon Glyph="&#xE769;" FontSize="15"
-                                                  Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
-                                    </HyperlinkButton>
-                                    <HyperlinkButton Padding="0">
-                                        <FontIcon Glyph="&#xF140;" FontSize="15"
-                                                  Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
-                                    </HyperlinkButton>
-                                </StackPanel>
-                            </Border>
-                            <Border Grid.Column="1" Padding="10,0,0,0">
-                                <TextBlock
-                                    Text="{x:Bind LocalPath}"
-                                    TextTrimming="CharacterEllipsis"
-                                    IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" />
-                            </Border>
-                            <Border Grid.Column="2">
-                                <TextBlock
-                                    Text="{x:Bind RemoteName}"
-                                    TextTrimming="CharacterEllipsis"
-                                    IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" />
-                            </Border>
-                            <Border Grid.Column="3">
-                                <TextBlock
-                                    Text="{x:Bind RemotePath}"
-                                    TextTrimming="CharacterEllipsis"
-                                    IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" />
-                            </Border>
-                            <Border Grid.Column="4">
-                                <Border.Resources>
-                                    <converters:StringToBrushSelector
-                                        x:Key="StatusColor"
-                                        SelectedKey="{x:Bind Path=Status}">
+            <StackPanel
+                Grid.Row="0"
+                Orientation="Vertical"
+                Padding="30,15,30,0">
 
-                                        <converters:StringToBrushSelectorItem
-                                            Value="{ThemeResource SystemFillColorCriticalBrush}" />
-                                        <converters:StringToBrushSelectorItem
-                                            Key="Paused"
-                                            Value="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
-                                        <converters:StringToBrushSelectorItem
-                                            Key="Error"
-                                            Value="{ThemeResource SystemFillColorCriticalBrush}" />
-                                        <converters:StringToBrushSelectorItem
-                                            Key="NeedsAttention"
-                                            Value="{ThemeResource SystemFillColorCautionBrush}" />
-                                        <converters:StringToBrushSelectorItem
-                                            Key="Working"
-                                            Value="{ThemeResource SystemFillColorAttentionBrush}" />
-                                        <converters:StringToBrushSelectorItem
-                                            Key="Ok"
-                                            Value="{ThemeResource DefaultTextForegroundThemeBrush}" />
-                                    </converters:StringToBrushSelector>
-                                </Border.Resources>
-                                <TextBlock
-                                    Text="{x:Bind StatusString}"
-                                    TextTrimming="CharacterEllipsis"
-                                    Foreground="{Binding Source={StaticResource StatusColor}, Path=SelectedObject}"
-                                    ToolTipService.ToolTip="{x:Bind StatusDetails}" />
-                            </Border>
-                            <Border Grid.Column="5">
-                                <TextBlock
-                                    Text="{x:Bind MaxSize.SizeBytes, Converter={StaticResource FriendlyByteConverter}}"
-                                    ToolTipService.ToolTip="{x:Bind SizeDetails}" />
-                            </Border>
-                        </Grid>
-                    </DataTemplate>
-                </ItemsRepeater.ItemTemplate>
-            </ItemsRepeater>
+                <!--
+                    We use separate grids for the header and each child because WinUI 3
+                    doesn't support having a dynamic row count.
 
-            <!-- "New Sync" button -->
-            <!--
-                HACK: this has some random numbers for padding and margins. Since
-                we need to align the icon and the text to the two grid columns
-                above (but still have it be within the same button), this is the
-                best solution I could come up with.
-            -->
-            <HyperlinkButton
-                Margin="13,5,0,0"
-                Command="{x:Bind ViewModel.StartCreatingNewSessionCommand}"
-                Visibility="{x:Bind ViewModel.CreatingNewSession, Converter={StaticResource InverseBoolToVisibilityConverter}, Mode=OneWay}">
+                    This unfortunately means we need to copy the resources and the
+                    column definitions to each Grid.
+                -->
+                <Grid Margin="0,0,0,5">
+                    <Grid.Resources>
+                        <Style TargetType="TextBlock">
+                            <Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" />
+                        </Style>
+                        <Style TargetType="Border">
+                            <Setter Property="Padding" Value="40,0,0,0" />
+                        </Style>
+                    </Grid.Resources>
 
-                <StackPanel Orientation="Horizontal">
-                    <FontIcon
-                        FontSize="18"
-                        Margin="0,0,10,0"
-                        Glyph="&#xE710;"
-                        Foreground="{ThemeResource SystemFillColorSuccessBrush}" />
-                    <TextBlock
-                        Text="New Sync"
-                        Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
-                </StackPanel>
-            </HyperlinkButton>
+                    <!-- Cannot use "Auto" as it won't work for multiple Grids. -->
+                    <Grid.ColumnDefinitions>
+                        <!-- Icon column: 14 + 5 padding + 14 + 10 padding -->
+                        <ColumnDefinition Width="43" />
+                        <ColumnDefinition Width="2*" MinWidth="200" />
+                        <ColumnDefinition Width="1*" MinWidth="120" />
+                        <ColumnDefinition Width="2*" MinWidth="200" />
+                        <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
+                        <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
+                    </Grid.ColumnDefinitions>
+
+                    <Border Grid.Column="1" Padding="10,0,0,0">
+                        <TextBlock Text="Local Path" />
+                    </Border>
+                    <Border Grid.Column="2">
+                        <TextBlock Text="Workspace" />
+                    </Border>
+                    <Border Grid.Column="3">
+                        <TextBlock Text="Remote Path" />
+                    </Border>
+                    <Border Grid.Column="4">
+                        <TextBlock Text="Status" />
+                    </Border>
+                    <Border Grid.Column="5">
+                        <TextBlock Text="Size" />
+                    </Border>
+                </Grid>
+
+                <Border
+                    Height="1"
+                    Margin="-30,0,-30,5"
+                    Background="{ThemeResource ControlElevationBorderBrush}" />
+            </StackPanel>
 
-            <!-- New item Grid -->
-            <Grid
-                Margin="0,10"
-                Visibility="{x:Bind ViewModel.CreatingNewSession, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}">
+            <ScrollView Grid.Row="1">
+                <StackPanel Orientation="Vertical" Padding="30,0,30,15">
+                    <ItemsRepeater ItemsSource="{x:Bind ViewModel.Sessions, Mode=OneWay}">
+                        <ItemsRepeater.Layout>
+                            <StackLayout Orientation="Vertical" />
+                        </ItemsRepeater.Layout>
 
-                <!-- These are (mostly) from the header Grid and should be copied here -->
-                <Grid.Resources>
-                    <Style TargetType="Border">
-                        <Setter Property="Padding" Value="40,0,0,0" />
-                    </Style>
-                </Grid.Resources>
-                <Grid.ColumnDefinitions>
-                    <ColumnDefinition Width="43" />
-                    <ColumnDefinition Width="2*" MinWidth="200" />
-                    <ColumnDefinition Width="1*" MinWidth="120" />
-                    <ColumnDefinition Width="2*" MinWidth="200" />
-                    <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
-                    <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
-                </Grid.ColumnDefinitions>
+                        <ItemsRepeater.ItemTemplate>
+                            <DataTemplate x:DataType="models:SyncSessionModel">
+                                <Grid Margin="0,10">
+                                    <!-- These are (mostly) from the header Grid and should be copied here -->
+                                    <Grid.Resources>
+                                        <Style TargetType="Border">
+                                            <Setter Property="Padding" Value="40,0,0,0" />
+                                        </Style>
+                                    </Grid.Resources>
+                                    <Grid.ColumnDefinitions>
+                                        <ColumnDefinition Width="43" />
+                                        <ColumnDefinition Width="2*" MinWidth="200" />
+                                        <ColumnDefinition Width="1*" MinWidth="120" />
+                                        <ColumnDefinition Width="2*" MinWidth="200" />
+                                        <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
+                                        <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
+                                    </Grid.ColumnDefinitions>
+
+                                    <Border Grid.Column="0" Padding="0" HorizontalAlignment="Right">
+                                        <StackPanel Orientation="Horizontal">
+                                            <HyperlinkButton Padding="0" Margin="0,0,5,0">
+                                                <FontIcon Glyph="&#xE769;" FontSize="15"
+                                                          Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
+                                            </HyperlinkButton>
+                                            <HyperlinkButton Padding="0">
+                                                <FontIcon Glyph="&#xF140;" FontSize="15"
+                                                          Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
+                                            </HyperlinkButton>
+                                        </StackPanel>
+                                    </Border>
+                                    <Border Grid.Column="1" Padding="10,0,0,0">
+                                        <TextBlock
+                                            Text="{x:Bind LocalPath}"
+                                            TextTrimming="CharacterEllipsis"
+                                            IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" />
+                                    </Border>
+                                    <Border Grid.Column="2">
+                                        <TextBlock
+                                            Text="{x:Bind RemoteName}"
+                                            TextTrimming="CharacterEllipsis"
+                                            IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" />
+                                    </Border>
+                                    <Border Grid.Column="3">
+                                        <TextBlock
+                                            Text="{x:Bind RemotePath}"
+                                            TextTrimming="CharacterEllipsis"
+                                            IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" />
+                                    </Border>
+                                    <Border Grid.Column="4">
+                                        <Border.Resources>
+                                            <converters:StringToBrushSelector
+                                                x:Key="StatusColor"
+                                                SelectedKey="{x:Bind Path=StatusCategory}">
+
+                                                <converters:StringToBrushSelectorItem
+                                                    Value="{ThemeResource SystemFillColorCriticalBrush}" />
+                                                <converters:StringToBrushSelectorItem
+                                                    Key="Paused"
+                                                    Value="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
+                                                <converters:StringToBrushSelectorItem
+                                                    Key="Error"
+                                                    Value="{ThemeResource SystemFillColorCriticalBrush}" />
+                                                <converters:StringToBrushSelectorItem
+                                                    Key="Conflicts"
+                                                    Value="{ThemeResource SystemFillColorCautionBrush}" />
+                                                <converters:StringToBrushSelectorItem
+                                                    Key="Working"
+                                                    Value="{ThemeResource SystemFillColorAttentionBrush}" />
+                                                <converters:StringToBrushSelectorItem
+                                                    Key="Ok"
+                                                    Value="{ThemeResource DefaultTextForegroundThemeBrush}" />
+                                            </converters:StringToBrushSelector>
+                                        </Border.Resources>
+                                        <TextBlock
+                                            Text="{x:Bind StatusString}"
+                                            TextTrimming="CharacterEllipsis"
+                                            Foreground="{Binding Source={StaticResource StatusColor}, Path=SelectedObject}"
+                                            ToolTipService.ToolTip="{x:Bind StatusDetails}" />
+                                    </Border>
+                                    <Border Grid.Column="5">
+                                        <TextBlock
+                                            Text="{x:Bind LocalSize.SizeBytes, Converter={StaticResource FriendlyByteConverter}}"
+                                            ToolTipService.ToolTip="{x:Bind SizeDetails}" />
+                                    </Border>
+                                </Grid>
+                            </DataTemplate>
+                        </ItemsRepeater.ItemTemplate>
+                    </ItemsRepeater>
+
+                    <!-- "New Sync" button -->
+                    <!--
+                        HACK: this has some random numbers for padding and margins. Since
+                        we need to align the icon and the text to the two grid columns
+                        above (but still have it be within the same button), this is the
+                        best solution I could come up with.
+                    -->
+                    <HyperlinkButton
+                        Margin="13,5,0,0"
+                        Command="{x:Bind ViewModel.StartCreatingNewSessionCommand}"
+                        Visibility="{x:Bind ViewModel.CreatingNewSession, Converter={StaticResource InverseBoolToVisibilityConverter}, Mode=OneWay}">
 
-                <Border Grid.Column="0" Padding="0">
-                    <StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
-                        <!-- TODO: gray out the button if the form is not filled out correctly -->
-                        <HyperlinkButton
-                            Padding="0"
-                            Margin="0,0,5,0"
-                            Command="{x:Bind ViewModel.ConfirmNewSessionCommand}">
+                        <StackPanel Orientation="Horizontal">
+                            <FontIcon
+                                FontSize="18"
+                                Margin="0,0,10,0"
+                                Glyph="&#xE710;"
+                                Foreground="{ThemeResource SystemFillColorSuccessBrush}" />
+                            <TextBlock
+                                Text="New Sync"
+                                Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
+                        </StackPanel>
+                    </HyperlinkButton>
 
-                            <FontIcon Glyph="&#xE930;" FontSize="15"
-                                      Foreground="{ThemeResource SystemFillColorSuccessBrush}" />
-                        </HyperlinkButton>
-                        <HyperlinkButton
-                            Padding="0"
-                            Command="{x:Bind ViewModel.CancelNewSessionCommand}">
+                    <!-- New item Grid -->
+                    <Grid
+                        Margin="0,10"
+                        Visibility="{x:Bind ViewModel.CreatingNewSession, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}">
 
-                            <FontIcon Glyph="&#xF096;" FontSize="15"
-                                      Foreground="{ThemeResource SystemFillColorCriticalBrush}" />
-                        </HyperlinkButton>
-                    </StackPanel>
-                </Border>
-                <Border Grid.Column="1" Padding="10,0,0,0">
-                    <Grid>
+                        <!-- These are (mostly) from the header Grid and should be copied here -->
+                        <Grid.Resources>
+                            <Style TargetType="Border">
+                                <Setter Property="Padding" Value="40,0,0,0" />
+                            </Style>
+                        </Grid.Resources>
                         <Grid.ColumnDefinitions>
-                            <ColumnDefinition Width="*" />
-                            <ColumnDefinition Width="Auto" />
+                            <ColumnDefinition Width="43" />
+                            <ColumnDefinition Width="2*" MinWidth="200" />
+                            <ColumnDefinition Width="1*" MinWidth="120" />
+                            <ColumnDefinition Width="2*" MinWidth="200" />
+                            <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
+                            <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" />
                         </Grid.ColumnDefinitions>
 
-                        <TextBox
-                            Grid.Column="0"
-                            Margin="0,0,5,0"
-                            VerticalAlignment="Stretch"
-                            Text="{x:Bind ViewModel.NewSessionLocalPath, Mode=TwoWay}" />
+                        <Border Grid.Column="0" Padding="0">
+                            <StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
+                                <!-- TODO: gray out the button if the form is not filled out correctly -->
+                                <HyperlinkButton
+                                    Padding="0"
+                                    Margin="0,0,5,0"
+                                    Command="{x:Bind ViewModel.ConfirmNewSessionCommand}">
+
+                                    <FontIcon Glyph="&#xE930;" FontSize="15"
+                                              Foreground="{ThemeResource SystemFillColorSuccessBrush}" />
+                                </HyperlinkButton>
+                                <HyperlinkButton
+                                    Padding="0"
+                                    Command="{x:Bind ViewModel.CancelNewSessionCommand}">
+
+                                    <FontIcon Glyph="&#xF096;" FontSize="15"
+                                              Foreground="{ThemeResource SystemFillColorCriticalBrush}" />
+                                </HyperlinkButton>
+                            </StackPanel>
+                        </Border>
+                        <Border Grid.Column="1" Padding="10,0,0,0">
+                            <Grid>
+                                <Grid.ColumnDefinitions>
+                                    <ColumnDefinition Width="*" />
+                                    <ColumnDefinition Width="Auto" />
+                                </Grid.ColumnDefinitions>
+
+                                <TextBox
+                                    Grid.Column="0"
+                                    Margin="0,0,5,0"
+                                    VerticalAlignment="Stretch"
+                                    Text="{x:Bind ViewModel.NewSessionLocalPath, Mode=TwoWay}" />
 
-                        <Button
-                            Grid.Column="1"
-                            IsEnabled="{x:Bind ViewModel.NewSessionLocalPathDialogOpen, Converter={StaticResource InverseBoolConverter}, Mode=OneWay}"
-                            Command="{x:Bind OpenLocalPathSelectDialogCommand}"
-                            VerticalAlignment="Stretch">
+                                <Button
+                                    Grid.Column="1"
+                                    IsEnabled="{x:Bind ViewModel.NewSessionLocalPathDialogOpen, Converter={StaticResource InverseBoolConverter}, Mode=OneWay}"
+                                    Command="{x:Bind OpenLocalPathSelectDialogCommand}"
+                                    VerticalAlignment="Stretch">
 
-                            <FontIcon Glyph="&#xE838;" FontSize="13" />
-                        </Button>
+                                    <FontIcon Glyph="&#xE838;" FontSize="13" />
+                                </Button>
+                            </Grid>
+                        </Border>
+                        <Border Grid.Column="2">
+                            <!-- TODO: use a combo box for workspace agents -->
+                            <!--
+                            <ComboBox
+                                ItemsSource="{x:Bind WorkspaceAgents}"
+                                VerticalAlignment="Stretch"
+                                HorizontalAlignment="Stretch" />
+                            -->
+                            <TextBox
+                                VerticalAlignment="Stretch"
+                                HorizontalAlignment="Stretch"
+                                Text="{x:Bind ViewModel.NewSessionRemoteName, Mode=TwoWay}" />
+                        </Border>
+                        <Border Grid.Column="3">
+                            <TextBox
+                                VerticalAlignment="Stretch"
+                                HorizontalAlignment="Stretch"
+                                Text="{x:Bind ViewModel.NewSessionRemotePath, Mode=TwoWay}" />
+                        </Border>
                     </Grid>
-                </Border>
-                <Border Grid.Column="2">
-                    <!-- TODO: use a combo box for workspace agents -->
-                    <!--
-                    <ComboBox
-                        ItemsSource="{x:Bind WorkspaceAgents}"
-                        VerticalAlignment="Stretch"
-                        HorizontalAlignment="Stretch" />
-                    -->
-                    <TextBox
-                        VerticalAlignment="Stretch"
-                        HorizontalAlignment="Stretch"
-                        Text="{x:Bind ViewModel.NewSessionRemoteName, Mode=TwoWay}" />
-                </Border>
-                <Border Grid.Column="3">
-                    <TextBox
-                        VerticalAlignment="Stretch"
-                        HorizontalAlignment="Stretch"
-                        Text="{x:Bind ViewModel.NewSessionRemotePath, Mode=TwoWay}" />
-                </Border>
-            </Grid>
-        </StackPanel>
-    </ScrollView>
+                </StackPanel>
+            </ScrollView>
+        </Grid>
+    </Grid>
 </Page>
diff --git a/App/Views/Pages/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml
index 94c80b3..b208020 100644
--- a/App/Views/Pages/TrayWindowMainPage.xaml
+++ b/App/Views/Pages/TrayWindowMainPage.xaml
@@ -228,6 +228,18 @@
 
         <controls:HorizontalRule />
 
+        <HyperlinkButton
+            Command="{x:Bind ViewModel.ShowFileSyncListWindowCommand, Mode=OneWay}"
+            Margin="-12,0"
+            HorizontalAlignment="Stretch"
+            HorizontalContentAlignment="Left">
+
+            <!-- TODO: status icon if there is a problem -->
+            <TextBlock Text="File sync" Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
+        </HyperlinkButton>
+
+        <controls:HorizontalRule />
+
         <HyperlinkButton
             Command="{x:Bind ViewModel.SignOutCommand, Mode=OneWay}"
             IsEnabled="{x:Bind ViewModel.VpnLifecycle, Converter={StaticResource StoppedBoolConverter}, Mode=OneWay}"
diff --git a/Tests.App/Services/MutagenControllerTest.cs b/Tests.App/Services/MutagenControllerTest.cs
index 40d6a48..be054a7 100644
--- a/Tests.App/Services/MutagenControllerTest.cs
+++ b/Tests.App/Services/MutagenControllerTest.cs
@@ -90,12 +90,12 @@ public async Task CreateRestartsDaemon(CancellationToken ct)
         await using (var controller = new MutagenController(_mutagenBinaryPath, dataDirectory))
         {
             await controller.Initialize(ct);
-            await controller.CreateSyncSession(new SyncSession(), ct);
+            await controller.CreateSyncSession(new CreateSyncSessionRequest(), ct);
         }
 
         var logPath = Path.Combine(dataDirectory, "daemon.log");
         Assert.That(File.Exists(logPath));
-        var logLines = File.ReadAllLines(logPath);
+        var logLines = await File.ReadAllLinesAsync(logPath, ct);
 
         // Here we're going to use the log to verify the daemon was started 2 times.
         // slightly brittle, but unlikely this log line will change.
@@ -114,7 +114,7 @@ public async Task Orphaned(CancellationToken ct)
         {
             controller1 = new MutagenController(_mutagenBinaryPath, dataDirectory);
             await controller1.Initialize(ct);
-            await controller1.CreateSyncSession(new SyncSession(), ct);
+            await controller1.CreateSyncSession(new CreateSyncSessionRequest(), ct);
 
             controller2 = new MutagenController(_mutagenBinaryPath, dataDirectory);
             await controller2.Initialize(ct);
@@ -127,7 +127,7 @@ public async Task Orphaned(CancellationToken ct)
 
         var logPath = Path.Combine(dataDirectory, "daemon.log");
         Assert.That(File.Exists(logPath));
-        var logLines = File.ReadAllLines(logPath);
+        var logLines = await File.ReadAllLinesAsync(logPath, ct);
 
         // Here we're going to use the log to verify the daemon was started 3 times.
         // slightly brittle, but unlikely this log line will change.

From d1990260a6d37cb696e2508ba3e58584f7242b89 Mon Sep 17 00:00:00 2001
From: Dean Sheather <dean@deansheather.com>
Date: Mon, 24 Mar 2025 19:33:26 +1100
Subject: [PATCH 3/5] FriendlyByteConverterTest.cs

---
 .../Converters/FriendlyByteConverterTest.cs   | 36 +++++++++++++++++++
 1 file changed, 36 insertions(+)
 create mode 100644 Tests.App/Converters/FriendlyByteConverterTest.cs

diff --git a/Tests.App/Converters/FriendlyByteConverterTest.cs b/Tests.App/Converters/FriendlyByteConverterTest.cs
new file mode 100644
index 0000000..e75d275
--- /dev/null
+++ b/Tests.App/Converters/FriendlyByteConverterTest.cs
@@ -0,0 +1,36 @@
+using Coder.Desktop.App.Converters;
+
+namespace Coder.Desktop.Tests.App.Converters;
+
+[TestFixture]
+public class FriendlyByteConverterTest
+{
+    [Test]
+    public void EndToEnd()
+    {
+        var cases = new List<(object, string)>
+        {
+            (0, "0 B"),
+            ((uint)0, "0 B"),
+            ((long)0, "0 B"),
+            ((ulong)0, "0 B"),
+
+            (1, "1 B"),
+            (1024, "1 KB"),
+            ((ulong)(1.1 * 1024), "1.1 KB"),
+            (1024 * 1024, "1 MB"),
+            (1024 * 1024 * 1024, "1 GB"),
+            ((ulong)1024 * 1024 * 1024 * 1024, "1 TB"),
+            ((ulong)1024 * 1024 * 1024 * 1024 * 1024, "1 PB"),
+            ((ulong)1024 * 1024 * 1024 * 1024 * 1024 * 1024, "1 EB"),
+            (ulong.MaxValue, "16 EB"),
+        };
+
+        var converter = new FriendlyByteConverter();
+        foreach (var (input, expected) in cases)
+        {
+            var actual = converter.Convert(input, typeof(string), null, null);
+            Assert.That(actual, Is.EqualTo(expected), $"case ({input.GetType()}){input}");
+        }
+    }
+}

From 171c9e54d6253ad70e106cc74e22e5a60989d3b7 Mon Sep 17 00:00:00 2001
From: Dean Sheather <dean@deansheather.com>
Date: Mon, 24 Mar 2025 19:55:34 +1100
Subject: [PATCH 4/5] Unavailable state

---
 App/ViewModels/FileSyncListViewModel.cs   | 58 +++++++++++++----------
 App/Views/FileSyncListWindow.xaml.cs      | 10 ----
 App/Views/Pages/FileSyncListMainPage.xaml | 11 +++++
 3 files changed, 44 insertions(+), 35 deletions(-)

diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs
index 0521e48..a790bbd 100644
--- a/App/ViewModels/FileSyncListViewModel.cs
+++ b/App/ViewModels/FileSyncListViewModel.cs
@@ -16,11 +16,6 @@ namespace Coder.Desktop.App.ViewModels;
 
 public partial class FileSyncListViewModel : ObservableObject
 {
-    public delegate void OnFileSyncListStaleDelegate();
-
-    // Triggered when the window should be closed.
-    public event OnFileSyncListStaleDelegate? OnFileSyncListStale;
-
     private DispatcherQueue? _dispatcherQueue;
 
     private readonly ISyncSessionController _syncSessionController;
@@ -28,12 +23,21 @@ public partial class FileSyncListViewModel : ObservableObject
     private readonly ICredentialManager _credentialManager;
 
     [ObservableProperty]
+    [NotifyPropertyChangedFor(nameof(ShowUnavailable))]
     [NotifyPropertyChangedFor(nameof(ShowLoading))]
     [NotifyPropertyChangedFor(nameof(ShowError))]
     [NotifyPropertyChangedFor(nameof(ShowSessions))]
     public partial bool Loading { get; set; } = true;
 
     [ObservableProperty]
+    [NotifyPropertyChangedFor(nameof(ShowUnavailable))]
+    [NotifyPropertyChangedFor(nameof(ShowLoading))]
+    [NotifyPropertyChangedFor(nameof(ShowError))]
+    [NotifyPropertyChangedFor(nameof(ShowSessions))]
+    public partial string? UnavailableMessage { get; set; } = null;
+
+    [ObservableProperty]
+    [NotifyPropertyChangedFor(nameof(ShowUnavailable))]
     [NotifyPropertyChangedFor(nameof(ShowLoading))]
     [NotifyPropertyChangedFor(nameof(ShowError))]
     [NotifyPropertyChangedFor(nameof(ShowSessions))]
@@ -72,9 +76,11 @@ public bool NewSessionCreateEnabled
         }
     }
 
-    public bool ShowLoading => Loading && Error == null;
-    public bool ShowError => Error != null;
-    public bool ShowSessions => !Loading && Error == null;
+    // TODO: this could definitely be improved
+    public bool ShowUnavailable => UnavailableMessage != null;
+    public bool ShowLoading => Loading && UnavailableMessage == null && Error == null;
+    public bool ShowError => UnavailableMessage == null && Error != null;
+    public bool ShowSessions => !Loading && UnavailableMessage == null && Error == null;
 
     public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcController rpcController,
         ICredentialManager credentialManager)
@@ -105,54 +111,56 @@ public void Initialize(DispatcherQueue dispatcherQueue)
     {
         _dispatcherQueue = dispatcherQueue;
 
-        _rpcController.StateChanged += (_, rpcModel) => UpdateFromRpcModel(rpcModel);
-        _credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialsModel(credentialModel);
+        _rpcController.StateChanged += RpcControllerStateChanged;
+        _credentialManager.CredentialsChanged += CredentialManagerCredentialsChanged;
 
         var rpcModel = _rpcController.GetState();
         var credentialModel = _credentialManager.GetCachedCredentials();
-        // TODO: fix this
-        //if (MaybeSendStaleEvent(rpcModel, credentialModel)) return;
+        MaybeSetUnavailableMessage(rpcModel, credentialModel);
 
         // TODO: Simulate loading until we have real data.
         Task.Delay(TimeSpan.FromSeconds(3)).ContinueWith(_ => _dispatcherQueue.TryEnqueue(() => Loading = false));
     }
 
-    private void UpdateFromRpcModel(RpcModel rpcModel)
+    private void RpcControllerStateChanged(object? sender, RpcModel rpcModel)
     {
         // Ensure we're on the UI thread.
         if (_dispatcherQueue == null) return;
         if (!_dispatcherQueue.HasThreadAccess)
         {
-            _dispatcherQueue.TryEnqueue(() => UpdateFromRpcModel(rpcModel));
+            _dispatcherQueue.TryEnqueue(() => RpcControllerStateChanged(sender, rpcModel));
             return;
         }
 
         var credentialModel = _credentialManager.GetCachedCredentials();
-        MaybeSendStaleEvent(rpcModel, credentialModel);
+        MaybeSetUnavailableMessage(rpcModel, credentialModel);
     }
 
-    private void UpdateFromCredentialsModel(CredentialModel credentialModel)
+    private void CredentialManagerCredentialsChanged(object? sender, CredentialModel credentialModel)
     {
         // Ensure we're on the UI thread.
         if (_dispatcherQueue == null) return;
         if (!_dispatcherQueue.HasThreadAccess)
         {
-            _dispatcherQueue.TryEnqueue(() => UpdateFromCredentialsModel(credentialModel));
+            _dispatcherQueue.TryEnqueue(() => CredentialManagerCredentialsChanged(sender, credentialModel));
             return;
         }
 
         var rpcModel = _rpcController.GetState();
-        MaybeSendStaleEvent(rpcModel, credentialModel);
+        MaybeSetUnavailableMessage(rpcModel, credentialModel);
     }
 
-    private bool MaybeSendStaleEvent(RpcModel rpcModel, CredentialModel credentialModel)
+    private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel credentialModel)
     {
-        var ok = rpcModel.RpcLifecycle is RpcLifecycle.Connected
-                 && rpcModel.VpnLifecycle is VpnLifecycle.Started
-                 && credentialModel.State == CredentialState.Valid;
-
-        if (!ok) OnFileSyncListStale?.Invoke();
-        return !ok;
+        if (rpcModel.RpcLifecycle != RpcLifecycle.Connected)
+            UnavailableMessage =
+                "Disconnected from the Windows service. Please see the tray window for more information.";
+        else if (credentialModel.State != CredentialState.Valid)
+            UnavailableMessage = "Please sign in to access file sync.";
+        else if (rpcModel.VpnLifecycle != VpnLifecycle.Started)
+            UnavailableMessage = "Please start Coder Connect from the tray window to access file sync.";
+        else
+            UnavailableMessage = null;
     }
 
     private void ClearNewForm()
diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs
index 0e784dc..27d386d 100644
--- a/App/Views/FileSyncListWindow.xaml.cs
+++ b/App/Views/FileSyncListWindow.xaml.cs
@@ -12,8 +12,6 @@ public sealed partial class FileSyncListWindow : WindowEx
     public FileSyncListWindow(FileSyncListViewModel viewModel)
     {
         ViewModel = viewModel;
-        ViewModel.OnFileSyncListStale += ViewModel_OnFileSyncListStale;
-
         InitializeComponent();
         SystemBackdrop = new DesktopAcrylicBackdrop();
 
@@ -22,12 +20,4 @@ public FileSyncListWindow(FileSyncListViewModel viewModel)
 
         this.CenterOnScreen();
     }
-
-    private void ViewModel_OnFileSyncListStale()
-    {
-        // TODO: Fix this. I got a weird memory corruption exception when it
-        //       fired immediately on start. Maybe we should schedule it for
-        //       next frame or something.
-        //Close()
-    }
 }
diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml
index 8080b79..82d99e6 100644
--- a/App/Views/Pages/FileSyncListMainPage.xaml
+++ b/App/Views/Pages/FileSyncListMainPage.xaml
@@ -12,6 +12,17 @@
     Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
 
     <Grid>
+        <Grid
+            Visibility="{x:Bind ViewModel.ShowUnavailable, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
+            Padding="60,60"
+            HorizontalAlignment="Center"
+            VerticalAlignment="Center">
+
+            <TextBlock
+                HorizontalAlignment="Center"
+                Text="{x:Bind ViewModel.UnavailableMessage, Mode=OneWay}" />
+        </Grid>
+
         <Grid
             Visibility="{x:Bind ViewModel.ShowLoading, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
             Padding="60,60"

From 4c37cabf51b4de0050f4f715ff8796eec38d8b9d Mon Sep 17 00:00:00 2001
From: Dean Sheather <dean@deansheather.com>
Date: Tue, 25 Mar 2025 22:12:16 +1100
Subject: [PATCH 5/5] Comments

---
 App/Models/SyncSessionModel.cs            | 237 +++++++++++-----------
 App/Services/MutagenController.cs         |   7 +-
 App/ViewModels/FileSyncListViewModel.cs   |  13 +-
 App/Views/Pages/FileSyncListMainPage.xaml |  11 +-
 4 files changed, 140 insertions(+), 128 deletions(-)

diff --git a/App/Models/SyncSessionModel.cs b/App/Models/SyncSessionModel.cs
index 7953720..d8d261d 100644
--- a/App/Models/SyncSessionModel.cs
+++ b/App/Models/SyncSessionModel.cs
@@ -12,7 +12,15 @@ public enum SyncSessionStatusCategory
 {
     Unknown,
     Paused,
+
+    // Halted is a combination of Error and Paused. If the session
+    // automatically pauses due to a safety check, we want to show it as an
+    // error, but also show that it can be resumed.
+    Halted,
     Error,
+
+    // If there are any conflicts, the state will be set to Conflicts,
+    // overriding Working and Ok.
     Conflicts,
     Working,
     Ok,
@@ -42,16 +50,17 @@ public class SyncSessionModel
     public readonly string Identifier;
     public readonly string Name;
 
-    public readonly string LocalPath = "Unknown";
-    public readonly string RemoteName = "Unknown";
-    public readonly string RemotePath = "Unknown";
+    public readonly string AlphaName;
+    public readonly string AlphaPath;
+    public readonly string BetaName;
+    public readonly string BetaPath;
 
     public readonly SyncSessionStatusCategory StatusCategory;
     public readonly string StatusString;
     public readonly string StatusDescription;
 
-    public readonly SyncSessionModelEndpointSize LocalSize;
-    public readonly SyncSessionModelEndpointSize RemoteSize;
+    public readonly SyncSessionModelEndpointSize AlphaSize;
+    public readonly SyncSessionModelEndpointSize BetaSize;
 
     public readonly string[] Errors = [];
 
@@ -69,33 +78,34 @@ public string SizeDetails
     {
         get
         {
-            var str = "Local:\n" + LocalSize.Description("  ") + "\n\n" +
-                      "Remote:\n" + RemoteSize.Description("  ");
+            var str = "Alpha:\n" + AlphaSize.Description("  ") + "\n\n" +
+                      "Remote:\n" + BetaSize.Description("  ");
             return str;
         }
     }
 
     // TODO: remove once we process sessions from the mutagen RPC
-    public SyncSessionModel(string localPath, string remoteName, string remotePath,
+    public SyncSessionModel(string alphaPath, string betaName, string betaPath,
         SyncSessionStatusCategory statusCategory,
         string statusString, string statusDescription, string[] errors)
     {
         Identifier = "TODO";
         Name = "TODO";
 
-        LocalPath = localPath;
-        RemoteName = remoteName;
-        RemotePath = remotePath;
+        AlphaName = "Local";
+        AlphaPath = alphaPath;
+        BetaName = betaName;
+        BetaPath = betaPath;
         StatusCategory = statusCategory;
         StatusString = statusString;
         StatusDescription = statusDescription;
-        LocalSize = new SyncSessionModelEndpointSize
+        AlphaSize = new SyncSessionModelEndpointSize
         {
             SizeBytes = (ulong)new Random().Next(0, 1000000000),
             FileCount = (ulong)new Random().Next(0, 10000),
             DirCount = (ulong)new Random().Next(0, 10000),
         };
-        RemoteSize = new SyncSessionModelEndpointSize
+        BetaSize = new SyncSessionModelEndpointSize
         {
             SizeBytes = (ulong)new Random().Next(0, 1000000000),
             FileCount = (ulong)new Random().Next(0, 10000),
@@ -110,116 +120,99 @@ public SyncSessionModel(State state)
         Identifier = state.Session.Identifier;
         Name = state.Session.Name;
 
-        // If the protocol isn't what we expect for alpha or beta, show
-        // "unknown".
-        if (state.Session.Alpha.Protocol == Protocol.Local && !string.IsNullOrWhiteSpace(state.Session.Alpha.Path))
-            LocalPath = state.Session.Alpha.Path;
-        if (state.Session.Beta.Protocol == Protocol.Ssh)
+        (AlphaName, AlphaPath) = NameAndPathFromUrl(state.Session.Alpha);
+        (BetaName, BetaPath) = NameAndPathFromUrl(state.Session.Beta);
+
+        switch (state.Status)
         {
-            if (string.IsNullOrWhiteSpace(state.Session.Beta.Host))
-            {
-                var name = state.Session.Beta.Host;
-                // TODO: this will need to be compatible with custom hostname
-                //       suffixes
-                if (name.EndsWith(".coder")) name = name[..^6];
-                RemoteName = name;
-            }
-
-            if (string.IsNullOrWhiteSpace(state.Session.Beta.Path)) RemotePath = state.Session.Beta.Path;
+            case Status.Disconnected:
+                StatusCategory = SyncSessionStatusCategory.Error;
+                StatusString = "Disconnected";
+                StatusDescription =
+                    "The session is unpaused but not currently connected or connecting to either endpoint.";
+                break;
+            case Status.HaltedOnRootEmptied:
+                StatusCategory = SyncSessionStatusCategory.Halted;
+                StatusString = "Halted on root emptied";
+                StatusDescription = "The session is halted due to the root emptying safety check.";
+                break;
+            case Status.HaltedOnRootDeletion:
+                StatusCategory = SyncSessionStatusCategory.Halted;
+                StatusString = "Halted on root deletion";
+                StatusDescription = "The session is halted due to the root deletion safety check.";
+                break;
+            case Status.HaltedOnRootTypeChange:
+                StatusCategory = SyncSessionStatusCategory.Halted;
+                StatusString = "Halted on root type change";
+                StatusDescription = "The session is halted due to the root type change safety check.";
+                break;
+            case Status.ConnectingAlpha:
+                StatusCategory = SyncSessionStatusCategory.Working;
+                StatusString = "Connecting (alpha)";
+                StatusDescription = "The session is attempting to connect to the alpha endpoint.";
+                break;
+            case Status.ConnectingBeta:
+                StatusCategory = SyncSessionStatusCategory.Working;
+                StatusString = "Connecting (beta)";
+                StatusDescription = "The session is attempting to connect to the beta endpoint.";
+                break;
+            case Status.Watching:
+                StatusCategory = SyncSessionStatusCategory.Ok;
+                StatusString = "Watching";
+                StatusDescription = "The session is watching for filesystem changes.";
+                break;
+            case Status.Scanning:
+                StatusCategory = SyncSessionStatusCategory.Working;
+                StatusString = "Scanning";
+                StatusDescription = "The session is scanning the filesystem on each endpoint.";
+                break;
+            case Status.WaitingForRescan:
+                StatusCategory = SyncSessionStatusCategory.Working;
+                StatusString = "Waiting for rescan";
+                StatusDescription =
+                    "The session is waiting to retry scanning after an error during the previous scanning operation.";
+                break;
+            case Status.Reconciling:
+                StatusCategory = SyncSessionStatusCategory.Working;
+                StatusString = "Reconciling";
+                StatusDescription = "The session is performing reconciliation.";
+                break;
+            case Status.StagingAlpha:
+                StatusCategory = SyncSessionStatusCategory.Working;
+                StatusString = "Staging (alpha)";
+                StatusDescription = "The session is staging files on alpha.";
+                break;
+            case Status.StagingBeta:
+                StatusCategory = SyncSessionStatusCategory.Working;
+                StatusString = "Staging (beta)";
+                StatusDescription = "The session is staging files on beta.";
+                break;
+            case Status.Transitioning:
+                StatusCategory = SyncSessionStatusCategory.Working;
+                StatusString = "Transitioning";
+                StatusDescription = "The session is performing transition operations on each endpoint.";
+                break;
+            case Status.Saving:
+                StatusCategory = SyncSessionStatusCategory.Working;
+                StatusString = "Saving";
+                StatusDescription = "The session is recording synchronization history to disk.";
+                break;
+            default:
+                StatusCategory = SyncSessionStatusCategory.Unknown;
+                StatusString = state.Status.ToString();
+                StatusDescription = "Unknown status message.";
+                break;
         }
 
-        if (state.Session.Paused)
+        // If the session is paused, override all other statuses except Halted.
+        if (state.Session.Paused && StatusCategory is not SyncSessionStatusCategory.Halted)
         {
-            // Disregard any status if it's paused.
             StatusCategory = SyncSessionStatusCategory.Paused;
             StatusString = "Paused";
             StatusDescription = "The session is paused.";
         }
-        else
-        {
-            switch (state.Status)
-            {
-                case Status.Disconnected:
-                    StatusCategory = SyncSessionStatusCategory.Error;
-                    StatusString = "Disconnected";
-                    StatusDescription =
-                        "The session is unpaused but not currently connected or connecting to either endpoint.";
-                    break;
-                case Status.HaltedOnRootEmptied:
-                    StatusCategory = SyncSessionStatusCategory.Error;
-                    StatusString = "Halted on root emptied";
-                    StatusDescription = "The session is halted due to the root emptying safety check.";
-                    break;
-                case Status.HaltedOnRootDeletion:
-                    StatusCategory = SyncSessionStatusCategory.Error;
-                    StatusString = "Halted on root deletion";
-                    StatusDescription = "The session is halted due to the root deletion safety check.";
-                    break;
-                case Status.HaltedOnRootTypeChange:
-                    StatusCategory = SyncSessionStatusCategory.Error;
-                    StatusString = "Halted on root type change";
-                    StatusDescription = "The session is halted due to the root type change safety check.";
-                    break;
-                case Status.ConnectingAlpha:
-                    StatusCategory = SyncSessionStatusCategory.Working;
-                    StatusString = "Connecting (alpha)";
-                    StatusDescription = "The session is attempting to connect to the alpha endpoint.";
-                    break;
-                case Status.ConnectingBeta:
-                    StatusCategory = SyncSessionStatusCategory.Working;
-                    StatusString = "Connecting (beta)";
-                    StatusDescription = "The session is attempting to connect to the beta endpoint.";
-                    break;
-                case Status.Watching:
-                    StatusCategory = SyncSessionStatusCategory.Ok;
-                    StatusString = "Watching";
-                    StatusDescription = "The session is watching for filesystem changes.";
-                    break;
-                case Status.Scanning:
-                    StatusCategory = SyncSessionStatusCategory.Working;
-                    StatusString = "Scanning";
-                    StatusDescription = "The session is scanning the filesystem on each endpoint.";
-                    break;
-                case Status.WaitingForRescan:
-                    StatusCategory = SyncSessionStatusCategory.Working;
-                    StatusString = "Waiting for rescan";
-                    StatusDescription =
-                        "The session is waiting to retry scanning after an error during the previous scanning operation.";
-                    break;
-                case Status.Reconciling:
-                    StatusCategory = SyncSessionStatusCategory.Working;
-                    StatusString = "Reconciling";
-                    StatusDescription = "The session is performing reconciliation.";
-                    break;
-                case Status.StagingAlpha:
-                    StatusCategory = SyncSessionStatusCategory.Working;
-                    StatusString = "Staging (alpha)";
-                    StatusDescription = "The session is staging files on alpha.";
-                    break;
-                case Status.StagingBeta:
-                    StatusCategory = SyncSessionStatusCategory.Working;
-                    StatusString = "Staging (beta)";
-                    StatusDescription = "The session is staging files on beta.";
-                    break;
-                case Status.Transitioning:
-                    StatusCategory = SyncSessionStatusCategory.Working;
-                    StatusString = "Transitioning";
-                    StatusDescription = "The session is performing transition operations on each endpoint.";
-                    break;
-                case Status.Saving:
-                    StatusCategory = SyncSessionStatusCategory.Working;
-                    StatusString = "Saving";
-                    StatusDescription = "The session is recording synchronization history to disk.";
-                    break;
-                default:
-                    StatusCategory = SyncSessionStatusCategory.Unknown;
-                    StatusString = state.Status.ToString();
-                    StatusDescription = "Unknown status message.";
-                    break;
-            }
-        }
 
-        // If there are any conflicts, set the status to Conflicts.
+        // If there are any conflicts, override Working and Ok.
         if (state.Conflicts.Count > 0 && StatusCategory > SyncSessionStatusCategory.Conflicts)
         {
             StatusCategory = SyncSessionStatusCategory.Conflicts;
@@ -227,14 +220,14 @@ public SyncSessionModel(State state)
             StatusDescription = "The session has conflicts that need to be resolved.";
         }
 
-        LocalSize = new SyncSessionModelEndpointSize
+        AlphaSize = new SyncSessionModelEndpointSize
         {
             SizeBytes = state.AlphaState.TotalFileSize,
             FileCount = state.AlphaState.Files,
             DirCount = state.AlphaState.Directories,
             SymlinkCount = state.AlphaState.SymbolicLinks,
         };
-        RemoteSize = new SyncSessionModelEndpointSize
+        BetaSize = new SyncSessionModelEndpointSize
         {
             SizeBytes = state.BetaState.TotalFileSize,
             FileCount = state.BetaState.Files,
@@ -246,4 +239,16 @@ public SyncSessionModel(State state)
         //       come from
         if (!string.IsNullOrWhiteSpace(state.LastError)) Errors = [state.LastError];
     }
+
+    private static (string, string) NameAndPathFromUrl(URL url)
+    {
+        var name = "Local";
+        var path = !string.IsNullOrWhiteSpace(url.Path) ? url.Path : "Unknown";
+
+        if (url.Protocol is not Protocol.Local)
+            name = !string.IsNullOrWhiteSpace(url.Host) ? url.Host : "Unknown";
+        if (string.IsNullOrWhiteSpace(url.Host)) name = url.Host;
+
+        return (name, path);
+    }
 }
diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs
index fc6546e..4bd5688 100644
--- a/App/Services/MutagenController.cs
+++ b/App/Services/MutagenController.cs
@@ -122,7 +122,9 @@ public async Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest r
             _sessionCount += 1;
         }
 
-        throw new NotImplementedException();
+        // TODO: implement this
+        return new SyncSessionModel(@"C:\path", "remote", "~/path", SyncSessionStatusCategory.Ok, "Watching",
+            "Description", []);
     }
 
     public async Task<IEnumerable<SyncSessionModel>> ListSyncSessions(CancellationToken ct)
@@ -138,7 +140,8 @@ public async Task<IEnumerable<SyncSessionModel>> ListSyncSessions(CancellationTo
                 return [];
         }
 
-        throw new NotImplementedException();
+        // TODO: implement this
+        return [];
     }
 
     public async Task Initialize(CancellationToken ct)
diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs
index a790bbd..45ca318 100644
--- a/App/ViewModels/FileSyncListViewModel.cs
+++ b/App/ViewModels/FileSyncListViewModel.cs
@@ -27,17 +27,14 @@ public partial class FileSyncListViewModel : ObservableObject
     [NotifyPropertyChangedFor(nameof(ShowLoading))]
     [NotifyPropertyChangedFor(nameof(ShowError))]
     [NotifyPropertyChangedFor(nameof(ShowSessions))]
-    public partial bool Loading { get; set; } = true;
+    public partial string? UnavailableMessage { get; set; } = null;
 
     [ObservableProperty]
-    [NotifyPropertyChangedFor(nameof(ShowUnavailable))]
     [NotifyPropertyChangedFor(nameof(ShowLoading))]
-    [NotifyPropertyChangedFor(nameof(ShowError))]
     [NotifyPropertyChangedFor(nameof(ShowSessions))]
-    public partial string? UnavailableMessage { get; set; } = null;
+    public partial bool Loading { get; set; } = true;
 
     [ObservableProperty]
-    [NotifyPropertyChangedFor(nameof(ShowUnavailable))]
     [NotifyPropertyChangedFor(nameof(ShowLoading))]
     [NotifyPropertyChangedFor(nameof(ShowError))]
     [NotifyPropertyChangedFor(nameof(ShowSessions))]
@@ -98,8 +95,10 @@ public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcC
                 "Some description", []),
             new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Conflicts,
                 "Conflicts", "Some description", []),
-            new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Error,
+            new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Halted,
                 "Halted on root emptied", "Some description", []),
+            new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Error,
+                "Some error", "Some description", []),
             new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Unknown,
                 "Unknown", "Some description", []),
             new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Working,
@@ -110,6 +109,8 @@ public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcC
     public void Initialize(DispatcherQueue dispatcherQueue)
     {
         _dispatcherQueue = dispatcherQueue;
+        if (!_dispatcherQueue.HasThreadAccess)
+            throw new InvalidOperationException("Initialize must be called from the UI thread");
 
         _rpcController.StateChanged += RpcControllerStateChanged;
         _credentialManager.CredentialsChanged += CredentialManagerCredentialsChanged;
diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml
index 82d99e6..768e396 100644
--- a/App/Views/Pages/FileSyncListMainPage.xaml
+++ b/App/Views/Pages/FileSyncListMainPage.xaml
@@ -157,19 +157,19 @@
                                     </Border>
                                     <Border Grid.Column="1" Padding="10,0,0,0">
                                         <TextBlock
-                                            Text="{x:Bind LocalPath}"
+                                            Text="{x:Bind AlphaPath}"
                                             TextTrimming="CharacterEllipsis"
                                             IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" />
                                     </Border>
                                     <Border Grid.Column="2">
                                         <TextBlock
-                                            Text="{x:Bind RemoteName}"
+                                            Text="{x:Bind BetaName}"
                                             TextTrimming="CharacterEllipsis"
                                             IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" />
                                     </Border>
                                     <Border Grid.Column="3">
                                         <TextBlock
-                                            Text="{x:Bind RemotePath}"
+                                            Text="{x:Bind BetaPath}"
                                             TextTrimming="CharacterEllipsis"
                                             IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" />
                                     </Border>
@@ -184,6 +184,9 @@
                                                 <converters:StringToBrushSelectorItem
                                                     Key="Paused"
                                                     Value="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
+                                                <converters:StringToBrushSelectorItem
+                                                    Key="Halted"
+                                                    Value="{ThemeResource SystemFillColorCriticalBrush}" />
                                                 <converters:StringToBrushSelectorItem
                                                     Key="Error"
                                                     Value="{ThemeResource SystemFillColorCriticalBrush}" />
@@ -206,7 +209,7 @@
                                     </Border>
                                     <Border Grid.Column="5">
                                         <TextBlock
-                                            Text="{x:Bind LocalSize.SizeBytes, Converter={StaticResource FriendlyByteConverter}}"
+                                            Text="{x:Bind AlphaSize.SizeBytes, Converter={StaticResource FriendlyByteConverter}}"
                                             ToolTipService.ToolTip="{x:Bind SizeDetails}" />
                                     </Border>
                                 </Grid>