Initial commit: Moltbot Windows Hub monorepo

- Moltbot.Tray: Windows system tray companion
- Moltbot.Shared: Gateway client library
- Moltbot.CommandPalette: PowerToys Command Palette extension

Consolidated from clawdbot-windows-tray, clawdbot-shared, and moltbot-commandpalette-extension repos.
This commit is contained in:
Scott Hanselman 2026-01-28 17:34:44 -08:00
commit db54ae50b2
50 changed files with 5998 additions and 0 deletions

345
.gitignore vendored Normal file
View File

@ -0,0 +1,345 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these files may be extracted
*.azurePubxml
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment the next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
CrystalDecisions.ReportingServices.ViewerObjectModel.dll
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Scott Hanselman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

109
README.md Normal file
View File

@ -0,0 +1,109 @@
# 🦞 Moltbot Windows Hub
A Windows companion suite for [Moltbot](https://moltbot.com) - the AI-powered personal assistant.
## Projects
This monorepo contains three projects:
| Project | Description |
|---------|-------------|
| **Moltbot.Tray** | System tray application for quick access to Moltbot |
| **Moltbot.Shared** | Shared gateway client library |
| **Moltbot.CommandPalette** | PowerToys Command Palette extension |
## 🚀 Quick Start
### Prerequisites
- .NET 9.0 SDK
- Windows 10/11
- PowerToys (for Command Palette extension)
### Build
```bash
dotnet build
```
### Run Tray App
```bash
dotnet run --project src/Moltbot.Tray
```
## 📦 Moltbot.Tray
Windows system tray companion that connects to your local Moltbot gateway.
### Features
- 🦞 Lobster icon in system tray (connected/disconnected states)
- 💬 Quick Send - Send messages via global hotkey (Ctrl+Shift+M)
- 🌐 Web Chat - Embedded chat window
- 📊 Status Display - View sessions and channels
- 🔔 Toast Notifications - Clickable Windows notifications
- 🚀 Auto-start with Windows
- ⚙️ Settings management
### Mac Parity Status
| Feature | Mac | Windows |
|---------|-----|---------|
| System tray icon | ✅ | ✅ |
| Connection status | ✅ | ✅ |
| Quick send hotkey | ✅ | ✅ |
| Web chat window | ✅ | ✅ |
| Toast notifications | ✅ | ✅ |
| Auto-start | ✅ | ✅ |
| Session display | ✅ | ✅ |
| Channel health | ✅ | ✅ |
| Deep links | ✅ | 🔄 |
## 📦 Moltbot.CommandPalette
PowerToys Command Palette extension for quick Moltbot access.
### Commands
- **🦞 Open Dashboard** - Launch web dashboard
- **💬 Quick Send** - Send a message
- **📊 Full Status** - View gateway status
- **⚡ Sessions** - View active sessions
- **📡 Channels** - View channel health
- **🔄 Health Check** - Trigger health refresh
### Installation
1. Build the solution in Release mode
2. Deploy the MSIX package via Visual Studio
3. Open Command Palette (Win+Alt+Space)
4. Type "Moltbot" to see commands
## 📦 Moltbot.Shared
Shared library containing:
- `MoltbotGatewayClient` - WebSocket client for gateway protocol
- `IMoltbotLogger` - Logging interface
- Data models (SessionInfo, ChannelHealth, etc.)
## Development
### Project Structure
```
moltbot-windows-hub/
├── src/
│ ├── Moltbot.Shared/ # Shared gateway library
│ ├── Moltbot.Tray/ # System tray app
│ └── Moltbot.CommandPalette/ # PowerToys extension
├── moltbot-windows-hub.sln
├── README.md
├── LICENSE
└── .gitignore
```
### Configuration
Settings are stored in:
- Tray: `%APPDATA%\MoltbotTray\settings.json`
- Logs: `%APPDATA%\MoltbotTray\moltbot-tray.log`
Default gateway: `ws://localhost:18789`
## License
MIT License - see [LICENSE](LICENSE)

9
moltbot-windows-hub.slnx Normal file
View File

@ -0,0 +1,9 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Moltbot.CommandPalette/Moltbot.CommandPalette.csproj">
<Platform Project="x64" />
</Project>
<Project Path="src/Moltbot.Shared/Moltbot.Shared.csproj" />
<Project Path="src/Moltbot.Tray/Moltbot.Tray.csproj" />
</Folder>
</Solution>

Binary file not shown.

View File

@ -0,0 +1,10 @@
<Project>
<PropertyGroup>
<Platforms>x64;ARM64</Platforms>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisMode>Recommended</AnalysisMode>
<_SkipUpgradeNetAnalyzersNuGetWarning>true</_SkipUpgradeNetAnalyzersNuGetWarning>
<NuGetAuditMode>direct</NuGetAuditMode>
<PlatformTarget>$(Platform)</PlatformTarget>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,18 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.5.250829002" />
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0-preview.24508.2" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183" />
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4188" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.20250829.1" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="1.8.250907003" />
<PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="System.Text.Json" Version="9.0.8" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,101 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<RootNamespace>Moltbot</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<WindowsSdkPackageVersion>10.0.26100.68-preview</WindowsSdkPackageVersion>
<TargetFramework>net9.0-windows10.0.26100.0</TargetFramework>
<TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion>
<SupportedOSPlatformVersion>10.0.19041.0</SupportedOSPlatformVersion>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
<EnableMsixTooling>true</EnableMsixTooling>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Content Include="Assets\SplashScreen.scale-200.png" />
<Content Include="Assets\LockScreenLogo.scale-200.png" />
<Content Include="Assets\Square150x150Logo.scale-200.png" />
<Content Include="Assets\Square44x44Logo.scale-200.png" />
<Content Include="Assets\Square44x44Logo.targetsize-24_altform-unplated.png" />
<Content Include="Assets\StoreLogo.png" />
<Content Include="Assets\Wide310x150Logo.scale-200.png" />
</ItemGroup>
<ItemGroup>
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
<!--
Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging
Tools extension to be activated for this project even if the Windows App SDK Nuget
package has not yet been restored.
-->
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<ProjectCapability Include="Msix" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CommandPalette.Extensions" />
<PackageReference Include="Microsoft.Windows.CsWinRT" />
<PackageReference Include="Shmuelie.WinRTServer" />
<!-- Needed to enable building an MSIX package -->
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.MSIX">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Moltbot.Shared\Moltbot.Shared.csproj" />
</ItemGroup>
<!--
Defining the "HasPackageAndPublishMenuAddedByProject" property here allows the Solution
Explorer "Package and Publish" context menu entry to be enabled for this project even if
the Windows App SDK Nuget package has not yet been restored.
-->
<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
</PropertyGroup>
<PropertyGroup>
<PublishSingleFile>true</PublishSingleFile>
<IsAotCompatible>true</IsAotCompatible>
<CsWinRTAotOptimizerEnabled>true</CsWinRTAotOptimizerEnabled>
<CsWinRTAotWarningLevel>2</CsWinRTAotWarningLevel>
<!-- Suppress DynamicallyAccessedMemberTypes.PublicParameterlessConstructor in fallback code path of Windows SDK projection -->
<WarningsNotAsErrors>IL2081;$(WarningsNotAsErrors)</WarningsNotAsErrors>
<!-- When publishing trimmed, make sure to treat trimming warnings as build errors -->
<ILLinkTreatWarningsAsErrors>true</ILLinkTreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
<!-- In Debug builds, trimming is disabled by default, but all the trim &
AOT warnings are enabled. This gives debug builds a tighter inner loop,
while at least warning about future trim violations -->
<PublishTrimmed>false</PublishTrimmed>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
<EnableAotAnalyzer>true</EnableAotAnalyzer>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'!='Debug'">
<!-- In Release builds, trimming is enabled by default.
feel free to disable this if needed -->
<PublishTrimmed>true</PublishTrimmed>
<!-- In release, also ignore the aforementioned ILLink warning -->
<ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,35 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.CommandPalette.Extensions;
namespace Moltbot;
[Guid("b71e1e6d-20f4-4fd8-9d8e-cc5dc94ca8b5")]
public sealed partial class Moltbot : IExtension, IDisposable
{
private readonly ManualResetEvent _extensionDisposedEvent;
private readonly MoltbotCommandsProvider _provider = new();
public Moltbot(ManualResetEvent extensionDisposedEvent)
{
this._extensionDisposedEvent = extensionDisposedEvent;
}
public object? GetProvider(ProviderType providerType)
{
return providerType switch
{
ProviderType.Commands => _provider,
_ => null,
};
}
public void Dispose() => this._extensionDisposedEvent.Set();
}

View File

@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Moltbot;
public partial class MoltbotCommandsProvider : CommandProvider
{
private readonly ICommandItem[] _commands;
public MoltbotCommandsProvider()
{
DisplayName = "Moltbot";
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
_commands = [
new CommandItem(new MoltbotPage()) { Title = DisplayName },
];
}
public override ICommandItem[] TopLevelCommands()
{
return _commands;
}
}

View File

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap uap3 rescap">
<Identity
Name="Moltbot"
Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US"
Version="0.0.1.0" />
<!-- When you're ready to publish your extension, you'll need to change the
Publisher= to match your own identity -->
<Properties>
<DisplayName>Moltbot</DisplayName>
<PublisherDisplayName>A Lone Developer</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.19041.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19041.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate"/>
</Resources>
<Applications>
<Application Id="App"
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="Moltbot"
Description="Moltbot"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" />
</uap:VisualElements>
<Extensions>
<com:Extension Category="windows.comServer">
<com:ComServer>
<com:ExeServer Executable="Moltbot.exe" Arguments="-RegisterProcessAsComServer" DisplayName="Moltbot">
<com:Class Id="b71e1e6d-20f4-4fd8-9d8e-cc5dc94ca8b5" DisplayName="Moltbot" />
</com:ExeServer>
</com:ComServer>
</com:Extension>
<uap3:Extension Category="windows.appExtension">
<uap3:AppExtension Name="com.microsoft.commandpalette"
Id="ID"
PublicFolder="Public"
DisplayName="Moltbot"
Description="Moltbot">
<uap3:Properties>
<CmdPalProvider>
<Activation>
<CreateInstance ClassId="b71e1e6d-20f4-4fd8-9d8e-cc5dc94ca8b5" />
</Activation>
<SupportedInterfaces>
<Commands/>
</SupportedInterfaces>
</CmdPalProvider>
</uap3:Properties>
</uap3:AppExtension>
</uap3:Extension>
</Extensions>
</Application>
</Applications>
<Capabilities>
<Capability Name="internetClient" />
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>

View File

@ -0,0 +1,513 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Moltbot.Shared;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Moltbot;
internal sealed partial class MoltbotPage : ListPage
{
private static string _gatewayUrl = "ws://localhost:18789";
private static string _token = "";
public MoltbotPage()
{
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
Title = "Moltbot";
Name = "Open";
// Try to load settings from tray app
LoadSettings();
}
private static void LoadSettings()
{
try
{
var settingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"MoltbotTray", "settings.json");
if (File.Exists(settingsPath))
{
var json = File.ReadAllText(settingsPath);
var settings = System.Text.Json.JsonDocument.Parse(json);
if (settings.RootElement.TryGetProperty("GatewayUrl", out var url))
_gatewayUrl = url.GetString() ?? _gatewayUrl;
if (settings.RootElement.TryGetProperty("Token", out var token))
_token = token.GetString() ?? "";
}
}
catch { }
}
public override IListItem[] GetItems()
{
var items = new List<IListItem>
{
new ListItem(new OpenDashboardCommand(_gatewayUrl, _token))
{
Title = "🦞 Open Dashboard",
Subtitle = "Open Moltbot web dashboard in browser"
},
new ListItem(new QuickSendCommand(_gatewayUrl, _token))
{
Title = "💬 Quick Send",
Subtitle = "Send a message to Moltbot"
},
new ListItem(new StatusPage(_gatewayUrl, _token))
{
Title = "📊 Full Status",
Subtitle = "View gateway, sessions, and channels"
},
new ListItem(new SessionsPage(_gatewayUrl, _token))
{
Title = "⚡ Sessions",
Subtitle = "View active agent sessions"
},
new ListItem(new ChannelsPage(_gatewayUrl, _token))
{
Title = "📡 Channels",
Subtitle = "View Telegram, WhatsApp status"
},
new ListItem(new HealthCheckCommand(_gatewayUrl, _token))
{
Title = "🔄 Health Check",
Subtitle = "Run a gateway health check"
}
};
return items.ToArray();
}
}
/// <summary>
/// Command to open the Moltbot dashboard in the browser.
/// </summary>
internal sealed partial class OpenDashboardCommand : InvokableCommand
{
private readonly string _gatewayUrl;
private readonly string _token;
public OpenDashboardCommand(string gatewayUrl, string token)
{
_gatewayUrl = gatewayUrl;
_token = token;
}
public override ICommandResult Invoke()
{
try
{
var url = GetDashboardUrl(_gatewayUrl, _token);
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url)
{
UseShellExecute = true
});
}
catch { }
return CommandResult.Hide();
}
internal static string GetDashboardUrl(string gatewayUrl, string token)
{
var url = gatewayUrl
.Replace("ws://", "http://")
.Replace("wss://", "https://");
if (!string.IsNullOrEmpty(token))
{
var separator = url.Contains('?') ? "&" : "?";
url = $"{url}{separator}token={Uri.EscapeDataString(token)}";
}
return url;
}
}
/// <summary>
/// Command to send a quick message - prompts for input then sends.
/// </summary>
internal sealed partial class QuickSendCommand : InvokableCommand
{
private readonly string _gatewayUrl;
private readonly string _token;
public QuickSendCommand(string gatewayUrl, string token)
{
_gatewayUrl = gatewayUrl;
_token = token;
Name = "Send message to Moltbot";
}
public override ICommandResult Invoke()
{
// Open a simple input dialog using Windows forms
try
{
// Use the dashboard URL with a message prompt
var url = OpenDashboardCommand.GetDashboardUrl(_gatewayUrl, _token);
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url)
{
UseShellExecute = true
});
}
catch { }
return CommandResult.Hide();
}
}
/// <summary>
/// Command to run a health check.
/// </summary>
internal sealed partial class HealthCheckCommand : InvokableCommand
{
private readonly string _gatewayUrl;
private readonly string _token;
public HealthCheckCommand(string gatewayUrl, string token)
{
_gatewayUrl = gatewayUrl;
_token = token;
}
public override ICommandResult Invoke()
{
// Just run the health check and show a toast/notification
Task.Run(async () =>
{
try
{
using var client = new MoltbotGatewayClient(_gatewayUrl, _token);
await client.ConnectAsync();
await client.CheckHealthAsync();
await Task.Delay(1000);
await client.DisconnectAsync();
}
catch { }
});
// Keep palette open - user can check status page
return CommandResult.KeepOpen();
}
}
/// <summary>
/// Page showing active sessions.
/// </summary>
internal sealed partial class SessionsPage : ContentPage
{
private readonly string _gatewayUrl;
private readonly string _token;
public SessionsPage(string gatewayUrl, string token)
{
_gatewayUrl = gatewayUrl;
_token = token;
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
Title = "Sessions";
Name = "View sessions";
}
public override IContent[] GetContent()
{
var sb = new StringBuilder();
sb.AppendLine("## ⚡ Active Sessions");
sb.AppendLine();
try
{
using var client = new MoltbotGatewayClient(_gatewayUrl, _token);
var task = client.ConnectAsync();
task.Wait(TimeSpan.FromSeconds(3));
if (!task.IsCompletedSuccessfully)
{
sb.AppendLine("❌ Could not connect to gateway");
return [new MarkdownContent { Body = sb.ToString() }];
}
client.RequestSessionsAsync().Wait(TimeSpan.FromSeconds(2));
var sessions = client.GetSessionList();
if (sessions.Length == 0)
{
sb.AppendLine("_No active sessions_");
}
else
{
// Group by main/sub
var mainSessions = new List<SessionInfo>();
var subSessions = new List<SessionInfo>();
foreach (var s in sessions)
{
if (s.IsMain) mainSessions.Add(s);
else subSessions.Add(s);
}
if (mainSessions.Count > 0)
{
sb.AppendLine("### ⚡ Main Sessions");
foreach (var s in mainSessions)
{
sb.AppendLine($"- **{s.ShortKey}**");
if (!string.IsNullOrEmpty(s.Model))
sb.AppendLine($" - Model: `{s.Model}`");
if (!string.IsNullOrEmpty(s.Channel))
sb.AppendLine($" - Channel: {s.Channel}");
if (s.StartedAt.HasValue)
sb.AppendLine($" - Started: {s.StartedAt:g}");
}
sb.AppendLine();
}
if (subSessions.Count > 0)
{
sb.AppendLine($"### 🔹 Sub-Sessions ({subSessions.Count})");
foreach (var s in subSessions)
{
var activity = !string.IsNullOrEmpty(s.CurrentActivity) ? $" - {s.CurrentActivity}" : "";
sb.AppendLine($"- {s.ShortKey}{activity}");
}
}
}
client.DisconnectAsync().Wait(TimeSpan.FromSeconds(1));
}
catch (Exception ex)
{
sb.AppendLine($"❌ Error: {ex.Message}");
}
return [new MarkdownContent { Body = sb.ToString() }];
}
}
/// <summary>
/// Page showing channel health.
/// </summary>
internal sealed partial class ChannelsPage : ContentPage
{
private readonly string _gatewayUrl;
private readonly string _token;
private ChannelHealth[]? _channels;
public ChannelsPage(string gatewayUrl, string token)
{
_gatewayUrl = gatewayUrl;
_token = token;
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
Title = "Channels";
Name = "View channels";
}
public override IContent[] GetContent()
{
var sb = new StringBuilder();
sb.AppendLine("## 📡 Channel Status");
sb.AppendLine();
try
{
using var client = new MoltbotGatewayClient(_gatewayUrl, _token);
client.ChannelHealthUpdated += (s, channels) => _channels = channels;
var task = client.ConnectAsync();
task.Wait(TimeSpan.FromSeconds(3));
if (!task.IsCompletedSuccessfully)
{
sb.AppendLine("❌ Could not connect to gateway");
return [new MarkdownContent { Body = sb.ToString() }];
}
// Health check fetches channel status
client.CheckHealthAsync().Wait(TimeSpan.FromSeconds(2));
if (_channels == null || _channels.Length == 0)
{
sb.AppendLine("_No channels configured_");
}
else
{
foreach (var ch in _channels)
{
var statusIcon = ch.Status.ToLowerInvariant() switch
{
"running" or "ok" or "connected" => "🟢",
"ready" => "🟡",
"linked" => "🔵",
"stopped" or "configured" => "⚪",
"error" => "🔴",
_ => "⚫"
};
var name = char.ToUpper(ch.Name[0]) + ch.Name[1..];
sb.AppendLine($"### {statusIcon} {name}");
sb.AppendLine();
sb.AppendLine($"- **Status:** {ch.Status}");
if (ch.IsLinked)
sb.AppendLine("- **Linked:** ✅ Yes");
if (!string.IsNullOrEmpty(ch.AuthAge))
sb.AppendLine($"- **Auth Age:** {ch.AuthAge}");
if (!string.IsNullOrEmpty(ch.Error))
sb.AppendLine($"- **Error:** ⚠️ {ch.Error}");
sb.AppendLine();
}
}
client.DisconnectAsync().Wait(TimeSpan.FromSeconds(1));
}
catch (Exception ex)
{
sb.AppendLine($"❌ Error: {ex.Message}");
}
return [new MarkdownContent { Body = sb.ToString() }];
}
}
/// <summary>
/// Page showing full Moltbot status information.
/// </summary>
internal sealed partial class StatusPage : ContentPage
{
private readonly string _gatewayUrl;
private readonly string _token;
private ChannelHealth[]? _channels;
public StatusPage(string gatewayUrl, string token)
{
_gatewayUrl = gatewayUrl;
_token = token;
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
Title = "Moltbot Status";
Name = "View status";
}
public override IContent[] GetContent()
{
var markdown = new MarkdownContent
{
Body = GetStatusMarkdown()
};
return [markdown];
}
private string GetStatusMarkdown()
{
var sb = new StringBuilder();
try
{
using var client = new MoltbotGatewayClient(_gatewayUrl, _token);
client.ChannelHealthUpdated += (s, channels) => _channels = channels;
var task = client.ConnectAsync();
task.Wait(TimeSpan.FromSeconds(3));
if (!task.IsCompletedSuccessfully)
{
return "## ❌ Disconnected\n\nCould not connect to gateway.\n\nMake sure Moltbot gateway is running.";
}
sb.AppendLine("## 🦞 Moltbot Status");
sb.AppendLine();
sb.AppendLine("### Connection");
sb.AppendLine($"- **Gateway:** `{_gatewayUrl}`");
sb.AppendLine("- **Status:** ✅ Connected");
sb.AppendLine();
// Get health and sessions
client.CheckHealthAsync().Wait(TimeSpan.FromSeconds(2));
client.RequestSessionsAsync().Wait(TimeSpan.FromSeconds(1));
var sessions = client.GetSessionList();
// Sessions
sb.AppendLine("### ⚡ Sessions");
if (sessions.Length == 0)
{
sb.AppendLine("_No active sessions_");
}
else
{
var mainCount = 0;
var subCount = 0;
foreach (var s in sessions)
{
if (s.IsMain) mainCount++;
else subCount++;
}
sb.AppendLine($"- Main: **{mainCount}** | Sub: **{subCount}**");
sb.AppendLine();
foreach (var s in sessions.Take(5))
{
var icon = s.IsMain ? "⚡" : "🔹";
sb.AppendLine($"- {icon} {s.DisplayText}");
}
if (sessions.Length > 5)
sb.AppendLine($"- _...and {sessions.Length - 5} more_");
}
sb.AppendLine();
// Channels
sb.AppendLine("### 📡 Channels");
if (_channels == null || _channels.Length == 0)
{
sb.AppendLine("_No channels configured_");
}
else
{
foreach (var ch in _channels)
{
var statusIcon = ch.Status.ToLowerInvariant() switch
{
"running" or "ok" => "🟢",
"ready" => "🟡",
"linked" => "🔵",
"stopped" => "⚪",
"error" => "🔴",
_ => "⚫"
};
var name = char.ToUpper(ch.Name[0]) + ch.Name[1..];
sb.AppendLine($"- {statusIcon} **{name}:** {ch.Status}");
}
}
client.DisconnectAsync().Wait(TimeSpan.FromSeconds(1));
sb.AppendLine();
sb.AppendLine("---");
sb.AppendLine("_Use 🦞 Open Dashboard for the full web interface_");
return sb.ToString();
}
catch (Exception ex)
{
return $"## ❌ Error\n\n{ex.Message}\n\nMake sure the gateway is running and your settings are correct.";
}
}
}

