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/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/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..8c1570f
--- /dev/null
+++ b/App/Converters/DependencyObjectSelector.cs
@@ -0,0 +1,188 @@
+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.
+
+/// <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>
+{
+    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);
+    }
+}
+
+/// <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>
+{
+    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, SelectedKeyPropertyChanged));
+
+    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);
+        }
+    }
+
+    /// <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);
+        set => SetValue(SelectedObjectProperty, value);
+    }
+
+    public DependencyObjectSelector()
+    {
+        References = [];
+    }
+
+    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 =>
+                               (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)
+            {
+                // Bind the SelectedObject property to the reference's Value.
+                // If the underlying Value changes, it will propagate to the
+                // SelectedObject.
+                BindingOperations.SetBinding
+                (
+                    this,
+                    SelectedObjectProperty,
+                    new Binding
+                    {
+                        Source = item,
+                        Path = new PropertyPath(nameof(DependencyObjectSelectorItem<TK, TV>.Value)),
+                    }
+                );
+                return;
+            }
+        }
+
+        ClearValue(SelectedObjectProperty);
+    }
+
+    // Called when the References property is replaced.
+    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;
+    }
+
+    // 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();
+    }
+}
+
+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/SyncSessionModel.cs b/App/Models/SyncSessionModel.cs
new file mode 100644
index 0000000..d8d261d
--- /dev/null
+++ b/App/Models/SyncSessionModel.cs
@@ -0,0 +1,254 @@
+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,
+
+    // 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,
+}
+
+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 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 AlphaSize;
+    public readonly SyncSessionModelEndpointSize BetaSize;
+
+    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 = "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 alphaPath, string betaName, string betaPath,
+        SyncSessionStatusCategory statusCategory,
+        string statusString, string statusDescription, string[] errors)
+    {
+        Identifier = "TODO";
+        Name = "TODO";
+
+        AlphaName = "Local";
+        AlphaPath = alphaPath;
+        BetaName = betaName;
+        BetaPath = betaPath;
+        StatusCategory = statusCategory;
+        StatusString = statusString;
+        StatusDescription = statusDescription;
+        AlphaSize = new SyncSessionModelEndpointSize
+        {
+            SizeBytes = (ulong)new Random().Next(0, 1000000000),
+            FileCount = (ulong)new Random().Next(0, 10000),
+            DirCount = (ulong)new Random().Next(0, 10000),
+        };
+        BetaSize = 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;
+
+        (AlphaName, AlphaPath) = NameAndPathFromUrl(state.Session.Alpha);
+        (BetaName, BetaPath) = NameAndPathFromUrl(state.Session.Beta);
+
+        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.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 the session is paused, override all other statuses except Halted.
+        if (state.Session.Paused && StatusCategory is not SyncSessionStatusCategory.Halted)
+        {
+            StatusCategory = SyncSessionStatusCategory.Paused;
+            StatusString = "Paused";
+            StatusDescription = "The session is paused.";
+        }
+
+        // If there are any conflicts, override Working and Ok.
+        if (state.Conflicts.Count > 0 && StatusCategory > SyncSessionStatusCategory.Conflicts)
+        {
+            StatusCategory = SyncSessionStatusCategory.Conflicts;
+            StatusString = "Conflicts";
+            StatusDescription = "The session has conflicts that need to be resolved.";
+        }
+
+        AlphaSize = new SyncSessionModelEndpointSize
+        {
+            SizeBytes = state.AlphaState.TotalFileSize,
+            FileCount = state.AlphaState.Files,
+            DirCount = state.AlphaState.Directories,
+            SymlinkCount = state.AlphaState.SymbolicLinks,
+        };
+        BetaSize = 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];
+    }
+
+    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 7f48426..4bd5688 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,12 @@ public async Task<SyncSession> CreateSyncSession(SyncSession session, Cancellati
             _sessionCount += 1;
         }
 
-        return session;
+        // TODO: implement this
+        return new SyncSessionModel(@"C:\path", "remote", "~/path", SyncSessionStatusCategory.Ok, "Watching",
+            "Description", []);
     }
 
-
-    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 +137,11 @@ 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>();
+        // TODO: implement this
+        return [];
     }
 
     public async Task Initialize(CancellationToken ct)