View File

@ -0,0 +1,44 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CommandPalette.Extensions;
using Shmuelie.WinRTServer;
using Shmuelie.WinRTServer.CsWinRT;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Moltbot;
public class Program
{
[MTAThread]
public static void Main(string[] args)
{
if (args.Length > 0 && args[0] == "-RegisterProcessAsComServer")
{
global::Shmuelie.WinRTServer.ComServer server = new();
ManualResetEvent extensionDisposedEvent = new(false);
// We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called.
// This makes sure that only one instance of SampleExtension is alive, which is returned every time the host asks for the IExtension object.
// If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate.
Moltbot extensionInstance = new(extensionDisposedEvent);
server.RegisterClass<Moltbot, IExtension>(() => extensionInstance);
server.Start();
// This will make the main thread wait until the event is signalled by the extension class.
// Since we have single instance of the extension object, we exit as soon as it is disposed.
extensionDisposedEvent.WaitOne();
server.Stop();
server.UnsafeDispose();
}
else
{
Console.WriteLine("Not being launched as a Extension... exiting.");
}
}
}

View File

@ -0,0 +1,11 @@
{
"profiles": {
"Moltbot (Package)": {
"commandName": "MsixPackage",
"doNotLaunchApp": true
},
"Moltbot (Unpackaged)": {
"commandName": "Project"
}
}
}

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Moltbot.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- The ID below informs the system that this application is compatible with OS features first introduced in Windows 10.
It is necessary to support features in unpackaged applications, for example the custom titlebar implementation.
For more info see https://docs.microsoft.com/windows/apps/windows-app-sdk/use-windows-app-sdk-run-time#declare-os-compatibility-in-your-application-manifest -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>

View File

@ -0,0 +1,35 @@
namespace Moltbot.Shared;
/// <summary>
/// Simple logger interface for the gateway client.
/// Implementations can write to file, console, debug output, etc.
/// </summary>
public interface IMoltbotLogger
{
void Info(string message);
void Warn(string message);
void Error(string message, Exception? ex = null);
}
/// <summary>
/// Default no-op logger for when logging isn't needed.
/// </summary>
public class NullLogger : IMoltbotLogger
{
public static readonly NullLogger Instance = new();
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? ex = null) { }
}
/// <summary>
/// Console logger for simple debugging.
/// </summary>
public class ConsoleLogger : IMoltbotLogger
{
public void Info(string message) => Console.WriteLine($"[INFO] {message}");
public void Warn(string message) => Console.WriteLine($"[WARN] {message}");
public void Error(string message, Exception? ex = null) =>
Console.WriteLine($"[ERROR] {message}{(ex != null ? $": {ex.Message}" : "")}");
}

View File

@ -0,0 +1,186 @@
namespace Moltbot.Shared;
public enum ConnectionStatus
{
Disconnected,
Connecting,
Connected,
Error
}
public enum ActivityKind
{
Idle,
Job,
Exec,
Read,
Write,
Edit,
Search,
Browser,
Message,
Tool
}
public class AgentActivity
{
public string SessionKey { get; set; } = "";
public bool IsMain { get; set; }
public ActivityKind Kind { get; set; } = ActivityKind.Idle;
public string State { get; set; } = "";
public string ToolName { get; set; } = "";
public string Label { get; set; } = "";
public string Glyph => Kind switch
{
ActivityKind.Exec => "💻",
ActivityKind.Read => "📄",
ActivityKind.Write => "✍️",
ActivityKind.Edit => "📝",
ActivityKind.Search => "🔍",
ActivityKind.Browser => "🌐",
ActivityKind.Message => "💬",
ActivityKind.Tool => "🛠️",
ActivityKind.Job => "⚡",
_ => ""
};
public string DisplayText => Kind == ActivityKind.Idle
? ""
: $"{(IsMain ? "Main" : "Sub")} · {Glyph} {Label}";
}
public class MoltbotNotification
{
public string Title { get; set; } = "";
public string Message { get; set; } = "";
public string Type { get; set; } = "";
}
public class ChannelHealth
{
public string Name { get; set; } = "";
public string Status { get; set; } = "unknown";
public bool IsLinked { get; set; }
public string? Error { get; set; }
public string? AuthAge { get; set; }
public string? Type { get; set; }
public string DisplayText
{
get
{
var label = Status.ToLowerInvariant() switch
{
"ok" or "connected" or "running" => "[ON]",
"linked" => "[LINKED]",
"ready" => "[READY]",
"connecting" or "reconnecting" => "[...]",
"error" or "disconnected" => "[ERR]",
"stale" => "[STALE]",
"configured" or "stopped" => "[OFF]",
"not configured" => "[N/A]",
_ => "[OFF]"
};
var detail = IsLinked && AuthAge != null ? $"linked · {AuthAge}" : Status;
if (Error != null) detail += $" ({Error})";
return $"{label} {Capitalize(Name)}: {detail}";
}
}
private static string Capitalize(string s) =>
string.IsNullOrEmpty(s) ? s : char.ToUpper(s[0]) + s[1..];
}
public class SessionInfo
{
public string Key { get; set; } = "";
public bool IsMain { get; set; }
public string Status { get; set; } = "unknown";
public string? Model { get; set; }
public string? Channel { get; set; }
public string? CurrentActivity { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime LastSeen { get; set; } = DateTime.UtcNow;
public string DisplayText
{
get
{
var prefix = IsMain ? "Main" : "Sub";
var parts = new List<string> { prefix };
if (!string.IsNullOrEmpty(Channel))
parts.Add(Channel);
if (!string.IsNullOrEmpty(CurrentActivity))
parts.Add(CurrentActivity);
else if (!string.IsNullOrEmpty(Status) && Status != "unknown" && Status != "active")
parts.Add(Status);
return string.Join(" · ", parts);
}
}
/// <summary>Gets a shortened, user-friendly version of the session key.</summary>
public string ShortKey
{
get
{
if (string.IsNullOrEmpty(Key)) return "unknown";
// Extract meaningful part from session keys like "agent:main:subagent:uuid"
var parts = Key.Split(':');
if (parts.Length >= 3)
{
// Return something like "subagent" or "cron"
return parts[^2]; // Second to last part
}
// For file paths, just return filename
if (Key.Contains('/') || Key.Contains('\\'))
{
return Path.GetFileName(Key);
}
return Key.Length > 20 ? Key[..17] + "..." : Key;
}
}
}
public class GatewayUsageInfo
{
public long InputTokens { get; set; }
public long OutputTokens { get; set; }
public long TotalTokens { get; set; }
public double CostUsd { get; set; }
public int RequestCount { get; set; }
public string? Model { get; set; }
public string DisplayText
{
get
{
var parts = new List<string>();
if (TotalTokens > 0)
parts.Add($"Tokens: {FormatCount(TotalTokens)}");
if (CostUsd > 0)
parts.Add($"${CostUsd:F2}");
if (RequestCount > 0)
parts.Add($"{RequestCount} requests");
if (!string.IsNullOrEmpty(Model))
parts.Add(Model);
return parts.Count > 0
? string.Join(" · ", parts)
: "No usage data";
}
}
private static string FormatCount(long n)
{
if (n >= 1_000_000) return $"{n / 1_000_000.0:F1}M";
if (n >= 1_000) return $"{n / 1_000.0:F1}K";
return n.ToString();
}
}

View File

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Moltbot.Shared</RootNamespace>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,862 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Moltbot.Shared;
public class MoltbotGatewayClient : IDisposable
{
private ClientWebSocket? _webSocket;
private readonly string _gatewayUrl;
private readonly string _token;
private readonly IMoltbotLogger _logger;
private CancellationTokenSource _cts;
private bool _disposed;
private int _reconnectAttempts;
private static readonly int[] BackoffMs = { 1000, 2000, 4000, 8000, 15000, 30000, 60000 };
// Tracked state
private readonly Dictionary<string, SessionInfo> _sessions = new();
private GatewayUsageInfo? _usage;
// Events
public event EventHandler<ConnectionStatus>? StatusChanged;
public event EventHandler<MoltbotNotification>? NotificationReceived;
public event EventHandler<AgentActivity>? ActivityChanged;
public event EventHandler<ChannelHealth[]>? ChannelHealthUpdated;
public event EventHandler<SessionInfo[]>? SessionsUpdated;
public event EventHandler<GatewayUsageInfo>? UsageUpdated;
public MoltbotGatewayClient(string gatewayUrl, string token, IMoltbotLogger? logger = null)
{
_gatewayUrl = gatewayUrl;
_token = token;
_logger = logger ?? NullLogger.Instance;
_cts = new CancellationTokenSource();
}
public async Task ConnectAsync()
{
try
{
StatusChanged?.Invoke(this, ConnectionStatus.Connecting);
_logger.Info($"Connecting to gateway: {_gatewayUrl}");
_webSocket = new ClientWebSocket();
_webSocket.Options.KeepAliveInterval = TimeSpan.FromSeconds(30);
// Set Origin header to localhost to satisfy secure context check
_webSocket.Options.SetRequestHeader("Origin", "http://localhost:18789");
var uri = new Uri(_gatewayUrl);
await _webSocket.ConnectAsync(uri, _cts.Token);
_reconnectAttempts = 0;
_logger.Info("Gateway connected, waiting for challenge...");
// Don't send connect yet - wait for challenge event in ListenForMessagesAsync
_ = Task.Run(() => ListenForMessagesAsync(), _cts.Token);
}
catch (Exception ex)
{
_logger.Error("Connection failed", ex);
StatusChanged?.Invoke(this, ConnectionStatus.Error);
throw;
}
}
public async Task DisconnectAsync()
{
if (_webSocket?.State == WebSocketState.Open)
{
try
{
await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disconnecting", CancellationToken.None);
}
catch (Exception ex)
{
_logger.Warn($"Error during disconnect: {ex.Message}");
}
}
StatusChanged?.Invoke(this, ConnectionStatus.Disconnected);
_logger.Info("Disconnected");
}
public async Task CheckHealthAsync()
{
if (_webSocket?.State != WebSocketState.Open)
{
await ReconnectWithBackoffAsync();
return;
}
try
{
var req = new
{
type = "req",
id = Guid.NewGuid().ToString(),
method = "health",
@params = new { deep = true }
};
await SendRawAsync(JsonSerializer.Serialize(req));
}
catch (Exception ex)
{
_logger.Error("Health check failed", ex);
StatusChanged?.Invoke(this, ConnectionStatus.Error);
await ReconnectWithBackoffAsync();
}
}
public async Task SendChatMessageAsync(string message)
{
if (_webSocket?.State != WebSocketState.Open)
throw new InvalidOperationException("Gateway connection is not open");
var req = new
{
type = "req",
id = Guid.NewGuid().ToString(),
method = "chat.send",
@params = new { message }
};
await SendRawAsync(JsonSerializer.Serialize(req));
_logger.Info($"Sent chat message ({message.Length} chars)");
}
/// <summary>Request session list from gateway.</summary>
public async Task RequestSessionsAsync()
{
if (_webSocket?.State != WebSocketState.Open) return;
var req = new
{
type = "req",
id = Guid.NewGuid().ToString(),
method = "sessions.list"
};
await SendRawAsync(JsonSerializer.Serialize(req));
}
/// <summary>Request usage/context info from gateway (may not be supported on all gateways).</summary>
public async Task RequestUsageAsync()
{
// Usage endpoint may not exist on all gateways - fail silently
if (_webSocket?.State != WebSocketState.Open) return;
try
{
var req = new
{
type = "req",
id = Guid.NewGuid().ToString(),
method = "usage"
};
await SendRawAsync(JsonSerializer.Serialize(req));
}
catch { }
}
// --- Connection management ---
private async Task ReconnectWithBackoffAsync()
{
var delay = BackoffMs[Math.Min(_reconnectAttempts, BackoffMs.Length - 1)];
_reconnectAttempts++;
_logger.Warn($"Reconnecting in {delay}ms (attempt {_reconnectAttempts})");
StatusChanged?.Invoke(this, ConnectionStatus.Connecting);
try
{
await Task.Delay(delay, _cts.Token);
_webSocket?.Dispose();
_webSocket = null;
await ConnectAsync();
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.Error("Reconnect failed", ex);
StatusChanged?.Invoke(this, ConnectionStatus.Error);
// Don't recurse — the listen loop will trigger reconnect again
}
}
private async Task SendConnectMessageAsync(string? nonce = null)
{
// Use "cli" client ID for native apps - no browser security checks
var msg = new
{
type = "req",
id = Guid.NewGuid().ToString(),
method = "connect",
@params = new
{
minProtocol = 3,
maxProtocol = 3,
client = new
{
id = "cli", // Native client ID
version = "1.0.0",
platform = "windows",
mode = "cli",
displayName = "Clawdbot Windows Tray"
},
role = "operator",
scopes = new[] { "operator.admin", "operator.approvals", "operator.pairing" },
caps = Array.Empty<string>(),
commands = Array.Empty<string>(),
permissions = new { },
auth = new { token = _token },
locale = "en-US",
userAgent = "clawdbot-windows-tray/1.0.0"
}
};
await SendRawAsync(JsonSerializer.Serialize(msg));
}
private async Task SendRawAsync(string message)
{
if (_webSocket?.State == WebSocketState.Open)
{
var bytes = Encoding.UTF8.GetBytes(message);
await _webSocket.SendAsync(new ArraySegment<byte>(bytes),
WebSocketMessageType.Text, true, _cts.Token);
}
}
// --- Message loop ---
private async Task ListenForMessagesAsync()
{
var buffer = new byte[16384]; // Larger buffer for big events
var sb = new StringBuilder();
try
{
while (_webSocket?.State == WebSocketState.Open && !_cts.Token.IsCancellationRequested)
{
var result = await _webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer), _cts.Token);
if (result.MessageType == WebSocketMessageType.Text)
{
sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
if (result.EndOfMessage)
{
ProcessMessage(sb.ToString());
sb.Clear();
}
}
else if (result.MessageType == WebSocketMessageType.Close)
{
var closeStatus = _webSocket.CloseStatus?.ToString() ?? "unknown";
var closeDesc = _webSocket.CloseStatusDescription ?? "no description";
_logger.Info($"Server closed connection: {closeStatus} - {closeDesc}");
StatusChanged?.Invoke(this, ConnectionStatus.Disconnected);
break;
}
}
}
catch (WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely)
{
_logger.Warn("Connection closed prematurely");
StatusChanged?.Invoke(this, ConnectionStatus.Disconnected);
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.Error("Listen error", ex);
StatusChanged?.Invoke(this, ConnectionStatus.Error);
}
// Auto-reconnect if not intentionally disposed
if (!_disposed && !_cts.Token.IsCancellationRequested)
{
await ReconnectWithBackoffAsync();
}
}
// --- Message processing ---
private void ProcessMessage(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("type", out var typeProp)) return;
var type = typeProp.GetString();
switch (type)
{
case "res":
HandleResponse(root);
break;
case "event":
HandleEvent(root);
break;
}
}
catch (JsonException ex)
{
_logger.Warn($"JSON parse error: {ex.Message}");
}
catch (Exception ex)
{
_logger.Error("Message processing error", ex);
}
}
private void HandleResponse(JsonElement root)
{
if (!root.TryGetProperty("payload", out var payload)) return;
// Handle hello-ok
if (payload.TryGetProperty("type", out var t) && t.GetString() == "hello-ok")
{
_logger.Info("Handshake complete (hello-ok)");
StatusChanged?.Invoke(this, ConnectionStatus.Connected);
// Request initial state after handshake
_ = Task.Run(async () =>
{
await Task.Delay(500);
await CheckHealthAsync();
await RequestSessionsAsync();
await RequestUsageAsync();
});
}
// Handle health response — channels
if (payload.TryGetProperty("channels", out var channels))
{
ParseChannelHealth(channels);
}
// Handle sessions response
if (payload.TryGetProperty("sessions", out var sessions))
{
ParseSessions(sessions);
}
// Handle usage response
if (payload.TryGetProperty("usage", out var usage))
{
ParseUsage(usage);
}
}
private void HandleEvent(JsonElement root)
{
if (!root.TryGetProperty("event", out var eventProp)) return;
var eventType = eventProp.GetString();
switch (eventType)
{
case "connect.challenge":
HandleConnectChallenge(root);
break;
case "agent":
HandleAgentEvent(root);
break;
case "health":
if (root.TryGetProperty("payload", out var hp) &&
hp.TryGetProperty("channels", out var ch))
ParseChannelHealth(ch);
break;
case "chat":
HandleChatEvent(root);
break;
case "session":
HandleSessionEvent(root);
break;
}
}
private void HandleConnectChallenge(JsonElement root)
{
string? nonce = null;
if (root.TryGetProperty("payload", out var payload) &&
payload.TryGetProperty("nonce", out var nonceProp))
{
nonce = nonceProp.GetString();
}
_logger.Info($"Received challenge, nonce: {nonce}");
_ = SendConnectMessageAsync(nonce);
}
private void HandleAgentEvent(JsonElement root)
{
if (!root.TryGetProperty("payload", out var payload)) return;
// Determine session
var sessionKey = "unknown";
if (root.TryGetProperty("sessionKey", out var sk))
sessionKey = sk.GetString() ?? "unknown";
var isMain = sessionKey == "main" || sessionKey.Contains(":main:");
// Parse activity from stream field
if (payload.TryGetProperty("stream", out var streamProp))
{
var stream = streamProp.GetString();
if (stream == "job")
{
HandleJobEvent(payload, sessionKey, isMain);
}
else if (stream == "tool")
{
HandleToolEvent(payload, sessionKey, isMain);
}
}
// Check for notification content
if (payload.TryGetProperty("content", out var content))
{
var text = content.GetString() ?? "";
if (!string.IsNullOrEmpty(text))
{
EmitNotification(text);
}
}
}
private void HandleJobEvent(JsonElement payload, string sessionKey, bool isMain)
{
var state = "unknown";
if (payload.TryGetProperty("data", out var data) &&
data.TryGetProperty("state", out var stateProp))
state = stateProp.GetString() ?? "unknown";
var activity = new AgentActivity
{
SessionKey = sessionKey,
IsMain = isMain,
Kind = ActivityKind.Job,
State = state,
Label = $"Job: {state}"
};
if (state == "done" || state == "error")
activity.Kind = ActivityKind.Idle;
_logger.Info($"Agent activity: {activity.Label} (session: {sessionKey})");
ActivityChanged?.Invoke(this, activity);
// Update tracked session
UpdateTrackedSession(sessionKey, isMain, state == "done" || state == "error" ? null : $"Job: {state}");
}
private void HandleToolEvent(JsonElement payload, string sessionKey, bool isMain)
{
var phase = "";
var toolName = "";
var label = "";
if (payload.TryGetProperty("data", out var data))
{
if (data.TryGetProperty("phase", out var phaseProp))
phase = phaseProp.GetString() ?? "";
if (data.TryGetProperty("name", out var nameProp))
toolName = nameProp.GetString() ?? "";
// Extract detail from args
if (data.TryGetProperty("args", out var args))
{
if (args.TryGetProperty("command", out var cmd))
label = TruncateLabel(cmd.GetString()?.Split('\n')[0] ?? "");
else if (args.TryGetProperty("path", out var path))
label = ShortenPath(path.GetString() ?? "");
else if (args.TryGetProperty("file_path", out var filePath))
label = ShortenPath(filePath.GetString() ?? "");
else if (args.TryGetProperty("query", out var query))
label = TruncateLabel(query.GetString() ?? "");
else if (args.TryGetProperty("url", out var url))
label = TruncateLabel(url.GetString() ?? "");
}
}
if (string.IsNullOrEmpty(label))
label = toolName;
var kind = ClassifyTool(toolName);
// On tool result, briefly show then go idle
if (phase == "result")
kind = ActivityKind.Idle;
var activity = new AgentActivity
{
SessionKey = sessionKey,
IsMain = isMain,
Kind = kind,
State = phase,
ToolName = toolName,
Label = label
};
_logger.Info($"Tool: {toolName} ({phase}) — {label}");
ActivityChanged?.Invoke(this, activity);
// Update tracked session
if (kind != ActivityKind.Idle)
{
UpdateTrackedSession(sessionKey, isMain, $"{activity.Glyph} {label}");
}
}
private void HandleChatEvent(JsonElement root)
{
_logger.Info($"Chat event received: {root.GetRawText().Substring(0, Math.Min(200, root.GetRawText().Length))}");
if (!root.TryGetProperty("payload", out var payload)) return;
if (payload.TryGetProperty("text", out var textProp))
{
var text = textProp.GetString() ?? "";
if (payload.TryGetProperty("role", out var role) &&
role.GetString() == "assistant" &&
!string.IsNullOrEmpty(text))
{
_logger.Info($"Assistant response: {text.Substring(0, Math.Min(100, text.Length))}");
// Only notify for short assistant messages (likely alerts/responses)
if (text.Length < 500)
{
EmitNotification(text);
}
}
}
}
private void HandleSessionEvent(JsonElement root)
{
// Re-request sessions list when session events come through
_ = RequestSessionsAsync();
}
// --- State tracking ---
private void UpdateTrackedSession(string sessionKey, bool isMain, string? currentActivity)
{
if (!_sessions.ContainsKey(sessionKey))
{
_sessions[sessionKey] = new SessionInfo
{
Key = sessionKey,
IsMain = isMain,
Status = "active"
};
}
_sessions[sessionKey].CurrentActivity = currentActivity;
_sessions[sessionKey].LastSeen = DateTime.UtcNow;
SessionsUpdated?.Invoke(this, GetSessionList());
}
public SessionInfo[] GetSessionList()
{
var list = new List<SessionInfo>(_sessions.Values);
list.Sort((a, b) =>
{
// Main session first, then by last seen
if (a.IsMain != b.IsMain) return a.IsMain ? -1 : 1;
return b.LastSeen.CompareTo(a.LastSeen);
});
return list.ToArray();
}
// --- Parsing helpers ---
private void ParseChannelHealth(JsonElement channels)
{
var healthList = new List<ChannelHealth>();
// Debug: log raw channel data
_logger.Info($"Raw channel health JSON: {channels.GetRawText()}");
foreach (var prop in channels.EnumerateObject())
{
var ch = new ChannelHealth { Name = prop.Name };
var val = prop.Value;
// Get running status
bool isRunning = false;
bool isConfigured = false;
bool isLinked = false;
bool probeOk = false;
if (val.TryGetProperty("running", out var running))
isRunning = running.GetBoolean();
if (val.TryGetProperty("configured", out var configured))
isConfigured = configured.GetBoolean();
if (val.TryGetProperty("linked", out var linked))
{
isLinked = linked.GetBoolean();
ch.IsLinked = isLinked;
}
// Check probe status for webhook-based channels like Telegram
if (val.TryGetProperty("probe", out var probe) && probe.TryGetProperty("ok", out var ok))
probeOk = ok.GetBoolean();
// Determine status string
if (val.TryGetProperty("status", out var status))
ch.Status = status.GetString() ?? "unknown";
else if (isRunning)
ch.Status = "running";
else if (probeOk && isConfigured)
ch.Status = "ready"; // Webhook mode, bot is responding
else if (isLinked)
ch.Status = "linked"; // Authenticated but not running
else if (isConfigured)
ch.Status = "stopped";
else
ch.Status = "not configured";
if (val.TryGetProperty("error", out var error))
ch.Error = error.GetString();
if (val.TryGetProperty("authAge", out var authAge))
ch.AuthAge = authAge.GetString();
if (val.TryGetProperty("type", out var chType))
ch.Type = chType.GetString();
healthList.Add(ch);
}
if (healthList.Count > 0)
{
_logger.Info($"Channel health: {string.Join(", ", healthList.ConvertAll(c => $"{c.Name}={c.Status}"))}");
ChannelHealthUpdated?.Invoke(this, healthList.ToArray());
}
}
private void ParseSessions(JsonElement sessions)
{
try
{
_sessions.Clear();
// Handle both Array format and Object (dictionary) format
if (sessions.ValueKind == JsonValueKind.Array)
{
foreach (var item in sessions.EnumerateArray())
{
ParseSessionItem(item);
}
}
else if (sessions.ValueKind == JsonValueKind.Object)
{
// Object format: keys are session IDs, values could be session info objects or simple strings
foreach (var prop in sessions.EnumerateObject())
{
var sessionKey = prop.Name;
// Skip metadata fields that aren't actual sessions
if (sessionKey is "recent" or "count" or "path" or "defaults" or "ts")
continue;
// Skip non-session keys (must look like a session key pattern)
if (!sessionKey.Contains(':') && !sessionKey.Contains("agent") && !sessionKey.Contains("session"))
continue;
var session = new SessionInfo { Key = sessionKey };
var item = prop.Value;
// Detect main session from key pattern - "agent:main:main" ends with ":main"
var endsWithMain = sessionKey.EndsWith(":main");
session.IsMain = sessionKey == "main" || endsWithMain || sessionKey.Contains(":main:main");
_logger.Info($"Session key={sessionKey}, endsWithMain={endsWithMain}, IsMain={session.IsMain}");
// Value might be an object with session details or just a string status
if (item.ValueKind == JsonValueKind.Object)
{
// Only override IsMain if the JSON explicitly says true
if (item.TryGetProperty("isMain", out var isMain) && isMain.GetBoolean())
session.IsMain = true;
if (item.TryGetProperty("status", out var status))
session.Status = status.GetString() ?? "active";
if (item.TryGetProperty("model", out var model))
session.Model = model.GetString();
if (item.TryGetProperty("channel", out var channel))
session.Channel = channel.GetString();
if (item.TryGetProperty("startedAt", out var started))
{
if (DateTime.TryParse(started.GetString(), out var dt))
session.StartedAt = dt;
}
}
else if (item.ValueKind == JsonValueKind.String)
{
// Simple string value - skip if it looks like a path (metadata)
var strVal = item.GetString() ?? "";
if (strVal.StartsWith("/") || strVal.Contains("/."))
continue;
session.Status = strVal;
}
else if (item.ValueKind == JsonValueKind.Number)
{
// Skip numeric values (like count)
continue;
}
_sessions[session.Key] = session;
}
}
SessionsUpdated?.Invoke(this, GetSessionList());
}
catch (Exception ex)
{
_logger.Warn($"Failed to parse sessions: {ex.Message}");
}
}
private void ParseSessionItem(JsonElement item)
{
var session = new SessionInfo();
if (item.TryGetProperty("key", out var key))
session.Key = key.GetString() ?? "unknown";
// Detect main from key pattern first
session.IsMain = session.Key == "main" ||
session.Key.EndsWith(":main") ||
session.Key.Contains(":main:main");
// Only override if JSON explicitly says true
if (item.TryGetProperty("isMain", out var isMain) && isMain.GetBoolean())
session.IsMain = true;
if (item.TryGetProperty("status", out var status))
session.Status = status.GetString() ?? "unknown";
if (item.TryGetProperty("model", out var model))
session.Model = model.GetString();
if (item.TryGetProperty("channel", out var channel))
session.Channel = channel.GetString();
if (item.TryGetProperty("startedAt", out var started))
{
if (DateTime.TryParse(started.GetString(), out var dt))
session.StartedAt = dt;
}
_sessions[session.Key] = session;
}
private void ParseUsage(JsonElement usage)
{
try
{
_usage = new GatewayUsageInfo();
if (usage.TryGetProperty("inputTokens", out var inp))
_usage.InputTokens = inp.GetInt64();
if (usage.TryGetProperty("outputTokens", out var outp))
_usage.OutputTokens = outp.GetInt64();
if (usage.TryGetProperty("totalTokens", out var tot))
_usage.TotalTokens = tot.GetInt64();
if (usage.TryGetProperty("cost", out var cost))
_usage.CostUsd = cost.GetDouble();
if (usage.TryGetProperty("requestCount", out var req))
_usage.RequestCount = req.GetInt32();
if (usage.TryGetProperty("model", out var model))
_usage.Model = model.GetString();
UsageUpdated?.Invoke(this, _usage);
}
catch (Exception ex)
{
_logger.Warn($"Failed to parse usage: {ex.Message}");
}
}
// --- Notification classification ---
private void EmitNotification(string text)
{
var (title, type) = ClassifyNotification(text);
NotificationReceived?.Invoke(this, new MoltbotNotification
{
Title = title,
Message = text.Length > 200 ? text[..200] + "…" : text,
Type = type
});
}
private static (string title, string type) ClassifyNotification(string text)
{
var lower = text.ToLowerInvariant();
if (lower.Contains("blood sugar") || lower.Contains("glucose") ||
lower.Contains("cgm") || lower.Contains("mg/dl"))
return ("🩸 Blood Sugar Alert", "health");
if (lower.Contains("urgent") || lower.Contains("critical") ||
lower.Contains("emergency"))
return ("🚨 Urgent Alert", "urgent");
if (lower.Contains("reminder"))
return ("⏰ Reminder", "reminder");
if (lower.Contains("stock") || lower.Contains("in stock") ||
lower.Contains("available now"))
return ("📦 Stock Alert", "stock");
if (lower.Contains("email") || lower.Contains("inbox") ||
lower.Contains("gmail"))
return ("📧 Email", "email");
if (lower.Contains("calendar") || lower.Contains("meeting") ||
lower.Contains("event"))
return ("📅 Calendar", "calendar");
if (lower.Contains("error") || lower.Contains("failed") ||
lower.Contains("exception"))
return ("⚠️ Error", "error");
if (lower.Contains("build") || lower.Contains("ci ") ||
lower.Contains("deploy"))
return ("🔨 Build", "build");
return ("🤖 Clawdbot", "info");
}
// --- Utility ---
private static ActivityKind ClassifyTool(string toolName)
{
return toolName.ToLowerInvariant() switch
{
"exec" => ActivityKind.Exec,
"read" => ActivityKind.Read,
"write" => ActivityKind.Write,
"edit" => ActivityKind.Edit,
"web_search" => ActivityKind.Search,
"web_fetch" => ActivityKind.Search,
"browser" => ActivityKind.Browser,
"message" => ActivityKind.Message,
"tts" => ActivityKind.Tool,
"image" => ActivityKind.Tool,
_ => ActivityKind.Tool
};
}
private static string ShortenPath(string path)
{
if (string.IsNullOrEmpty(path)) return path;
var parts = path.Replace('\\', '/').Split('/');
return parts.Length > 2
? $"…/{parts[^2]}/{parts[^1]}"
: parts[^1];
}
private static string TruncateLabel(string text, int maxLen = 60)
{
if (string.IsNullOrEmpty(text) || text.Length <= maxLen) return text;
return text[..(maxLen - 1)] + "…";
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
_cts.Cancel();
_webSocket?.Dispose();
_cts.Dispose();
}
}
}

345
src/Moltbot.Tray/.gitignore vendored Normal file
View File

@ -0,0 +1,345 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these files may be extracted
*.azurePubxml
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment the next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
CrystalDecisions.ReportingServices.ViewerObjectModel.dll
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd

View File

@ -0,0 +1,59 @@
using Microsoft.Win32;
using System;
using System.IO;
using System.Reflection;
using System.Windows.Forms;
namespace MoltbotTray;
public static class AutoStartManager
{
private const string RegistryKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run";
private const string ApplicationName = "MoltbotTray";
public static void SetAutoStart(bool enabled)
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, true);
if (key != null)
{
if (enabled)
{
var exePath = GetExecutablePath();
key.SetValue(ApplicationName, $"\"{exePath}\"");
}
else
{
key.DeleteValue(ApplicationName, false);
}
}
}
catch (Exception ex)
{
MessageBox.Show($"Failed to update auto-start setting: {ex.Message}",
"Auto-start Error", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
public static bool IsAutoStartEnabled()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath);
return key?.GetValue(ApplicationName) != null;
}
catch
{
return false;
}
}
private static string GetExecutablePath()
{
// Use ProcessPath for single-file deployments (Assembly.Location is empty)
var location = Environment.ProcessPath ?? Application.ExecutablePath;
return location;
}
}