@@ -190,7 +180,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
new file mode 100644
index 0000000..45ca318
--- /dev/null
+++ b/App/ViewModels/FileSyncListViewModel.cs
@@ -0,0 +1,254 @@
+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;
+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
+{
+    private DispatcherQueue? _dispatcherQueue;
+
+    private readonly ISyncSessionController _syncSessionController;
+    private readonly IRpcController _rpcController;
+    private readonly ICredentialManager _credentialManager;
+
+    [ObservableProperty]
+    [NotifyPropertyChangedFor(nameof(ShowUnavailable))]
+    [NotifyPropertyChangedFor(nameof(ShowLoading))]
+    [NotifyPropertyChangedFor(nameof(ShowError))]
+    [NotifyPropertyChangedFor(nameof(ShowSessions))]
+    public partial string? UnavailableMessage { get; set; } = null;
+
+    [ObservableProperty]
+    [NotifyPropertyChangedFor(nameof(ShowLoading))]
+    [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;
+
+    [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;
+        }
+    }
+
+    // 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)
+    {
+        _syncSessionController = syncSessionController;
+        _rpcController = rpcController;
+        _credentialManager = credentialManager;
+
+        Sessions =
+        [
+            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 SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Conflicts,
+                "Conflicts", "Some description", []),
+            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,
+                "Reconciling", "Some description", []),
+        ];
+    }
+
+    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;
+
+        var rpcModel = _rpcController.GetState();
+        var credentialModel = _credentialManager.GetCachedCredentials();
+        MaybeSetUnavailableMessage(rpcModel, credentialModel);
+
+        // TODO: Simulate loading until we have real data.
+        Task.Delay(TimeSpan.FromSeconds(3)).ContinueWith(_ => _dispatcherQueue.TryEnqueue(() => Loading = false));
+    }
+
+    private void RpcControllerStateChanged(object? sender, RpcModel rpcModel)
+    {
+        // Ensure we're on the UI thread.
+        if (_dispatcherQueue == null) return;
+        if (!_dispatcherQueue.HasThreadAccess)
+        {
+            _dispatcherQueue.TryEnqueue(() => RpcControllerStateChanged(sender, rpcModel));
+            return;
+        }
+
+        var credentialModel = _credentialManager.GetCachedCredentials();
+        MaybeSetUnavailableMessage(rpcModel, credentialModel);
+    }
+
+    private void CredentialManagerCredentialsChanged(object? sender, CredentialModel credentialModel)
+    {
+        // Ensure we're on the UI thread.
+        if (_dispatcherQueue == null) return;
+        if (!_dispatcherQueue.HasThreadAccess)
+        {
+            _dispatcherQueue.TryEnqueue(() => CredentialManagerCredentialsChanged(sender, credentialModel));
+            return;
+        }
+
+        var rpcModel = _rpcController.GetState();
+        MaybeSetUnavailableMessage(rpcModel, credentialModel);
+    }
+
+    private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel credentialModel)
+    {
+        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()
+    {
+        CreatingNewSession = false;
+        NewSessionLocalPath = "";
+        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()
+    {
+        ClearNewForm();
+        CreatingNewSession = true;
+    }
+
+    public async Task OpenLocalPathSelectDialog(Window window)
+    {
+        var picker = new FolderPicker
+        {
+            SuggestedStartLocation = PickerLocationId.ComputerFolder,
+        };
+
+        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..532bfe4 100644
--- a/App/ViewModels/TrayWindowViewModel.cs
+++ b/App/ViewModels/TrayWindowViewModel.cs
@@ -4,10 +4,12 @@
 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;
 using Google.Protobuf;
+using Microsoft.Extensions.DependencyInjection;
 using Microsoft.UI.Dispatching;
 using Microsoft.UI.Xaml;
 using Microsoft.UI.Xaml.Controls;
@@ -20,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]
@@ -73,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;
     }
@@ -204,6 +211,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 +249,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 +261,7 @@ private async Task StopVpn()
         }
         catch (Exception e)
         {
-            VpnFailedMessage = "Failed to stop Coder Connect: " + MaybeUnwrapTunnelError(e);
+            VpnFailedMessage = "Failed to stop CoderVPN: " + MaybeUnwrapTunnelError(e);
         }
     }
 
@@ -262,6 +277,22 @@ public void ToggleShowAllAgents()
         ShowAllAgents = !ShowAllAgents;
     }
 
+    [RelayCommand]
+    public void ShowFileSyncListWindow()
+    {
+        // 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()
     {
diff --git a/App/Views/FileSyncListWindow.xaml b/App/Views/FileSyncListWindow.xaml
new file mode 100644
index 0000000..070efd2
--- /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 File Sync"
+    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..27d386d
--- /dev/null
+++ b/App/Views/FileSyncListWindow.xaml.cs
@@ -0,0 +1,23 @@
+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;
+        InitializeComponent();
+        SystemBackdrop = new DesktopAcrylicBackdrop();
+
+        ViewModel.Initialize(DispatcherQueue);
+        RootFrame.Content = new FileSyncListMainPage(ViewModel, this);
+
+        this.CenterOnScreen();
+    }
+}
diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml
new file mode 100644
index 0000000..768e396
--- /dev/null
+++ b/App/Views/Pages/FileSyncListMainPage.xaml
@@ -0,0 +1,331 @@
+<?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}">
+
+    <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"
+            HorizontalAlignment="Center"
+            VerticalAlignment="Center">
+
+            <ProgressRing
+                Width="32"
+                Height="32"
+                Margin="0,30"
+                HorizontalAlignment="Center" />
+
+            <TextBlock HorizontalAlignment="Center" Text="Loading sync sessions..." />
+        </Grid>
+
+        <StackPanel
+            Visibility="{x:Bind ViewModel.ShowError, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
+            Orientation="Vertical"
+            Padding="20">
+
+            <TextBlock
+                Margin="0,0,0,20"
+                Foreground="Red"
+                TextWrapping="Wrap"
+                Text="{x:Bind ViewModel.Error, Mode=OneWay}" />
+
+            <Button Command="{x:Bind ViewModel.ReloadSessionsCommand, Mode=OneWay}">
+                <TextBlock Text="Reload" />
+            </Button>
+        </StackPanel>
+
+        <!-- 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>
+
+            <StackPanel
+                Grid.Row="0"
+                Orientation="Vertical"
+                Padding="30,15,30,0">
+
+                <!--
+                    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}" />
+            </StackPanel>
+
+            <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>
+
+                        <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 AlphaPath}"
+                                            TextTrimming="CharacterEllipsis"
+                                            IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" />
+                                    </Border>
+                                    <Border Grid.Column="2">
+                                        <TextBlock
+                                            Text="{x:Bind BetaName}"
+                                            TextTrimming="CharacterEllipsis"
+                                            IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" />
+                                    </Border>
+                                    <Border Grid.Column="3">
+                                        <TextBlock
+                                            Text="{x:Bind BetaPath}"
+                                            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="Halted"
+                                                    Value="{ThemeResource SystemFillColorCriticalBrush}" />
+                                                <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 AlphaSize.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>
+        </Grid>
+    </Grid>
+</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..b208020 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"
@@ -203,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/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",
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}");
+        }
+    }
+}
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.