View File

@ -0,0 +1,167 @@
# Development Notes
## Architecture Overview
This Windows system tray application is built with .NET 9 and Windows Forms, designed to be lightweight and efficient while providing seamless integration with the Clawdbot gateway. It mirrors the macOS menu bar app's functionality for Windows users.
### Component Architecture
```
┌──────────────┐ ┌─────────────────────────┐
│ Program │────▶│ TrayApplication │
│ (entry) │ │ - System tray icon │
│ - Mutex │ │ - Context menu │
│ - URI reg │ │ - Event dispatch (UI) │
└──────────────┘ │ - Session awareness │
└────────┬────────────────┘
│ events
┌────────▼────────────────┐
│ ClawdbotGatewayClient │
│ - WebSocket connection │
│ - Protocol v3 handshake │
│ - Event parsing │
│ - Session/usage tracking│
│ - Auto-reconnect │
└─────────────────────────┘
```
### Key Components
| Component | File | Purpose |
|-----------|------|---------|
| **Program** | `Program.cs` | Entry point, single-instance mutex, URI scheme registration |
| **TrayApplication** | `TrayApplication.cs` | Main `ApplicationContext` managing the tray icon, context menu, and UI event dispatch |
| **ClawdbotGatewayClient** | `ClawdbotGatewayClient.cs` | WebSocket client implementing gateway protocol v3 with event parsing, session tracking, and usage monitoring |
| **SettingsManager** | `SettingsManager.cs` | JSON-based settings persistence in `%APPDATA%\ClawdbotTray\` |
| **SettingsDialog** | `SettingsDialog.cs` | Settings UI with URL/token config, test connection (with timeout), and notification preferences |
| **Logger** | `Logger.cs` | Thread-safe file + debug logger with automatic rotation (1MB), writes to `%LOCALAPPDATA%\ClawdbotTray\clawdbot-tray.log` |
| **DeepLinkHandler** | `DeepLinkHandler.cs` | `clawdbot://` URI scheme registration and processing for cross-app integration |
| **WebChatForm** | `WebChatForm.cs` | WebView2-based chat panel (singleton) with toolbar, fallback to browser |
| **QuickSendDialog** | `QuickSendDialog.cs` | Lightweight dialog for sending messages (supports Ctrl+Enter) |
| **StatusDetailForm** | `StatusDetailForm.cs` | Rich status view showing gateway connection, sessions, channels, usage, and app info |
| **NotificationHistoryForm** | `NotificationHistoryForm.cs` | Scrollable history of received notifications |
| **AutoStartManager** | `AutoStartManager.cs` | Windows startup integration via `HKCU\...\Run` registry key |
| **GlobalHotkey** | `GlobalHotkey.cs` | System-wide hotkey registration for quick access |
### Data Flow
1. **Gateway → Client**: WebSocket messages parsed into typed events (`agent`, `chat`, `health`, `session`, `usage`)
2. **Client → TrayApp**: C# events marshaled to UI thread via `SynchronizationContext.Post`
3. **TrayApp → UI**: Context menu items, tray icon, and toast notifications updated
### Event Types from Gateway
| Event | Handler | UI Result |
|-------|---------|-----------|
| `agent` (stream=job) | `HandleJobEvent` | Activity row update, icon badge |
| `agent` (stream=tool) | `HandleToolEvent` | Activity row with tool name + args detail |
| `chat` | `HandleChatEvent` | Toast notification for short assistant messages |
| `health` | `ParseChannelHealth` | Channel health rows in context menu |
| `session` | `HandleSessionEvent` | Session list refresh |
| `usage` | `ParseUsage` | Usage row (tokens, model, cost) |
### Notification Classification
Notifications are classified two ways:
1. **Structured** (preferred): Events with explicit `type`, `category`, or `notificationType` fields
2. **Text-based** (fallback): Keyword matching on notification content (glucose, reminder, stock, email, calendar, etc.)
### Session Awareness
The activity display uses a stable session selection algorithm:
1. Active main session always takes priority
2. Currently displayed session is kept if still active (prevents flip-flop)
3. Falls back to most recently active sub-session
4. 3-second debounce window prevents jitter during rapid activity changes
### Tray Icon
The tray icon is a programmatically drawn 16×16 circle:
- **Teal**: Connected
- **Amber**: Connecting
- **Red**: Error
- **Gray**: Disconnected
An activity badge (small corner dot) appears during tool execution:
- **Orange**: exec (running commands)
- **Green**: write/edit (file changes)
- **Blue**: read (file access)
- **Purple**: search/browser (web activity)
### Settings Storage
Settings are stored as JSON in `%APPDATA%\ClawdbotTray\settings.json`:
```json
{
"GatewayUrl": "ws://localhost:18789",
"Token": "...",
"AutoStart": false,
"ShowNotifications": true,
"NotificationSound": "Default"
}
```
### Deep Links
The app registers `clawdbot://` URI scheme for cross-app integration:
```
clawdbot://agent?message=Hello&key=optional-auth-key
```
Without a key, the user is prompted before sending. With a key, the message is sent directly.
## Build & Development
### Prerequisites
- .NET 9 SDK
- Windows 10 SDK (19041+) — cross-compilation from Linux supported via `EnableWindowsTargeting`
- WebView2 Runtime (for chat panel, optional at runtime)
### Build
```bash
dotnet build
```
### Publish (single-file, self-contained)
```bash
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
```
### CI
GitHub Actions builds on every push. Check status:
```bash
gh run list --repo shanselman/clawdbot-windows-tray --limit 1
```
## Dependencies
| Package | Purpose |
|---------|---------|
| `Microsoft.Toolkit.Uwp.Notifications` (7.1.3) | Toast notifications with rich content |
| `Microsoft.Web.WebView2` (1.0.3124.44) | Embedded browser for chat panel |
| `System.Text.Json` (9.0.0) | JSON serialization for settings and gateway protocol |
## Security Considerations
- **Token storage**: Plaintext in user AppData (future: Windows Credential Manager)
- **Deep links**: Untrusted deep links prompt user confirmation
- **WebSocket**: Supports both `ws://` (local) and `wss://` (remote)
- **Auto-start**: Registry-based, current user only (no elevation needed)
- **Logging**: Sensitive data (tokens) not logged
## Known Limitations
- Toast notifications require Windows 10 1903+
- WebView2 Runtime must be installed separately for chat panel
- Single-instance enforced via Mutex (deep link forwarding to running instance TODO)
- Tray icon tooltip limited to 63 characters (full detail shown in context menu activity row)

View File

@ -0,0 +1,136 @@
using Microsoft.Win32;
using Moltbot.Shared;
using System;
using System.Collections.Specialized;
using System.Threading.Tasks;
using System.Web;
using System.Windows.Forms;
namespace MoltbotTray;
/// <summary>
/// Handles clawdbot:// URI scheme registration and processing.
/// Matches macOS deep link support (clawdbot://agent?message=...)
/// </summary>
public static class DeepLinkHandler
{
private const string UriScheme = "Moltbot";
private const string FriendlyName = "Clawdbot Agent Command";
/// <summary>
/// Registers the clawdbot:// URI scheme in the Windows registry.
/// Requires elevation for HKCR, falls back to HKCU.
/// </summary>
public static void RegisterUriScheme()
{
try
{
var exePath = Environment.ProcessPath ?? Application.ExecutablePath;
// Try HKCU\Software\Classes (no elevation needed)
using var key = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{UriScheme}");
if (key == null) return;
key.SetValue("", $"URL:{FriendlyName}");
key.SetValue("URL Protocol", "");
using var iconKey = key.CreateSubKey("DefaultIcon");
iconKey?.SetValue("", $"\"{exePath}\",1");
using var commandKey = key.CreateSubKey(@"shell\open\command");
commandKey?.SetValue("", $"\"{exePath}\" \"%1\"");
Logger.Info($"Registered URI scheme: {UriScheme}://");
}
catch (Exception ex)
{
Logger.Error("Failed to register URI scheme", ex);
}
}
/// <summary>
/// Checks if the app was launched with a deep link argument.
/// </summary>
public static bool TryGetDeepLink(string[] args, out Uri? uri)
{
uri = null;
if (args.Length == 0) return false;
foreach (var arg in args)
{
if (arg.StartsWith($"{UriScheme}://", StringComparison.OrdinalIgnoreCase))
{
try
{
uri = new Uri(arg);
return true;
}
catch { }
}
}
return false;
}
/// <summary>
/// Processes a clawdbot:// deep link.
/// Supports: clawdbot://agent?message=...&sessionKey=...&channel=...
/// </summary>
public static async Task ProcessDeepLinkAsync(Uri uri, MoltbotGatewayClient client)
{
Logger.Info($"Processing deep link: {uri}");
var host = uri.Host.ToLowerInvariant();
var query = HttpUtility.ParseQueryString(uri.Query);
switch (host)
{
case "agent":
await HandleAgentDeepLinkAsync(query, client);
break;
default:
Logger.Warn($"Unknown deep link host: {host}");
break;
}
}
private static async Task HandleAgentDeepLinkAsync(NameValueCollection query, MoltbotGatewayClient client)
{
var message = query["message"];
if (string.IsNullOrWhiteSpace(message))
{
Logger.Warn("Deep link: missing message parameter");
return;
}
var key = query["key"];
var hasKey = !string.IsNullOrEmpty(key);
// Without a key, prompt for confirmation (safety)
if (!hasKey)
{
var preview = message.Length > 100 ? message[..100] + "…" : message;
var result = MessageBox.Show(
$"A deep link wants to send this message to Clawdbot:\n\n\"{preview}\"\n\nAllow?",
"Clawdbot Deep Link",
MessageBoxButtons.YesNo,
MessageBoxIcon.Question);
if (result != DialogResult.Yes)
{
Logger.Info("Deep link: user declined");
return;
}
}
try
{
await client.SendChatMessageAsync(message);
Logger.Info($"Deep link: sent message ({message.Length} chars)");
}
catch (Exception ex)
{
Logger.Error("Deep link: failed to send", ex);
}
}
}

View File

@ -0,0 +1,93 @@
using System;
using System.Runtime.InteropServices;
using System.Windows.Forms;
namespace MoltbotTray;
/// <summary>
/// Registers a system-wide hotkey that works even when the app is not focused.
/// Default: Ctrl+Shift+Space to open Quick Send.
/// </summary>
public class GlobalHotkey : IDisposable
{
[DllImport("user32.dll")]
private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
[DllImport("user32.dll")]
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
private const int HotkeyId = 9001;
private const uint MOD_CONTROL = 0x0002;
private const uint MOD_SHIFT = 0x0004;
private const uint VK_SPACE = 0x20;
private readonly HotkeyWindow _window;
private bool _registered;
public event EventHandler? HotkeyPressed;
public GlobalHotkey()
{
_window = new HotkeyWindow(this);
}
public bool Register()
{
try
{
_registered = RegisterHotKey(_window.Handle, HotkeyId, MOD_CONTROL | MOD_SHIFT, VK_SPACE);
if (_registered)
Logger.Info("Global hotkey registered: Ctrl+Shift+Space");
else
Logger.Warn("Failed to register global hotkey (may be in use by another app)");
return _registered;
}
catch (Exception ex)
{
Logger.Error("Hotkey registration error", ex);
return false;
}
}
public void Dispose()
{
if (_registered)
{
UnregisterHotKey(_window.Handle, HotkeyId);
_registered = false;
}
_window.Dispose();
}
internal void OnHotkeyPressed()
{
HotkeyPressed?.Invoke(this, EventArgs.Empty);
}
private class HotkeyWindow : NativeWindow, IDisposable
{
private const int WM_HOTKEY = 0x0312;
private readonly GlobalHotkey _owner;
public HotkeyWindow(GlobalHotkey owner)
{
_owner = owner;
CreateHandle(new CreateParams());
}
protected override void WndProc(ref Message m)
{
if (m.Msg == WM_HOTKEY && m.WParam.ToInt32() == HotkeyId)
{
_owner.OnHotkeyPressed();
}
base.WndProc(ref m);
}
public void Dispose()
{
DestroyHandle();
}
}
}

View File

@ -0,0 +1,45 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**System Information:**
- Windows version: [e.g. Windows 10 21H2, Windows 11 22H2]
- .NET version (if known): [e.g. .NET 10.0.1]
- Clawdbot version: [e.g. 1.2.3]
- Gateway URL: [e.g. ws://localhost:18789 or remote]
**Gateway Information:**
- Is Clawdbot gateway running? [Yes/No]
- Gateway location: [WSL2, remote server, etc.]
- Can you connect to gateway from browser? [Yes/No]
- Gateway logs (if available): [paste relevant logs]
**Additional context**
Add any other context about the problem here.
**Configuration**
- Auto-start enabled: [Yes/No]
- Notifications enabled: [Yes/No]
- First time setup: [Yes/No]
- Custom gateway URL: [Yes/No]

View File

@ -0,0 +1,32 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Use case**
Describe how this feature would be used and who would benefit from it.
**Implementation ideas**
If you have ideas about how this could be implemented, please share them.
**Additional context**
Add any other context or screenshots about the feature request here.
**Priority**
How important is this feature to you?
- [ ] Nice to have
- [ ] Would be very useful
- [ ] Critical for my workflow

View File

@ -0,0 +1,95 @@
using System.Drawing;
namespace MoltbotTray;
/// <summary>
/// Shared icon helper for creating the lobster icon used throughout the app.
/// </summary>
public static class IconHelper
{
private static Icon? _cachedLobsterIcon;
/// <summary>
/// Gets the lobster icon for use in forms and windows.
/// </summary>
public static Icon GetLobsterIcon()
{
if (_cachedLobsterIcon != null)
return _cachedLobsterIcon;
var bitmap = new Bitmap(16, 16);
using (var g = Graphics.FromImage(bitmap))
{
g.Clear(Color.Transparent);
DrawPixelLobster(g);
}
var hIcon = bitmap.GetHicon();
_cachedLobsterIcon = Icon.FromHandle(hIcon);
return _cachedLobsterIcon;
}
private static void DrawPixelLobster(Graphics g)
{
// Pixel lobster from SVG - 16x16 pixel art
var outline = Color.FromArgb(58, 10, 13); // #3a0a0d - dark outline
var body = Color.FromArgb(255, 79, 64); // #ff4f40 - red body
var claw = Color.FromArgb(255, 119, 95); // #ff775f - lighter claws
var eyeDark = Color.FromArgb(8, 16, 22); // #081016 - pupils
var eyeLight = Color.FromArgb(245, 251, 255); // #f5fbff - eye whites
// Outline (dark border)
var outlinePixels = new[] {
(1,5), (1,6), (1,7),
(2,4), (2,8),
(3,3), (3,9),
(4,2), (4,10),
(5,2), (6,2), (7,2), (8,2), (9,2), (10,2),
(11,2), (12,3), (12,9),
(13,4), (13,8),
(14,5), (14,6), (14,7),
(5,11), (6,11), (7,11), (8,11), (9,11), (10,11),
(4,12), (11,12),
(3,13), (12,13),
(5,14), (6,14), (7,14), (8,14), (9,14), (10,14)
};
foreach (var (x, y) in outlinePixels)
SetPixel(g, x, y, outline);
// Body (red)
var bodyPixels = new[] {
(5,3), (6,3), (7,3), (8,3), (9,3), (10,3),
(4,4), (5,4), (7,4), (8,4), (10,4), (11,4),
(3,5), (4,5), (5,5), (7,5), (8,5), (10,5), (11,5), (12,5),
(3,6), (4,6), (5,6), (6,6), (7,6), (8,6), (9,6), (10,6), (11,6), (12,6),
(3,7), (4,7), (5,7), (6,7), (7,7), (8,7), (9,7), (10,7), (11,7), (12,7),
(4,8), (5,8), (6,8), (7,8), (8,8), (9,8), (10,8), (11,8),
(5,9), (6,9), (7,9), (8,9), (9,9), (10,9),
(5,12), (6,12), (7,12), (8,12), (9,12), (10,12),
(6,13), (7,13), (8,13), (9,13)
};
foreach (var (x, y) in bodyPixels)
SetPixel(g, x, y, body);
// Claws (lighter red)
var clawPixels = new[] {
(1,6), (2,5), (2,6), (2,7),
(13,5), (13,6), (13,7), (14,6)
};
foreach (var (x, y) in clawPixels)
SetPixel(g, x, y, claw);
// Eyes
SetPixel(g, 6, 4, eyeLight);
SetPixel(g, 9, 4, eyeLight);
SetPixel(g, 6, 5, eyeDark);
SetPixel(g, 9, 5, eyeDark);
}
private static void SetPixel(Graphics g, int x, int y, Color c)
{
using var brush = new SolidBrush(c);
g.FillRectangle(brush, x, y, 1, 1);
}
}

21
src/Moltbot.Tray/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Scott Hanselman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

106
src/Moltbot.Tray/Logger.cs Normal file
View File

@ -0,0 +1,106 @@
using System;
using System.Diagnostics;
using System.IO;
using Moltbot.Shared;
namespace MoltbotTray;
/// <summary>
/// Simple file + debug logger for troubleshooting.
/// Writes to %LOCALAPPDATA%\MoltbotTray\clawdbot-tray.log
/// </summary>
public static class Logger
{
private static readonly string LogDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MoltbotTray");
private static readonly string LogPath = Path.Combine(LogDir, "clawdbot-tray.log");
private static readonly object Lock = new();
private static bool _initialized;
private static StreamWriter? _writer;
/// <summary>Get a logger instance that implements IMoltbotLogger for the shared library.</summary>
public static IMoltbotLogger Instance { get; } = new LoggerAdapter();
public static void Info(string message) => Write("INFO", message);
public static void Warn(string message) => Write("WARN", message);
public static void Error(string message) => Write("ERROR", message);
public static void Error(string message, Exception ex) => Write("ERROR", $"{message}: {ex.Message}\n Stack: {ex.StackTrace}");
/// <summary>Flush and close the log file (call on app exit).</summary>
public static void Shutdown()
{
lock (Lock)
{
_writer?.Flush();
_writer?.Dispose();
_writer = null;
_initialized = false;
}
}
private static void EnsureInitialized()
{
if (_initialized) return;
try
{
Directory.CreateDirectory(LogDir);
RotateIfNeeded();
_writer = new StreamWriter(LogPath, append: true) { AutoFlush = true };
_initialized = true;
}
catch
{
// Can't init — fall back to Debug.WriteLine only
}
}
private static void RotateIfNeeded()
{
try
{
var info = new FileInfo(LogPath);
if (info.Exists && info.Length > 1_048_576)
{
var backup = LogPath + ".1";
if (File.Exists(backup)) File.Delete(backup);
File.Move(LogPath, backup);
}
}
catch { }
}
private static void Write(string level, string message)
{
var line = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] [{level}] {message}";
Debug.WriteLine(line);
try
{
lock (Lock)
{
EnsureInitialized();
_writer?.WriteLine(line);
}
}
catch
{
// Don't crash if we can't write logs
}
}
/// <summary>Adapter to make the static Logger work with IMoltbotLogger interface.</summary>
private class LoggerAdapter : IMoltbotLogger
{
public void Info(string message) => Logger.Info(message);
public void Warn(string message) => Logger.Warn(message);
public void Error(string message, Exception? ex = null)
{
if (ex != null)
Logger.Error(message, ex);
else
Logger.Error(message);
}
}
}

View File

@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<NoWarn>MSB3277</NoWarn>
<ApplicationIcon>moltbot.ico</ApplicationIcon>
<AssemblyTitle>Moltbot Windows Tray</AssemblyTitle>
<AssemblyDescription>Windows system tray companion app for Moltbot</AssemblyDescription>
<AssemblyCompany>Scott Hanselman</AssemblyCompany>
<AssemblyProduct>Moltbot Tray</AssemblyProduct>
<Copyright>Copyright © 2026 Scott Hanselman</Copyright>
<Version>1.0.0</Version>
<FileVersion>1.0.0</FileVersion>
<AssemblyVersion>1.0.0</AssemblyVersion>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<!-- RuntimeIdentifier set at publish time: win-x64 or win-arm64 -->
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Moltbot.Shared\Moltbot.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3124.44" />
<PackageReference Include="System.Text.Json" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Icons\*.ico" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,161 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;
namespace MoltbotTray;
/// <summary>
/// Shows recent notification history in a simple list view.
/// </summary>
public class NotificationHistoryForm : Form
{
private ListView? _listView;
private Button _clearButton = null!;
private Button _closeButton = null!;
private static NotificationHistoryForm? _instance;
private static readonly List<NotificationEntry> _history = new();
private const int MaxHistory = 200;
public static void AddEntry(string title, string message, string type)
{
lock (_history)
{
_history.Add(new NotificationEntry
{
Timestamp = DateTime.Now,
Title = title,
Message = message,
Type = type
});
// Trim old entries
while (_history.Count > MaxHistory)
_history.RemoveAt(0);
}
// If window is open, refresh it
_instance?.RefreshList();
}
public static void ShowOrFocus()
{
if (_instance != null && !_instance.IsDisposed)
{
_instance.BringToFront();
_instance.Focus();
return;
}
_instance = new NotificationHistoryForm();
_instance.Show();
}
private NotificationHistoryForm()
{
InitializeComponent();
RefreshList();
}
private void InitializeComponent()
{
Text = "Notification History — Moltbot Tray";
Size = new Size(600, 450);
MinimumSize = new Size(400, 300);
StartPosition = FormStartPosition.CenterScreen;
Icon = IconHelper.GetLobsterIcon();
_listView = new ListView
{
Dock = DockStyle.Fill,
View = View.Details,
FullRowSelect = true,
GridLines = true,
Font = new Font("Segoe UI", 9F)
};
_listView.Columns.Add("Time", 130);
_listView.Columns.Add("Type", 80);
_listView.Columns.Add("Title", 150);
_listView.Columns.Add("Message", 300);
var buttonPanel = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
Height = 40,
FlowDirection = FlowDirection.RightToLeft,
Padding = new Padding(5)
};
_closeButton = new Button
{
Text = "&Close",
Size = new Size(75, 26),
Font = new Font("Segoe UI", 9F)
};
_closeButton.Click += (_, _) => Close();
_clearButton = new Button
{
Text = "C&lear All",
Size = new Size(85, 26),
Font = new Font("Segoe UI", 9F)
};
_clearButton.Click += (_, _) =>
{
lock (_history) _history.Clear();
RefreshList();
};
buttonPanel.Controls.Add(_closeButton);
buttonPanel.Controls.Add(_clearButton);
Controls.Add(_listView);
Controls.Add(buttonPanel);
}
private void RefreshList()
{
if (_listView == null || _listView.IsDisposed) return;
if (InvokeRequired)
{
Invoke(new Action(RefreshList));
return;
}
_listView.BeginUpdate();
_listView.Items.Clear();
lock (_history)
{
// Show newest first
for (int i = _history.Count - 1; i >= 0; i--)
{
var entry = _history[i];
var item = new ListViewItem(entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"));
item.SubItems.Add(entry.Type);
item.SubItems.Add(entry.Title);
item.SubItems.Add(entry.Message.Replace('\n', ' '));
_listView.Items.Add(item);
}
}
_listView.EndUpdate();
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
_instance = null;
base.OnFormClosed(e);
}
private class NotificationEntry
{
public DateTime Timestamp { get; set; }
public string Title { get; set; } = "";
public string Message { get; set; } = "";
public string Type { get; set; } = "";
}
}

View File

@ -0,0 +1,34 @@
using MoltbotTray;
using System;
using System.Threading;
using System.Windows.Forms;
namespace MoltbotTray;
internal static class Program
{
[STAThread]
static void Main(string[] args)
{
// Single instance check
using var mutex = new Mutex(true, "MoltbotTray", out bool createdNew);
if (!createdNew)
{
// TODO: Forward deep link args to running instance via named pipe
MessageBox.Show("Moltbot Tray is already running.", "Moltbot Tray",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
// Register URI scheme on first run
DeepLinkHandler.RegisterUriScheme();
Application.SetHighDpiMode(HighDpiMode.SystemAware);
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
var trayApp = new TrayApplication(args);
Application.Run(trayApp);
}
}

View File

@ -0,0 +1,135 @@
using System;
using System.Drawing;
using System.Windows.Forms;
namespace MoltbotTray;
public partial class QuickSendDialog : Form
{
private TextBox _messageTextBox = null!;
private Button _sendButton = null!;
private Button _cancelButton = null!;
private Label _hintLabel = null!;
public string Message => _messageTextBox.Text;
public QuickSendDialog()
{
InitializeComponent();
}
private void InitializeComponent()
{
// Form properties
Text = "Quick Send — Clawdbot";
Size = new Size(500, 220);
StartPosition = FormStartPosition.CenterScreen;
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
ShowInTaskbar = true;
TopMost = true; // Always on top when opened via hotkey
Icon = IconHelper.GetLobsterIcon();
// Label
var label = new Label
{
Text = "Send a message to Clawdbot:",
Location = new Point(12, 12),
Size = new Size(460, 20),
Font = new Font("Segoe UI", 9.5F, FontStyle.Regular)
};
// Message text box
_messageTextBox = new TextBox
{
Location = new Point(12, 36),
Size = new Size(460, 90),
Multiline = true,
ScrollBars = ScrollBars.Vertical,
Font = new Font("Segoe UI", 10F, FontStyle.Regular),
AcceptsReturn = false // Enter sends, Shift+Enter for newline
};
// Hint label
_hintLabel = new Label
{
Text = "Enter to send · Esc to cancel · Shift+Enter for new line",
Location = new Point(12, 132),
Size = new Size(300, 18),
Font = new Font("Segoe UI", 8F, FontStyle.Regular),
ForeColor = Color.Gray
};
// Send button
_sendButton = new Button
{
Text = "&Send",
Location = new Point(316, 148),
Size = new Size(75, 28),
UseVisualStyleBackColor = true,
Font = new Font("Segoe UI", 9F, FontStyle.Regular)
};
_sendButton.Click += OnSendClick;
// Cancel button
_cancelButton = new Button
{
Text = "&Cancel",
Location = new Point(397, 148),
Size = new Size(75, 28),
UseVisualStyleBackColor = true,
Font = new Font("Segoe UI", 9F, FontStyle.Regular)
};
_cancelButton.Click += OnCancelClick;
// Set dialog buttons
AcceptButton = _sendButton;
CancelButton = _cancelButton;
// Add controls
Controls.Add(label);
Controls.Add(_messageTextBox);
Controls.Add(_hintLabel);
Controls.Add(_sendButton);
Controls.Add(_cancelButton);
// Focus the text box on show
Shown += (_, _) =>
{
_messageTextBox.Focus();
Activate(); // Ensure window is focused when opened via hotkey
};
}
private void OnSendClick(object? sender, EventArgs e)
{
if (string.IsNullOrWhiteSpace(_messageTextBox.Text))
{
_messageTextBox.Focus();
return;
}
DialogResult = DialogResult.OK;
Close();
}
private void OnCancelClick(object? sender, EventArgs e)
{
DialogResult = DialogResult.Cancel;
Close();
}
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
// Ctrl+Enter or Enter (without Shift) as send
if (keyData == (Keys.Control | Keys.Enter) || keyData == Keys.Enter)
{
OnSendClick(null, EventArgs.Empty);
return true;
}
return base.ProcessCmdKey(ref msg, keyData);
}
}

225
src/Moltbot.Tray/README.md Normal file
View File

@ -0,0 +1,225 @@
# Clawdbot Windows Tray
A Windows system tray companion for [Clawdbot](https://github.com/clawdbot/clawdbot) — the Windows equivalent of the macOS menu bar app. Provides desktop notifications, embedded chat, live agent activity monitoring, and gateway status tracking.
## Features
### System Tray
- **Lobster icon** 🦞 when connected (pixel art), color-coded circles for other states
- **Activity badge** showing what the agent is doing (exec, read, write, edit, search, browser, message, tool)
- **Context menu** with status, sessions, channels, usage, quick send, settings, and auto-start
- **Text status labels** `[ON]`/`[OFF]`/`[READY]`/`[LINKED]` for clarity
- **Clickable status** opens detailed status view
- **Double-click** opens embedded web chat
- **Open Dashboard** opens browser with authenticated session
### Session Awareness
- **Live session tracking** — see main and sub-sessions in real-time
- **Session detail** — model, channel, current activity per session
- **Activity display** — "Main · 💻 pnpm test" or "Sub · 📄 reading file"
### Usage & Context
- **Token usage** display (input/output/total with human-readable formatting)
- **Cost tracking** when available from gateway
- **Request count** and active model
### WebChat Panel
- **Embedded chat** via WebView2 — no browser needed
- **Dark mode** background
- Toolbar with home, refresh, pop-out to browser, and DevTools
- Singleton window (double-click tray icon or "Open Web Chat" menu)
### Notifications
- **Windows toast notifications** with per-type filtering:
- 🩸 Health / CGM alerts
- 🚨 Urgent / error alerts
- ⏰ Reminders
- 📧 Email notifications
- 📅 Calendar events
- 🔨 Build / CI
- 📦 Stock availability
- 🤖 General info
- **Clickable toasts** — Quick Send toasts open dashboard when clicked
- **Notification history** — scrollable list with timestamps, even for filtered-out notifications
- Fallback to balloon tips if toast fails
### Channel Health
- Live WhatsApp, Telegram, and other channel status
- Smart status detection: `[READY]` (probe OK), `[LINKED]` (authenticated), `[ON]`/`[OFF]`
- Shows linked state, auth age, errors, and stale warnings
- On-demand health check button
### Keyboard Shortcuts
- **Ctrl+Shift+Space** — Global hotkey to open Quick Send from anywhere
- **Enter** — Send message in Quick Send dialog
- **Shift+Enter** — New line in Quick Send
- **Esc** — Cancel Quick Send
### Deep Links
- Registers `clawdbot://` URI scheme
- `clawdbot://agent?message=Hello` sends a message to the agent
- Confirmation prompt for safety (bypass with `key` parameter)
### Quality of Life
- **ARM64 support** — native builds for Windows on ARM
- **Auto-start** via Windows Registry
- **Exponential backoff** on reconnect (1s → 60s)
- **File logging** to `%LOCALAPPDATA%\ClawdbotTray\clawdbot-tray.log` (with rotation at 1MB)
- **Open Log File** menu item for quick debugging
- **Single instance** enforcement (mutex)
- **Proper GDI handle cleanup** (no icon leaks)
- **Status detail view** — rich dark-themed status panel
## Requirements
- Windows 10 version 1903+ (for toast notifications)
- .NET 9 Runtime (included in self-contained builds)
- [WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) (for chat panel)
- Clawdbot gateway running (typically in WSL2)
## Quick Start
1. Download the latest release from [Releases](https://github.com/shanselman/clawdbot-windows-tray/releases)
- **x64**: For Intel/AMD processors
- **arm64**: For Windows on ARM (e.g., Surface Pro X, Snapdragon laptops)
2. Run `ClawdbotTray.exe`
3. Right-click tray icon → Settings
4. Enter gateway URL (`ws://localhost:18789`) and your token
5. Done — you'll see the icon turn green when connected
### Finding Your Gateway Token
```bash
# In WSL2:
cat ~/.clawdbot/clawdbot.json | grep token
# Or:
clawdbot config get gateway.auth.token
```
## Build from Source
```bash
git clone https://github.com/shanselman/clawdbot-windows-tray.git
cd clawdbot-windows-tray
# Windows — auto-detects architecture
build.bat
# Manual build
dotnet build -c Release -r win-x64
dotnet build -c Release -r win-arm64
# Self-contained single-file executable
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -o publish
dotnet publish -c Release -r win-arm64 --self-contained -p:PublishSingleFile=true -o publish-arm64
# Cross-compile from Linux (for CI)
dotnet build -p:EnableWindowsTargeting=true -r win-x64
```
## Project Structure
```
├── Program.cs # Entry point, single instance, deep link registration
├── TrayApplication.cs # Tray icon, menu, event wiring, UI updates
├── ClawdbotGatewayClient.cs # WebSocket client, protocol v3, event parsing, state tracking
├── WebChatForm.cs # WebView2 chat panel (singleton, dark mode)
├── QuickSendDialog.cs # Quick message input (Enter to send, TopMost)
├── StatusDetailForm.cs # Rich status detail view (dark theme)
├── NotificationHistoryForm.cs # Scrollable notification history
├── GlobalHotkey.cs # Ctrl+Shift+Space system-wide hotkey
├── DeepLinkHandler.cs # clawdbot:// URI scheme handler
├── SettingsManager.cs # JSON config with notification filters
├── SettingsDialog.cs # Settings UI (connection, startup, notification filters)
├── AutoStartManager.cs # Windows Registry auto-start
├── Logger.cs # File + debug logger with rotation
└── ClawdbotTray.csproj # .NET 9, Windows Forms, WebView2
```
## macOS Parity Status
This Windows tray app aims for feature parity with the [Clawdbot macOS menu bar app](https://github.com/clawdbot/clawdbot-macos).
| Feature | macOS | Windows | Notes |
|---------|:-----:|:-------:|-------|
| System tray/menu bar icon | ✅ | ✅ | Lobster 🦞 when connected |
| Status colors/indicators | ✅ | ✅ | Text labels `[ON]/[OFF]` for clarity |
| Activity badges | ✅ | ✅ | exec/read/write/search/browser |
| Toast/native notifications | ✅ | ✅ | Windows toast + fallback |
| Per-type notification filters | ✅ | ✅ | Health, urgent, email, etc. |
| Clickable notifications | ✅ | ✅ | Opens dashboard with auth |
| Notification history | — | ✅ | Windows-only feature |
| Embedded chat (WebView) | ✅ | ✅ | WebView2 |
| Open Dashboard in browser | ✅ | ✅ | Token auto-included |
| Channel health display | ✅ | ✅ | Telegram, WhatsApp status |
| Session awareness (main/sub) | ✅ | ✅ | Live session tracking |
| Usage/token display | ✅ | ✅ | Input/output/total |
| Deep link URI scheme | ✅ | ✅ | `clawdbot://` |
| Global hotkey | — | ✅ | Ctrl+Shift+Space |
| Auto-start | ✅ | ✅ | Registry-based |
| Quick send | ✅ | ✅ | Fire-and-forget to main session |
| Health check (on-demand) | ✅ | ✅ | |
| Status detail view | — | ✅ | Windows-only feature |
| File logging | ✅ | ✅ | With rotation |
| ARM64 support | ✅ | ✅ | Apple Silicon / Windows ARM |
| Canvas panel | ✅ | 🔜 | Planned |
| Voice wake / push-to-talk | ✅ | 🔜 | Planned |
| Skills settings UI | ✅ | 🔜 | Planned |
| TCC permissions management | ✅ | N/A | macOS-specific |
| PeekabooBridge (UI automation) | ✅ | N/A | macOS-specific |
| XPC / node host service | ✅ | N/A | macOS-specific |
## Settings
Settings are stored in `%APPDATA%\ClawdbotTray\settings.json`:
```json
{
"GatewayUrl": "ws://localhost:18789",
"Token": "your-token",
"AutoStart": false,
"ShowNotifications": true,
"NotificationSound": "Default",
"NotifyHealth": true,
"NotifyUrgent": true,
"NotifyReminder": true,
"NotifyEmail": true,
"NotifyCalendar": true,
"NotifyBuild": true,
"NotifyStock": true,
"NotifyInfo": true,
"ShowGlobalHotkey": true
}
```
## Troubleshooting
**Can't connect?**
- Check gateway: `clawdbot gateway status` in WSL2
- Verify token matches `~/.clawdbot/clawdbot.json`
- Try WSL2 IP directly: `ws://<wsl-ip>:18789` (`wsl hostname -I`)
**No notifications?**
- Check Windows Settings → Notifications
- Check Focus Assist / Do Not Disturb
- Check notification filter settings in the app
**WebChat blank?**
- Install [WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)
- Check logs: `%LOCALAPPDATA%\ClawdbotTray\clawdbot-tray.log`
- Right-click tray → Open Log File
**Global hotkey not working?**
- Another app may have registered Ctrl+Shift+Space
- Check Settings → Global hotkey is enabled
- Check the log file for "Failed to register global hotkey"
## License
MIT
## Credits
- Built with .NET 9, Windows Forms, and [WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)
- Toast notifications via [Microsoft.Toolkit.Uwp.Notifications](https://github.com/CommunityToolkit/WindowsCommunityToolkit)
- Part of the [Clawdbot](https://github.com/clawdbot/clawdbot) ecosystem

View File

@ -0,0 +1,377 @@
using Moltbot.Shared;
using System;
using System.Drawing;
using System.Windows.Forms;
namespace MoltbotTray;
public partial class SettingsDialog : Form
{
private readonly SettingsManager _settings;
private TextBox _gatewayUrlTextBox = null!;
private TextBox _tokenTextBox = null!;
private CheckBox _autoStartCheckBox = null!;
private CheckBox _showNotificationsCheckBox = null!;
private CheckBox _globalHotkeyCheckBox = null!;
private ComboBox _notificationSoundComboBox = null!;
private Button _testConnectionButton = null!;
private Button _okButton = null!;
private Button _cancelButton = null!;
private Label _statusLabel = null!;
// Notification filter checkboxes
private CheckBox _notifyHealthCb = null!;
private CheckBox _notifyUrgentCb = null!;
private CheckBox _notifyReminderCb = null!;
private CheckBox _notifyEmailCb = null!;
private CheckBox _notifyCalendarCb = null!;
private CheckBox _notifyBuildCb = null!;
private CheckBox _notifyStockCb = null!;
private CheckBox _notifyInfoCb = null!;
private Panel _notifyFilterPanel = null!;
public SettingsDialog(SettingsManager settings)
{
_settings = settings;
InitializeComponent();
LoadSettings();
}
private void InitializeComponent()
{
Text = "Settings — Moltbot Tray";
Size = new Size(480, 560);
StartPosition = FormStartPosition.CenterScreen;
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
ShowInTaskbar = false;
AutoScroll = true;
Icon = IconHelper.GetLobsterIcon();
var y = 12;
var labelFont = new Font("Segoe UI", 9F);
var headerFont = new Font("Segoe UI", 9F, FontStyle.Bold);
// --- Connection Section ---
var connHeader = new Label
{
Text = "CONNECTION",
Location = new Point(12, y),
Size = new Size(200, 20),
Font = headerFont,
ForeColor = Color.FromArgb(0, 120, 215)
};
y += 22;
var gatewayUrlLabel = new Label
{
Text = "Gateway URL:",
Location = new Point(12, y),
Size = new Size(100, 20),
Font = labelFont
};
y += 22;
_gatewayUrlTextBox = new TextBox
{
Location = new Point(12, y),
Size = new Size(310, 23),
Font = labelFont
};
_testConnectionButton = new Button
{
Text = "Test",
Location = new Point(330, y - 1),
Size = new Size(65, 25),
Font = labelFont
};
_testConnectionButton.Click += OnTestConnection;
y += 30;
var tokenLabel = new Label
{
Text = "Token:",
Location = new Point(12, y),
Size = new Size(100, 20),
Font = labelFont
};
y += 22;
_tokenTextBox = new TextBox
{
Location = new Point(12, y),
Size = new Size(310, 23),
Font = labelFont,
UseSystemPasswordChar = true
};
_statusLabel = new Label
{
Text = "",
Location = new Point(330, y + 2),
Size = new Size(130, 20),
Font = new Font("Segoe UI", 8F),
ForeColor = Color.DarkGreen
};
y += 35;
// --- Startup Section ---
var startupHeader = new Label
{
Text = "STARTUP",
Location = new Point(12, y),
Size = new Size(200, 20),
Font = headerFont,
ForeColor = Color.FromArgb(0, 120, 215)
};
y += 22;
_autoStartCheckBox = new CheckBox
{
Text = "Start automatically with Windows",
Location = new Point(12, y),
Size = new Size(280, 22),
Font = labelFont
};
y += 26;
_globalHotkeyCheckBox = new CheckBox
{
Text = "Global hotkey (Ctrl+Shift+Space → Quick Send)",
Location = new Point(12, y),
Size = new Size(340, 22),
Font = labelFont
};
y += 35;
// --- Notifications Section ---
var notifyHeader = new Label
{
Text = "NOTIFICATIONS",
Location = new Point(12, y),
Size = new Size(200, 20),
Font = headerFont,
ForeColor = Color.FromArgb(0, 120, 215)
};
y += 22;
_showNotificationsCheckBox = new CheckBox
{
Text = "Show desktop notifications",
Location = new Point(12, y),
Size = new Size(250, 22),
Font = labelFont
};
_showNotificationsCheckBox.CheckedChanged += (_, _) =>
{
_notifyFilterPanel.Enabled = _showNotificationsCheckBox.Checked;
};
y += 26;
var soundLabel = new Label
{
Text = "Sound:",
Location = new Point(12, y),
Size = new Size(50, 20),
Font = labelFont
};
_notificationSoundComboBox = new ComboBox
{
Location = new Point(65, y - 2),
Size = new Size(140, 23),
DropDownStyle = ComboBoxStyle.DropDownList,
Font = labelFont
};
_notificationSoundComboBox.Items.AddRange(new[] { "Default", "None", "Critical", "Information" });
y += 30;
// Filter panel
var filterLabel = new Label
{
Text = "Show toasts for:",
Location = new Point(12, y),
Size = new Size(120, 20),
Font = labelFont,
ForeColor = Color.Gray
};
y += 22;
_notifyFilterPanel = new Panel
{
Location = new Point(12, y),
Size = new Size(440, 72),
BorderStyle = BorderStyle.None
};
// Two columns of filter checkboxes
var cbFont = new Font("Segoe UI", 8.5F);
_notifyHealthCb = MakeFilterCb("🩸 Health", 0, 0, cbFont);
_notifyUrgentCb = MakeFilterCb("🚨 Urgent", 0, 24, cbFont);
_notifyReminderCb = MakeFilterCb("⏰ Reminders", 0, 48, cbFont);
_notifyEmailCb = MakeFilterCb("📧 Email", 150, 0, cbFont);
_notifyCalendarCb = MakeFilterCb("📅 Calendar", 150, 24, cbFont);
_notifyBuildCb = MakeFilterCb("🔨 Build/CI", 150, 48, cbFont);
_notifyStockCb = MakeFilterCb("📦 Stock", 300, 0, cbFont);
_notifyInfoCb = MakeFilterCb("🤖 General", 300, 24, cbFont);
_notifyFilterPanel.Controls.AddRange(new Control[]
{
_notifyHealthCb, _notifyUrgentCb, _notifyReminderCb,
_notifyEmailCb, _notifyCalendarCb, _notifyBuildCb,
_notifyStockCb, _notifyInfoCb
});
y += 80;
// --- Buttons ---
y += 10;
_okButton = new Button
{
Text = "&OK",
Location = new Point(300, y),
Size = new Size(75, 28),
Font = labelFont
};
_okButton.Click += OnOkClick;
_cancelButton = new Button
{
Text = "&Cancel",
Location = new Point(382, y),
Size = new Size(75, 28),
Font = labelFont
};
_cancelButton.Click += OnCancelClick;
AcceptButton = _okButton;
CancelButton = _cancelButton;
// Add all controls
Controls.AddRange(new Control[]
{
connHeader, gatewayUrlLabel, _gatewayUrlTextBox, _testConnectionButton,
tokenLabel, _tokenTextBox, _statusLabel,
startupHeader, _autoStartCheckBox, _globalHotkeyCheckBox,
notifyHeader, _showNotificationsCheckBox, soundLabel, _notificationSoundComboBox,
filterLabel, _notifyFilterPanel,
_okButton, _cancelButton
});
}
private static CheckBox MakeFilterCb(string text, int x, int y, Font font)
{
return new CheckBox
{
Text = text,
Location = new Point(x, y),
Size = new Size(140, 22),
Font = font,
Checked = true
};
}
private void LoadSettings()
{
_gatewayUrlTextBox.Text = _settings.GatewayUrl;
_tokenTextBox.Text = _settings.Token;
_autoStartCheckBox.Checked = _settings.AutoStart;
_globalHotkeyCheckBox.Checked = _settings.ShowGlobalHotkey;
_showNotificationsCheckBox.Checked = _settings.ShowNotifications;
_notifyFilterPanel.Enabled = _settings.ShowNotifications;
var soundIndex = _notificationSoundComboBox.Items.IndexOf(_settings.NotificationSound);
_notificationSoundComboBox.SelectedIndex = soundIndex >= 0 ? soundIndex : 0;
_notifyHealthCb.Checked = _settings.NotifyHealth;
_notifyUrgentCb.Checked = _settings.NotifyUrgent;
_notifyReminderCb.Checked = _settings.NotifyReminder;
_notifyEmailCb.Checked = _settings.NotifyEmail;
_notifyCalendarCb.Checked = _settings.NotifyCalendar;
_notifyBuildCb.Checked = _settings.NotifyBuild;
_notifyStockCb.Checked = _settings.NotifyStock;
_notifyInfoCb.Checked = _settings.NotifyInfo;
}
private void SaveSettings()
{
_settings.GatewayUrl = _gatewayUrlTextBox.Text.Trim();
_settings.Token = _tokenTextBox.Text.Trim();
_settings.AutoStart = _autoStartCheckBox.Checked;
_settings.ShowGlobalHotkey = _globalHotkeyCheckBox.Checked;
_settings.ShowNotifications = _showNotificationsCheckBox.Checked;
_settings.NotificationSound = _notificationSoundComboBox.SelectedItem?.ToString() ?? "Default";
_settings.NotifyHealth = _notifyHealthCb.Checked;
_settings.NotifyUrgent = _notifyUrgentCb.Checked;
_settings.NotifyReminder = _notifyReminderCb.Checked;
_settings.NotifyEmail = _notifyEmailCb.Checked;
_settings.NotifyCalendar = _notifyCalendarCb.Checked;
_settings.NotifyBuild = _notifyBuildCb.Checked;
_settings.NotifyStock = _notifyStockCb.Checked;
_settings.NotifyInfo = _notifyInfoCb.Checked;
}
private async void OnTestConnection(object? sender, EventArgs e)
{
_testConnectionButton.Enabled = false;
_statusLabel.Text = "Testing...";
_statusLabel.ForeColor = Color.Blue;
try
{
var testClient = new MoltbotGatewayClient(
_gatewayUrlTextBox.Text.Trim(),
_tokenTextBox.Text.Trim());
await testClient.ConnectAsync();
await testClient.DisconnectAsync();
testClient.Dispose();
_statusLabel.Text = "✅ Connected";
_statusLabel.ForeColor = Color.DarkGreen;
}
catch (Exception ex)
{
_statusLabel.Text = $"❌ {ex.Message}";
_statusLabel.ForeColor = Color.Red;
}
finally
{
_testConnectionButton.Enabled = true;
}
}
private void OnOkClick(object? sender, EventArgs e)
{
if (string.IsNullOrWhiteSpace(_gatewayUrlTextBox.Text))
{
MessageBox.Show("Gateway URL is required.", "Settings",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
_gatewayUrlTextBox.Focus();
return;
}
if (!Uri.TryCreate(_gatewayUrlTextBox.Text.Trim(), UriKind.Absolute, out var uri) ||
(uri.Scheme != "ws" && uri.Scheme != "wss"))
{
MessageBox.Show("Gateway URL must be a valid WebSocket URL (ws:// or wss://).", "Settings",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
_gatewayUrlTextBox.Focus();
return;
}
SaveSettings();
DialogResult = DialogResult.OK;
Close();
}
private void OnCancelClick(object? sender, EventArgs e)
{
DialogResult = DialogResult.Cancel;
Close();
}
}

View File

@ -0,0 +1,153 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
namespace MoltbotTray;
public class SettingsManager
{
private static readonly string SettingsDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"MoltbotTray");
private static readonly string SettingsFile = Path.Combine(SettingsDirectory, "settings.json");
public string GatewayUrl { get; set; } = "ws://localhost:18789";
public string Token { get; set; } = "";
public bool AutoStart { get; set; } = false;
public bool ShowNotifications { get; set; } = true;
public string NotificationSound { get; set; } = "Default";
// Notification filters — which types to show toasts for
public bool NotifyHealth { get; set; } = true;
public bool NotifyUrgent { get; set; } = true;
public bool NotifyReminder { get; set; } = true;
public bool NotifyEmail { get; set; } = true;
public bool NotifyCalendar { get; set; } = true;
public bool NotifyBuild { get; set; } = true;
public bool NotifyStock { get; set; } = true;
public bool NotifyInfo { get; set; } = true;
// UI preferences
public bool ShowGlobalHotkey { get; set; } = true;
public bool MinimizeToTray { get; set; } = true;
public SettingsManager()
{
Load();
}
/// <summary>Check if a notification type should produce a toast.</summary>
public bool ShouldNotify(string type)
{
if (!ShowNotifications) return false;
return type switch
{
"health" => NotifyHealth,
"urgent" => NotifyUrgent,
"reminder" => NotifyReminder,
"email" => NotifyEmail,
"calendar" => NotifyCalendar,
"build" => NotifyBuild,
"stock" => NotifyStock,
"error" => NotifyUrgent, // Errors use urgent setting
"info" => NotifyInfo,
_ => NotifyInfo
};
}
public void Load()
{
try
{
if (File.Exists(SettingsFile))
{
var json = File.ReadAllText(SettingsFile);
var settings = JsonSerializer.Deserialize<SettingsData>(json);
if (settings != null)
{
GatewayUrl = settings.GatewayUrl ?? "ws://localhost:18789";
Token = settings.Token ?? "";
AutoStart = settings.AutoStart;
ShowNotifications = settings.ShowNotifications;
NotificationSound = settings.NotificationSound ?? "Default";
NotifyHealth = settings.NotifyHealth ?? true;
NotifyUrgent = settings.NotifyUrgent ?? true;
NotifyReminder = settings.NotifyReminder ?? true;
NotifyEmail = settings.NotifyEmail ?? true;
NotifyCalendar = settings.NotifyCalendar ?? true;
NotifyBuild = settings.NotifyBuild ?? true;
NotifyStock = settings.NotifyStock ?? true;
NotifyInfo = settings.NotifyInfo ?? true;
ShowGlobalHotkey = settings.ShowGlobalHotkey ?? true;
MinimizeToTray = settings.MinimizeToTray ?? true;
}
}
}
catch (Exception)
{
// Use defaults if loading fails
}
}
public void Save()
{
try
{
Directory.CreateDirectory(SettingsDirectory);
var settings = new SettingsData
{
GatewayUrl = GatewayUrl,
Token = Token,
AutoStart = AutoStart,
ShowNotifications = ShowNotifications,
NotificationSound = NotificationSound,
NotifyHealth = NotifyHealth,
NotifyUrgent = NotifyUrgent,
NotifyReminder = NotifyReminder,
NotifyEmail = NotifyEmail,
NotifyCalendar = NotifyCalendar,
NotifyBuild = NotifyBuild,
NotifyStock = NotifyStock,
NotifyInfo = NotifyInfo,
ShowGlobalHotkey = ShowGlobalHotkey,
MinimizeToTray = MinimizeToTray
};
var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(SettingsFile, json);
Logger.Info("Settings saved");
}
catch (Exception ex)
{
Logger.Error("Failed to save settings", ex);
throw new Exception($"Failed to save settings: {ex.Message}", ex);
}
}
public static string GetSettingsDirectory() => SettingsDirectory;
public static string GetSettingsFile() => SettingsFile;
private class SettingsData
{
public string? GatewayUrl { get; set; }
public string? Token { get; set; }
public bool AutoStart { get; set; }
public bool ShowNotifications { get; set; }
public string? NotificationSound { get; set; }
public bool? NotifyHealth { get; set; }
public bool? NotifyUrgent { get; set; }
public bool? NotifyReminder { get; set; }
public bool? NotifyEmail { get; set; }
public bool? NotifyCalendar { get; set; }
public bool? NotifyBuild { get; set; }
public bool? NotifyStock { get; set; }
public bool? NotifyInfo { get; set; }
public bool? ShowGlobalHotkey { get; set; }
public bool? MinimizeToTray { get; set; }
}
}

View File

@ -0,0 +1,183 @@
using Moltbot.Shared;
using System;
using System.Drawing;
using System.Text;
using System.Windows.Forms;
namespace MoltbotTray;
/// <summary>
/// Shows detailed gateway status, sessions, channels, and usage in a rich view.
/// </summary>
public class StatusDetailForm : Form
{
private RichTextBox _textBox = null!;
private Button _refreshButton = null!;
private Button _closeButton = null!;
private readonly MoltbotGatewayClient? _client;
private readonly SettingsManager? _settings;
private readonly ConnectionStatus _status;
private static StatusDetailForm? _instance;
public static void ShowOrFocus(MoltbotGatewayClient? client, SettingsManager? settings, ConnectionStatus status)
{
if (_instance != null && !_instance.IsDisposed)
{
_instance.BringToFront();
_instance.Focus();
return;
}
_instance = new StatusDetailForm(client, settings, status);
_instance.Show();
}
private StatusDetailForm(MoltbotGatewayClient? client, SettingsManager? settings, ConnectionStatus status)
{
_client = client;
_settings = settings;
_status = status;
InitializeComponent();
RefreshStatus();
}
private void InitializeComponent()
{
Text = "Clawdbot Status";
Size = new Size(520, 500);
MinimumSize = new Size(400, 350);
StartPosition = FormStartPosition.CenterScreen;
Icon = IconHelper.GetLobsterIcon();
_textBox = new RichTextBox
{
Dock = DockStyle.Fill,
ReadOnly = true,
Font = new Font("Cascadia Code", 10F, FontStyle.Regular, GraphicsUnit.Point),
BackColor = Color.FromArgb(30, 30, 30),
ForeColor = Color.FromArgb(220, 220, 220),
BorderStyle = BorderStyle.None,
WordWrap = true
};
var buttonPanel = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
Height = 40,
FlowDirection = FlowDirection.RightToLeft,
Padding = new Padding(5)
};
_closeButton = new Button
{
Text = "&Close",
Size = new Size(75, 26),
Font = new Font("Segoe UI", 9F)
};
_closeButton.Click += (_, _) => Close();
_refreshButton = new Button
{
Text = "&Refresh",
Size = new Size(75, 26),
Font = new Font("Segoe UI", 9F)
};
_refreshButton.Click += async (_, _) =>
{
if (_client != null)
{
await _client.CheckHealthAsync();
await _client.RequestSessionsAsync();
await _client.RequestUsageAsync();
}
RefreshStatus();
};
buttonPanel.Controls.Add(_closeButton);
buttonPanel.Controls.Add(_refreshButton);
Controls.Add(_textBox);
Controls.Add(buttonPanel);
}
private void RefreshStatus()
{
var sb = new StringBuilder();
// Header
sb.AppendLine("⚡ CLAWDBOT STATUS");
sb.AppendLine(new string('─', 40));
sb.AppendLine();
// Connection
var statusIcon = _status switch
{
ConnectionStatus.Connected => "🟢",
ConnectionStatus.Connecting => "🟡",
ConnectionStatus.Error => "🔴",
_ => "⚪"
};
sb.AppendLine($" Gateway: {statusIcon} {_status}");
sb.AppendLine($" URL: {_settings?.GatewayUrl ?? "not configured"}");
sb.AppendLine($" Token: {(_settings?.Token?.Length > 0 ? "" : "not set")}");
sb.AppendLine();
// Sessions
if (_client != null)
{
var sessions = _client.GetSessionList();
if (sessions.Length > 0)
{
sb.AppendLine("🧠 SESSIONS");
sb.AppendLine(new string('─', 40));
foreach (var s in sessions)
{
sb.AppendLine($" {s.DisplayText}");
if (s.Model != null)
sb.AppendLine($" Model: {s.Model}");
if (s.StartedAt != null)
sb.AppendLine($" Started: {s.StartedAt:HH:mm:ss}");
}
sb.AppendLine();
}
}
// App info
sb.AppendLine(" APP INFO");
sb.AppendLine(new string('─', 40));
sb.AppendLine($" Version: 1.0.0");
sb.AppendLine($" Runtime: {Environment.Version}");
sb.AppendLine($" OS: {Environment.OSVersion}");
sb.AppendLine($" Machine: {Environment.MachineName}");
sb.AppendLine($" PID: {Environment.ProcessId}");
sb.AppendLine($" Uptime: {GetUptime()}");
sb.AppendLine();
// Auto-start
sb.AppendLine("⚙️ SETTINGS");
sb.AppendLine(new string('─', 40));
sb.AppendLine($" Auto-start: {(_settings?.AutoStart == true ? "" : "")}");
sb.AppendLine($" Notifications: {(_settings?.ShowNotifications == true ? "" : "")}");
sb.AppendLine($" Sound: {_settings?.NotificationSound ?? "Default"}");
_textBox.Text = sb.ToString();
}
private static string GetUptime()
{
var elapsed = DateTime.Now - System.Diagnostics.Process.GetCurrentProcess().StartTime;
if (elapsed.TotalHours >= 1)
return $"{elapsed.Hours}h {elapsed.Minutes}m";
if (elapsed.TotalMinutes >= 1)
return $"{elapsed.Minutes}m {elapsed.Seconds}s";
return $"{elapsed.Seconds}s";
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
_instance = null;
base.OnFormClosed(e);
}
}

View File

@ -0,0 +1,805 @@
using Microsoft.Toolkit.Uwp.Notifications;
using Moltbot.Shared;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using ActivityKind = Moltbot.Shared.ActivityKind;
namespace MoltbotTray;
public class TrayApplication : ApplicationContext
{
private NotifyIcon? _notifyIcon;
private ContextMenuStrip? _contextMenu;
private MoltbotGatewayClient? _gatewayClient;
private SettingsManager? _settings;
private System.Windows.Forms.Timer? _healthCheckTimer;
private System.Windows.Forms.Timer? _sessionPollTimer;
private GlobalHotkey? _globalHotkey;
private ConnectionStatus _currentStatus = ConnectionStatus.Disconnected;
private AgentActivity? _currentActivity;
private readonly SynchronizationContext? _syncContext;
// Session-aware activity: track per-session state to avoid flip-flopping
private readonly Dictionary<string, AgentActivity> _sessionActivities = new();
private string? _displayedSessionKey;
private DateTime _lastSessionSwitch = DateTime.MinValue;
private static readonly TimeSpan SessionSwitchDebounce = TimeSpan.FromSeconds(3);
// Menu items for dynamic updates
private ToolStripMenuItem? _statusItem;
private ToolStripMenuItem? _activityItem;
private ToolStripMenuItem? _usageItem;
private ToolStripSeparator? _channelSeparator;
private ToolStripSeparator? _sessionSeparator;
private readonly List<ToolStripItem> _channelItems = new();
private readonly List<ToolStripItem> _sessionItems = new();
private readonly string[] _startupArgs;
// P/Invoke for proper icon cleanup
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern bool DestroyIcon(IntPtr handle);
public TrayApplication(string[]? args = null)
{
_startupArgs = args ?? Array.Empty<string>();
_syncContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext();
Logger.Info("Application starting");
InitializeComponent();
InitializeAsync();
}
private void InitializeComponent()
{
_settings = new SettingsManager();
// Register toast activation handler
ToastNotificationManagerCompat.OnActivated += OnToastActivated;
_contextMenu = new ContextMenuStrip();
// Title
var titleItem = new ToolStripMenuItem("⚡ Moltbot Tray") { Enabled = false };
_contextMenu.Items.Add(titleItem);
_contextMenu.Items.Add(new ToolStripSeparator());
// Status (clickable — opens detail view)
_statusItem = new ToolStripMenuItem("Status: Disconnected");
_statusItem.Click += OnShowStatusDetail;
_contextMenu.Items.Add(_statusItem);
// Activity (hidden when idle)
_activityItem = new ToolStripMenuItem("") { Enabled = false, Visible = false };
_contextMenu.Items.Add(_activityItem);
// Usage (hidden until data available)
_usageItem = new ToolStripMenuItem("") { Enabled = false, Visible = false };
_contextMenu.Items.Add(_usageItem);
// Session separator + placeholder
_sessionSeparator = new ToolStripSeparator { Visible = false };
_contextMenu.Items.Add(_sessionSeparator);
// Channel health separator + placeholder
_channelSeparator = new ToolStripSeparator { Visible = false };
_contextMenu.Items.Add(_channelSeparator);
_contextMenu.Items.Add(new ToolStripSeparator());
// Actions
_contextMenu.Items.Add("Open Dashboard", null, OnOpenDashboard);
_contextMenu.Items.Add("Open Web Chat", null, OnOpenWebUI);
_contextMenu.Items.Add("Quick Send...", null, OnQuickSend);
_contextMenu.Items.Add("Notification History...", null, OnNotificationHistory);
_contextMenu.Items.Add("Run Health Check", null, OnManualHealthCheck);
_contextMenu.Items.Add(new ToolStripSeparator());
// Settings
_contextMenu.Items.Add("Settings...", null, OnSettings);
var autoStartMenuItem = new ToolStripMenuItem("Auto-start", null, OnToggleAutoStart)
{
Checked = _settings.AutoStart
};
_contextMenu.Items.Add(autoStartMenuItem);
_contextMenu.Items.Add(new ToolStripSeparator());
// Log file access
_contextMenu.Items.Add("Open Log File", null, OnOpenLogFile);
_contextMenu.Items.Add("Exit", null, OnExit);
// Tray icon
_notifyIcon = new NotifyIcon
{
Icon = CreateStatusIcon(ConnectionStatus.Disconnected),
ContextMenuStrip = _contextMenu,
Text = "Moltbot Tray — Disconnected",
Visible = true
};
_notifyIcon.DoubleClick += OnDoubleClick;
// Health check timer (30s)
_healthCheckTimer = new System.Windows.Forms.Timer { Interval = 30000, Enabled = true };
_healthCheckTimer.Tick += OnHealthCheck;
// Session/usage poll timer (60s) — less frequent
_sessionPollTimer = new System.Windows.Forms.Timer { Interval = 60000, Enabled = true };
_sessionPollTimer.Tick += OnSessionPoll;
// Global hotkey: Ctrl+Shift+Space → Quick Send
_globalHotkey = new GlobalHotkey();
_globalHotkey.HotkeyPressed += (_, _) => OnQuickSend(null, EventArgs.Empty);
_globalHotkey.Register();
}
private async void InitializeAsync()
{
try
{
_gatewayClient = new MoltbotGatewayClient(_settings!.GatewayUrl, _settings.Token, Logger.Instance);
_gatewayClient.StatusChanged += OnStatusChanged;
_gatewayClient.NotificationReceived += OnNotificationReceived;
_gatewayClient.ActivityChanged += OnActivityChanged;
_gatewayClient.ChannelHealthUpdated += OnChannelHealthUpdated;
_gatewayClient.SessionsUpdated += OnSessionsUpdated;
_gatewayClient.UsageUpdated += OnUsageUpdated;
await _gatewayClient.ConnectAsync();
// Process deep link if launched via URI
if (DeepLinkHandler.TryGetDeepLink(_startupArgs, out var uri) && uri != null)
{
await DeepLinkHandler.ProcessDeepLinkAsync(uri, _gatewayClient);
}
}
catch (Exception ex)
{
Logger.Error("Initial connection failed", ex);
ShowErrorToast("Connection Failed", $"Failed to connect: {ex.Message}");
}
}
// --- Event Handlers (marshal to UI thread) ---
private void OnStatusChanged(object? sender, ConnectionStatus status)
{
_syncContext?.Post(_ => UpdateStatus(status), null);
}
private void OnNotificationReceived(object? sender, MoltbotNotification n)
{
_syncContext?.Post(_ => ShowNotificationToast(n.Title, n.Message, n.Type), null);
}
private void OnActivityChanged(object? sender, AgentActivity activity)
{
_syncContext?.Post(_ => UpdateActivity(activity), null);
}
private void OnChannelHealthUpdated(object? sender, ChannelHealth[] channels)
{
_syncContext?.Post(_ => UpdateChannelHealth(channels), null);
}
private void OnSessionsUpdated(object? sender, SessionInfo[] sessions)
{
_syncContext?.Post(_ => UpdateSessions(sessions), null);
}
private void OnUsageUpdated(object? sender, GatewayUsageInfo usage)
{
_syncContext?.Post(_ => UpdateUsage(usage), null);
}
// --- UI Updates ---
private void UpdateStatus(ConnectionStatus status)
{
_currentStatus = status;
if (_notifyIcon != null)
{
var oldIcon = _notifyIcon.Icon;
_notifyIcon.Icon = CreateStatusIcon(status, _currentActivity?.Kind);
SafeDestroyIcon(oldIcon);
var tooltip = _currentActivity?.Kind != ActivityKind.Idle && !string.IsNullOrEmpty(_currentActivity?.DisplayText)
? $"Clawdbot — {_currentActivity.DisplayText}"
: $"Clawdbot — {status}";
_notifyIcon.Text = tooltip.Length > 63 ? tooltip[..63] : tooltip;
}
if (_statusItem != null)
{
var label = status switch
{
ConnectionStatus.Connected => "[ON]",
ConnectionStatus.Connecting => "[...]",
ConnectionStatus.Error => "[ERR]",
_ => "[OFF]"
};
_statusItem.Text = $"{label} Gateway: {status}";
}
}
private void UpdateActivity(AgentActivity activity)
{
// Track per-session activity for stable display
_sessionActivities[activity.SessionKey] = activity;
// Resolve which session to display using stable selection:
// 1. Active main session always wins
// 2. Keep current session if still active (prevents flip-flop)
// 3. Fall back to most recently active non-main session
var displayActivity = ResolveDisplayActivity(activity);
_currentActivity = displayActivity;
if (_activityItem != null)
{
if (displayActivity.Kind != ActivityKind.Idle && !string.IsNullOrEmpty(displayActivity.DisplayText))
{
_activityItem.Text = displayActivity.DisplayText;
_activityItem.Visible = true;
}
else
{
_activityItem.Visible = false;
}
}
// Also update the tray icon to reflect activity
UpdateStatus(_currentStatus);
}
/// <summary>
/// Selects the best session to display in the activity row.
/// Avoids rapid switching between sessions by applying a debounce window.
/// </summary>
private AgentActivity ResolveDisplayActivity(AgentActivity incoming)
{
var now = DateTime.UtcNow;
// If main session is active, always prefer it
if (incoming.IsMain && incoming.Kind != ActivityKind.Idle)
{
_displayedSessionKey = incoming.SessionKey;
_lastSessionSwitch = now;
return incoming;
}
// If the currently displayed session is still active, keep it (no flip-flop)
if (_displayedSessionKey != null &&
_sessionActivities.TryGetValue(_displayedSessionKey, out var current) &&
current.Kind != ActivityKind.Idle)
{
// Only allow switching away if debounce period has passed
if (now - _lastSessionSwitch < SessionSwitchDebounce)
return current;
}
// Check if any main session is active
foreach (var kvp in _sessionActivities)
{
if (kvp.Value.IsMain && kvp.Value.Kind != ActivityKind.Idle)
{
_displayedSessionKey = kvp.Key;
_lastSessionSwitch = now;
return kvp.Value;
}
}
// No main active — show the incoming active session if it has work
if (incoming.Kind != ActivityKind.Idle)
{
_displayedSessionKey = incoming.SessionKey;
_lastSessionSwitch = now;
return incoming;
}
// Everything is idle
_displayedSessionKey = null;
return incoming;
}
private void UpdateChannelHealth(ChannelHealth[] channels)
{
// Remove old channel items
foreach (var item in _channelItems)
_contextMenu?.Items.Remove(item);
_channelItems.Clear();
if (channels.Length == 0)
{
if (_channelSeparator != null) _channelSeparator.Visible = false;
return;
}
if (_channelSeparator != null) _channelSeparator.Visible = true;
var insertIndex = _contextMenu?.Items.IndexOf(_channelSeparator!) ?? -1;
if (insertIndex < 0) return;
// Add header
insertIndex++;
var header = new ToolStripMenuItem("📡 Channels") { Enabled = false };
_contextMenu!.Items.Insert(insertIndex, header);
_channelItems.Add(header);
foreach (var ch in channels)
{
insertIndex++;
var item = new ToolStripMenuItem($" {ch.DisplayText}") { Enabled = false };
_contextMenu.Items.Insert(insertIndex, item);
_channelItems.Add(item);
}
}
private void UpdateSessions(SessionInfo[] sessions)
{
// Log session data for debugging
Logger.Info($"UpdateSessions: {sessions.Length} sessions");
foreach (var s in sessions)
Logger.Info($" Session: key={s.Key}, isMain={s.IsMain}, status={s.Status}, channel={s.Channel}");
// Remove old session items
foreach (var item in _sessionItems)
_contextMenu?.Items.Remove(item);
_sessionItems.Clear();
if (sessions.Length == 0)
{
if (_sessionSeparator != null) _sessionSeparator.Visible = false;
return;
}
if (_sessionSeparator != null) _sessionSeparator.Visible = true;
var insertIndex = _contextMenu?.Items.IndexOf(_sessionSeparator!) ?? -1;
if (insertIndex < 0) return;
// Add header
insertIndex++;
var header = new ToolStripMenuItem("🧠 Sessions") { Enabled = false };
_contextMenu!.Items.Insert(insertIndex, header);
_sessionItems.Add(header);
foreach (var session in sessions)
{
insertIndex++;
// Use ShortKey if DisplayText is too minimal
var displayText = session.DisplayText;
if (displayText == "⚡ Main" || displayText == "🔹 Sub")
displayText = $"{displayText} · {session.ShortKey}";
var item = new ToolStripMenuItem($" {displayText}") { Enabled = false };
_contextMenu.Items.Insert(insertIndex, item);
_sessionItems.Add(item);
}
}
private void UpdateUsage(GatewayUsageInfo usage)
{
if (_usageItem != null)
{
_usageItem.Text = $"📊 {usage.DisplayText}";
_usageItem.Visible = true;
}
}
// --- Icon Creation (with proper cleanup) ---
private Icon CreateStatusIcon(ConnectionStatus status, ActivityKind? activity = null)
{
var bitmap = new Bitmap(16, 16);
using (var g = Graphics.FromImage(bitmap))
{
g.Clear(Color.Transparent);
if (status == ConnectionStatus.Connected)
{
// Draw pixel lobster when connected
DrawPixelLobster(g);
}
else
{
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
// Base color from status
var baseColor = status switch
{
ConnectionStatus.Connecting => Color.FromArgb(255, 180, 0), // Amber
ConnectionStatus.Error => Color.FromArgb(220, 50, 50), // Red
_ => Color.FromArgb(128, 128, 128) // Gray
};
// Main circle for non-connected states
using var brush = new SolidBrush(baseColor);
g.FillEllipse(brush, 1, 1, 13, 13);
}
// Activity badge (small dot in corner when working)
if (activity is not null and not ActivityKind.Idle && status == ConnectionStatus.Connected)
{
var badgeColor = activity switch
{
ActivityKind.Exec => Color.FromArgb(255, 100, 0), // Orange
ActivityKind.Write or ActivityKind.Edit => Color.FromArgb(100, 200, 50), // Green
ActivityKind.Read => Color.FromArgb(80, 150, 255), // Blue
ActivityKind.Search or ActivityKind.Browser => Color.FromArgb(180, 80, 255), // Purple
ActivityKind.Message => Color.FromArgb(50, 200, 100), // Bright green
_ => Color.White
};
using var badgeBrush = new SolidBrush(badgeColor);
g.FillEllipse(badgeBrush, 10, 0, 6, 6);
using var borderPen = new Pen(Color.Black, 1);
g.DrawEllipse(borderPen, 10, 0, 6, 6);
}
}
var hIcon = bitmap.GetHicon();
var icon = Icon.FromHandle(hIcon);
bitmap.Dispose();
return icon;
}
private void DrawPixelLobster(Graphics g)
{
// Pixel lobster from SVG - 16x16 pixel art
var outline = Color.FromArgb(58, 10, 13); // #3a0a0d - dark outline
var body = Color.FromArgb(255, 79, 64); // #ff4f40 - red body
var claw = Color.FromArgb(255, 119, 95); // #ff775f - lighter claws
var eyeDark = Color.FromArgb(8, 16, 22); // #081016 - pupils
var eyeLight = Color.FromArgb(245, 251, 255); // #f5fbff - eye whites
// Outline (dark border)
var outlinePixels = new[] {
(1,5), (1,6), (1,7),
(2,4), (2,8),
(3,3), (3,9),
(4,2), (4,10),
(5,2), (6,2), (7,2), (8,2), (9,2), (10,2),
(11,2), (12,3), (12,9),
(13,4), (13,8),
(14,5), (14,6), (14,7),
(5,11), (6,11), (7,11), (8,11), (9,11), (10,11),
(4,12), (11,12),
(3,13), (12,13),
(5,14), (6,14), (7,14), (8,14), (9,14), (10,14)
};
foreach (var (x, y) in outlinePixels)
bitmap_SetPixel(g, x, y, outline);
// Body (red)
var bodyPixels = new[] {
(5,3), (6,3), (7,3), (8,3), (9,3), (10,3),
(4,4), (5,4), (7,4), (8,4), (10,4), (11,4),
(3,5), (4,5), (5,5), (7,5), (8,5), (10,5), (11,5), (12,5),
(3,6), (4,6), (5,6), (6,6), (7,6), (8,6), (9,6), (10,6), (11,6), (12,6),
(3,7), (4,7), (5,7), (6,7), (7,7), (8,7), (9,7), (10,7), (11,7), (12,7),
(4,8), (5,8), (6,8), (7,8), (8,8), (9,8), (10,8), (11,8),
(5,9), (6,9), (7,9), (8,9), (9,9), (10,9),
(5,12), (6,12), (7,12), (8,12), (9,12), (10,12),
(6,13), (7,13), (8,13), (9,13)
};
foreach (var (x, y) in bodyPixels)
bitmap_SetPixel(g, x, y, body);
// Claws (lighter red)
var clawPixels = new[] {
(1,6), (2,5), (2,6), (2,7),
(13,5), (13,6), (13,7), (14,6)
};
foreach (var (x, y) in clawPixels)
bitmap_SetPixel(g, x, y, claw);
// Eyes
bitmap_SetPixel(g, 6, 4, eyeLight);
bitmap_SetPixel(g, 9, 4, eyeLight);
bitmap_SetPixel(g, 6, 5, eyeDark);
bitmap_SetPixel(g, 9, 5, eyeDark);
}
private void bitmap_SetPixel(Graphics g, int x, int y, Color c)
{
using var brush = new SolidBrush(c);
g.FillRectangle(brush, x, y, 1, 1);
}
private static void SafeDestroyIcon(Icon? icon)
{
if (icon == null) return;
try
{
DestroyIcon(icon.Handle);
icon.Dispose();
}
catch { }
}
// --- Toast Notifications ---
private void ShowNotificationToast(string title, string message, string type = "info")
{
// Always log to history regardless of filter
NotificationHistoryForm.AddEntry(title, message, type);
// Check per-type filter
if (_settings?.ShouldNotify(type) != true) return;
try
{
new ToastContentBuilder()
.AddText(title)
.AddText(message)
.Show();
}
catch (Exception)
{
_notifyIcon?.ShowBalloonTip(3000, title, message, ToolTipIcon.Info);
}
}
private void ShowErrorToast(string title, string message)
{
try
{
new ToastContentBuilder()
.AddText(title)
.AddText(message)
.Show();
}
catch
{
_notifyIcon?.ShowBalloonTip(3000, title, message, ToolTipIcon.Error);
}
}
private void OnToastActivated(ToastNotificationActivatedEventArgsCompat e)
{
// Parse arguments from toast
var args = ToastArguments.Parse(e.Argument);
if (args.TryGetValue("action", out var action) && action == "openDashboard")
{
if (args.TryGetValue("url", out var url))
{
try
{
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
catch (Exception ex)
{
Logger.Error($"Failed to open dashboard from toast: {ex.Message}");
}
}
}
}
// --- Menu Actions ---
private async void OnHealthCheck(object? sender, EventArgs e)
{
if (_gatewayClient != null && _currentStatus != ConnectionStatus.Connecting)
await _gatewayClient.CheckHealthAsync();
}
private async void OnSessionPoll(object? sender, EventArgs e)
{
if (_gatewayClient != null && _currentStatus == ConnectionStatus.Connected)
{
await _gatewayClient.RequestSessionsAsync();
await _gatewayClient.RequestUsageAsync();
}
}
private async void OnManualHealthCheck(object? sender, EventArgs e)
{
Logger.Info("Manual health check triggered");
if (_gatewayClient != null)
{
await _gatewayClient.CheckHealthAsync();
await _gatewayClient.RequestSessionsAsync();
await _gatewayClient.RequestUsageAsync();
}
}
private void OnOpenWebUI(object? sender, EventArgs e)
{
try
{
WebChatForm.ShowOrFocus(_settings!.GatewayUrl, _settings.Token);
}
catch (Exception ex)
{
// Fallback to browser if WebView2 fails
Logger.Warn($"WebView2 failed, falling back to browser: {ex.Message}");
var url = _settings!.GatewayUrl
.Replace("ws://", "http://")
.Replace("wss://", "https://");
try
{
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
catch (Exception ex2)
{
ShowErrorToast("Failed to open Web UI", ex2.Message);
}
}
}
private string GetDashboardUrl()
{
var baseUrl = _settings!.GatewayUrl
.Replace("ws://", "http://")
.Replace("wss://", "https://");
// Add token if available
if (!string.IsNullOrEmpty(_settings.Token))
{
var separator = baseUrl.Contains("?") ? "&" : "?";
return $"{baseUrl}{separator}token={Uri.EscapeDataString(_settings.Token)}";
}
return baseUrl;
}
private void OnOpenDashboard(object? sender, EventArgs e)
{
try
{
Process.Start(new ProcessStartInfo(GetDashboardUrl()) { UseShellExecute = true });
}
catch (Exception ex)
{
ShowErrorToast("Failed to open Dashboard", ex.Message);
}
}
private async void OnQuickSend(object? sender, EventArgs e)
{
using var dialog = new QuickSendDialog();
if (dialog.ShowDialog() == DialogResult.OK)
{
try
{
await _gatewayClient!.SendChatMessageAsync(dialog.Message);
ShowClickableToast("Message Sent", "Click to continue chat in dashboard");
}
catch (Exception ex)
{
ShowErrorToast("Failed to Send", ex.Message);
}
}
}
private void ShowClickableToast(string title, string message)
{
NotificationHistoryForm.AddEntry(title, message, "info");
try
{
new ToastContentBuilder()
.AddText(title)
.AddText(message)
.AddArgument("action", "openDashboard")
.AddArgument("url", GetDashboardUrl())
.Show();
}
catch
{
_notifyIcon?.ShowBalloonTip(3000, title, message, ToolTipIcon.Info);
}
}
private void OnSettings(object? sender, EventArgs e)
{
using var dialog = new SettingsDialog(_settings!);
if (dialog.ShowDialog() == DialogResult.OK)
{
_settings!.Save();
Task.Run(async () => await ReconnectAsync());
}
}
private void OnToggleAutoStart(object? sender, EventArgs e)
{
var menuItem = (ToolStripMenuItem)sender!;
_settings!.AutoStart = !_settings.AutoStart;
menuItem.Checked = _settings.AutoStart;
_settings.Save();
AutoStartManager.SetAutoStart(_settings.AutoStart);
Logger.Info($"Auto-start: {_settings.AutoStart}");
}
private void OnShowStatusDetail(object? sender, EventArgs e)
{
StatusDetailForm.ShowOrFocus(_gatewayClient, _settings, _currentStatus);
}
private void OnNotificationHistory(object? sender, EventArgs e)
{
NotificationHistoryForm.ShowOrFocus();
}
private void OnOpenLogFile(object? sender, EventArgs e)
{
try
{
var logDir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MoltbotTray");
var logPath = System.IO.Path.Combine(logDir, "clawdbot-tray.log");
if (System.IO.File.Exists(logPath))
{
Process.Start(new ProcessStartInfo(logPath) { UseShellExecute = true });
}
else
{
Process.Start(new ProcessStartInfo(logDir) { UseShellExecute = true });
}
}
catch (Exception ex)
{
ShowErrorToast("Failed to Open Log", ex.Message);
}
}
private async Task ReconnectAsync()
{
try
{
if (_gatewayClient != null)
{
await _gatewayClient.DisconnectAsync();
_gatewayClient.Dispose();
}
_gatewayClient = new MoltbotGatewayClient(_settings!.GatewayUrl, _settings.Token, Logger.Instance);
_gatewayClient.StatusChanged += OnStatusChanged;
_gatewayClient.NotificationReceived += OnNotificationReceived;
_gatewayClient.ActivityChanged += OnActivityChanged;
_gatewayClient.ChannelHealthUpdated += OnChannelHealthUpdated;
_gatewayClient.SessionsUpdated += OnSessionsUpdated;
_gatewayClient.UsageUpdated += OnUsageUpdated;
await _gatewayClient.ConnectAsync();
}
catch (Exception ex)
{
Logger.Error("Reconnection failed", ex);
ShowErrorToast("Reconnection Failed", ex.Message);
}
}
private void OnDoubleClick(object? sender, EventArgs e) => OnOpenWebUI(sender, e);
private void OnExit(object? sender, EventArgs e) => ExitThread();
// --- Cleanup ---
protected override void Dispose(bool disposing)
{
if (disposing)
{
Logger.Info("Application shutting down");
_globalHotkey?.Dispose();
_healthCheckTimer?.Dispose();
_sessionPollTimer?.Dispose();
_gatewayClient?.Dispose();
_notifyIcon?.Dispose();
_contextMenu?.Dispose();
Logger.Shutdown();
}
base.Dispose(disposing);
}
protected override void ExitThreadCore()
{
if (_notifyIcon != null) _notifyIcon.Visible = false;
base.ExitThreadCore();
}
}

View File

@ -0,0 +1,176 @@
using Microsoft.Web.WebView2.WinForms;
using Microsoft.Web.WebView2.Core;
using System;
using System.Drawing;
using System.IO;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace MoltbotTray;
/// <summary>
/// Embeds the Clawdbot WebChat UI via WebView2, matching the macOS native chat panel.
/// </summary>
public class WebChatForm : Form
{
private WebView2? _webView;
private readonly string _gatewayUrl;
private readonly string _token;
private ToolStrip? _toolbar;
private bool _initialized;
private static WebChatForm? _instance;
/// <summary>
/// Show or focus the singleton WebChat window.
/// </summary>
public static void ShowOrFocus(string gatewayUrl, string token)
{
if (_instance != null && !_instance.IsDisposed)
{
_instance.BringToFront();
_instance.Focus();
return;
}
_instance = new WebChatForm(gatewayUrl, token);
_instance.Show();
}
private WebChatForm(string gatewayUrl, string token)
{
_gatewayUrl = gatewayUrl;
_token = token;
InitializeComponent();
_ = InitializeWebViewAsync();
}
private void InitializeComponent()
{
Text = "Clawdbot Chat";
Size = new Size(520, 750);
MinimumSize = new Size(380, 450);
StartPosition = FormStartPosition.CenterScreen;
Icon = IconHelper.GetLobsterIcon();
BackColor = Color.FromArgb(30, 30, 30);
// Toolbar
_toolbar = new ToolStrip
{
GripStyle = ToolStripGripStyle.Hidden,
RenderMode = ToolStripRenderMode.System,
BackColor = Color.FromArgb(45, 45, 45),
ForeColor = Color.White
};
var homeBtn = new ToolStripButton("🏠 Home") { ForeColor = Color.White };
homeBtn.Click += (_, _) => NavigateToChat();
var refreshBtn = new ToolStripButton("↻ Refresh") { ForeColor = Color.White };
refreshBtn.Click += (_, _) => _webView?.Reload();
var popoutBtn = new ToolStripButton("↗ Browser") { ForeColor = Color.White };
popoutBtn.Click += (_, _) =>
{
var url = _gatewayUrl.Replace("ws://", "http://").Replace("wss://", "https://");
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo($"{url}?token={Uri.EscapeDataString(_token)}") { UseShellExecute = true }); }
catch { }
};
var devToolsBtn = new ToolStripButton("🔧 DevTools") { ForeColor = Color.White };
devToolsBtn.Click += (_, _) => _webView?.CoreWebView2?.OpenDevToolsWindow();
_toolbar.Items.Add(homeBtn);
_toolbar.Items.Add(refreshBtn);
_toolbar.Items.Add(popoutBtn);
_toolbar.Items.Add(new ToolStripSeparator());
_toolbar.Items.Add(devToolsBtn);
// WebView2 fills remaining space
_webView = new WebView2
{
Dock = DockStyle.Fill,
DefaultBackgroundColor = Color.FromArgb(30, 30, 30)
};
// Controls layout — toolbar on top, webview fills rest
Controls.Add(_webView);
Controls.Add(_toolbar);
_toolbar.Dock = DockStyle.Top;
}
private async Task InitializeWebViewAsync()
{
try
{
// Use a dedicated user data folder
var userDataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MoltbotTray", "WebView2");
var env = await CoreWebView2Environment.CreateAsync(
userDataFolder: userDataDir);
await _webView!.EnsureCoreWebView2Async(env);
// Configure WebView2
var settings = _webView.CoreWebView2.Settings;
settings.IsStatusBarEnabled = false;
settings.AreDefaultContextMenusEnabled = true;
settings.IsZoomControlEnabled = true;
_initialized = true;
Logger.Info("WebView2 initialized");
NavigateToChat();
}
catch (WebView2RuntimeNotFoundException)
{
Logger.Error("WebView2 runtime not found");
var result = MessageBox.Show(
"The Microsoft WebView2 Runtime is required for the chat panel.\n\n" +
"Would you like to download it?",
"WebView2 Required",
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning);
if (result == DialogResult.Yes)
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(
"https://developer.microsoft.com/en-us/microsoft-edge/webview2/")
{ UseShellExecute = true });
}
Close();
}
catch (Exception ex)
{
Logger.Error("WebView2 init failed", ex);
MessageBox.Show($"Failed to initialize chat panel:\n{ex.Message}",
"Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
Close();
}
}
private void NavigateToChat()
{
if (!_initialized || _webView?.CoreWebView2 == null) return;
// Convert ws:// to http:// for the web UI
var httpUrl = _gatewayUrl
.Replace("ws://", "http://")
.Replace("wss://", "https://");
// The gateway serves WebChat at the root with token auth
var chatUrl = $"{httpUrl}?token={Uri.EscapeDataString(_token)}";
_webView.CoreWebView2.Navigate(chatUrl);
Logger.Info($"Navigating to WebChat: {httpUrl}");
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
_webView?.Dispose();
_instance = null;
base.OnFormClosed(e);
}
}

View File

@ -0,0 +1,48 @@
@echo off
setlocal
echo ===================================
echo Clawdbot Windows Tray - Build
echo ===================================
echo.
:: Detect architecture
if "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
set RID=win-arm64
) else (
set RID=win-x64
)
echo Architecture: %RID%
echo.
:: Build
echo [1/3] Building Debug...
dotnet build -c Debug -r %RID%
if errorlevel 1 goto :error
echo.
echo [2/3] Building Release...
dotnet build -c Release -r %RID%
if errorlevel 1 goto :error
echo.
echo [3/3] Publishing self-contained...
dotnet publish -c Release -r %RID% --self-contained -p:PublishSingleFile=true -o publish
if errorlevel 1 goto :error
echo.
echo ===================================
echo Build complete!
echo Output: publish\ClawdbotTray.exe
echo Architecture: %RID%
echo ===================================
goto :end
:error
echo.
echo BUILD FAILED
exit /b 1
:end
endlocal

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

View File

@ -0,0 +1,26 @@
# Screenshots
This directory contains screenshots for the README and documentation.
## Required Screenshots
1. **tray-menu.png** - System tray icon with context menu open
2. **settings-dialog.png** - Settings configuration dialog
3. **quick-send-dialog.png** - Quick send message dialog
4. **notification-example.png** - Example of Windows toast notification
## Taking Screenshots
1. Run the application
2. Right-click the tray icon to show the menu
3. Open various dialogs and take screenshots
4. Use tools like Snipping Tool or Windows+Shift+S
5. Save as PNG files with descriptive names
## Image Guidelines
- Use PNG format for transparency support
- Keep file sizes reasonable (< 500KB each)
- Show the application in a clean Windows environment
- Include relevant context (Windows version, other tray icons, etc.)
- Ensure text is readable at normal viewing sizes

View File

@ -0,0 +1,86 @@
name: Build and Release
on:
push:
branches: [ main ]
tags: [ 'v*' ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: windows-latest
strategy:
matrix:
rid: [win-x64, win-arm64]
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Restore dependencies
run: dotnet restore -r ${{ matrix.rid }}
- name: Build Debug
run: dotnet build --no-restore -c Debug -r ${{ matrix.rid }}
- name: Build Release
run: dotnet build --no-restore -c Release -r ${{ matrix.rid }}
- name: Publish Self-Contained
run: dotnet publish -c Release -r ${{ matrix.rid }} --self-contained -p:PublishSingleFile=true -o publish-${{ matrix.rid }}
- name: Upload Build Artifacts
uses: actions/upload-artifact@v4
with:
name: clawdbot-windows-tray-${{ matrix.rid }}
path: publish-${{ matrix.rid }}/
release:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download x64 artifact
uses: actions/download-artifact@v4
with:
name: clawdbot-windows-tray-win-x64
path: artifacts/win-x64
- name: Download arm64 artifact
uses: actions/download-artifact@v4
with:
name: clawdbot-windows-tray-win-arm64
path: artifacts/win-arm64
- name: Create Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: |
artifacts/win-x64/ClawdbotTray.exe
artifacts/win-arm64/ClawdbotTray.exe
body: |
## Clawdbot Windows Tray ${{ github.ref_name }}
### Downloads
- **x64**: `ClawdbotTray.exe` (Intel/AMD 64-bit)
- **arm64**: `ClawdbotTray.exe` (ARM64 — Windows on ARM)
### Requirements
- Windows 10 version 1903 or later
- [WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) (for embedded chat)
- Clawdbot gateway running on your network
### Quick Start
1. Download the executable for your architecture
2. Run `ClawdbotTray.exe`
3. Right-click tray icon → Settings
4. Enter your gateway URL and token