Skip to content

Commit c7ab48a

Browse files
fire-mageclaude
andcommitted
Add voice input, workspaces, onboarding, multi-chat sessions, and thread safety fixes
Major features: - Voice input (Whisper.net STT): hold-to-record mic button, model download, settings UI - Multi-project workspaces: named project groups with primary/related, workspace manager UI - Project onboarding: automatic CLAUDE.md generation for new projects via background CLI - Technical writer: background KB article extraction from chat responses - Multi-chat sub-tabs: ChatSessionViewModel extracted from MainViewModel, per-tab chat sessions - OpenTabEntry model for workspace-aware tab persistence (replaces OpenTabPaths) Bug fixes and improvements: - UsageService: always start timers even if token unavailable at startup (retry on poll) - SpeechRecognitionService: set _isRecording before waveInStart to prevent buffer loss, move waveInStop out of waveIn callback (MSDN restriction), use _transcribeLock to prevent processor dispose during transcription, add ObjectDisposedException guards - TechnicalWriterService: dispose timer on shutdown, _shutdownCalled flag prevents ContinueWith from touching disposed timer, idempotent Shutdown() - KnowledgeBaseService: add per-directory lock to LoadEntries for read consistency - App.xaml.cs: single CollectionChanged handler, fix tab service initialization order - MainWindow: atomic mic button entry guard, workspace menu and context menu - OnboardingService: only check glob (not regex pattern) for path traversal - ExplorerViewModel: multi-root support with SetRoots() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ed2cc8f commit c7ab48a

38 files changed

Lines changed: 6056 additions & 2974 deletions

src/ClaudeCodeWin/App.xaml.cs

Lines changed: 79 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ private async void Application_Startup(object sender, StartupEventArgs e)
9999
cliUpdateService.BlacklistedVersions = new HashSet<string>(settings.FailedCliVersions);
100100
var usageService = new UsageService();
101101
var contextSnapshotService = new ContextSnapshotService();
102+
var workspaceService = new WorkspaceService(settingsService, settings);
102103
var backlogService = new BacklogService();
103104
backlogService.Load();
104105
var teamNotesService = new TeamNotesService();
@@ -115,21 +116,24 @@ private async void Application_Startup(object sender, StartupEventArgs e)
115116
projectRegistry, contextSnapshotService, usageService,
116117
backlogService, teamNotesService, devKbService);
117118

118-
// Determine which project paths to restore (new multi-tab or legacy single)
119-
var tabPaths = (settings.OpenTabPaths is { Count: > 0 }
120-
? settings.OpenTabPaths
121-
: string.IsNullOrEmpty(settings.WorkingDirectory)
122-
? new List<string> { "" }
123-
: new List<string> { settings.WorkingDirectory })
124-
.Distinct(StringComparer.OrdinalIgnoreCase)
125-
.ToList();
119+
// Determine which tabs to restore (v2 workspace-aware, v1 path-only, or legacy single)
120+
var tabEntries = (settings.OpenTabs is { Count: > 0 }
121+
? settings.OpenTabs
122+
: (settings.OpenTabPaths is { Count: > 0 }
123+
? settings.OpenTabPaths.Select(p => new OpenTabEntry { Path = p }).ToList()
124+
: string.IsNullOrEmpty(settings.WorkingDirectory)
125+
? [new OpenTabEntry { Path = "" }]
126+
: [new OpenTabEntry { Path = settings.WorkingDirectory }])).ToList();
127+
128+
var tabPaths = tabEntries.Select(e => e.Path ?? "").ToList();
126129

127130
// Create initial tab (always created before mainWindow)
128131
var initialTab = tabHost.CreateTab();
129-
if (!string.IsNullOrEmpty(tabPaths[0]))
130-
initialTab.SetWorkingDirectoryOnStartup(tabPaths[0]);
132+
var firstEntry = tabEntries.First();
133+
if (!string.IsNullOrEmpty(firstEntry.Path))
134+
initialTab.SetWorkingDirectoryOnStartup(firstEntry.Path);
131135

132-
var mainWindow = new MainWindow(tabHost, notificationService, settingsService, settings, fileIndexService, chatHistoryService, projectRegistry);
136+
var mainWindow = new MainWindow(tabHost, notificationService, settingsService, settings, fileIndexService, chatHistoryService, projectRegistry, workspaceService);
133137
mainWindow.Show();
134138

135139
// Signal to update.cmd that the new version started successfully
@@ -178,25 +182,36 @@ private async void Application_Startup(object sender, StartupEventArgs e)
178182
scriptService.PopulateMenu(mainWindow, () => tabHost.ActiveTab!, gitService, settings, projectRegistry, backlogService);
179183
taskRunnerService.PopulateMenu(mainWindow, () => tabHost.ActiveTab!);
180184

181-
// Set task runner for all current and future tabs via CollectionChanged.
182-
// This ensures every new tab (startup restore, "+" button, Ctrl+T) gets it immediately.
183-
tabHost.Tabs.CollectionChanged += (_, args) =>
185+
// Restore additional saved tabs (after hooks are wired)
186+
// Build (tab, entry) pairs so workspace restoration doesn't rely on index matching
187+
var tabEntryPairs = new List<(MainViewModel tab, OpenTabEntry entry)>
184188
{
185-
if (args.NewItems is null) return;
186-
foreach (MainViewModel tab in args.NewItems)
187-
tab.SetTaskRunner(taskRunnerService, mainWindow);
189+
(initialTab, tabEntries[0])
188190
};
191+
foreach (var entry in tabEntries.Skip(1))
192+
{
193+
var tab = tabHost.CreateTab();
194+
if (!string.IsNullOrEmpty(entry.Path))
195+
tab.SetWorkingDirectoryOnStartup(entry.Path);
196+
tabEntryPairs.Add((tab, entry));
197+
}
189198

190-
// Set task runner for tabs already created before this subscription (initial tab)
199+
// Set task runner and workspace callback for all tabs (including restored ones)
191200
foreach (var tab in tabHost.Tabs)
201+
{
192202
tab.SetTaskRunner(taskRunnerService, mainWindow);
203+
tab.PersistWorkspacePrimary = (wsId, path) => workspaceService.SetPrimary(wsId, path);
204+
}
193205

194-
// Restore additional saved tabs (after hooks are wired)
195-
foreach (var path in tabPaths.Skip(1))
206+
// Restore workspaces using stored pairs (no index assumptions)
207+
foreach (var (tab, entry) in tabEntryPairs)
196208
{
197-
var tab = tabHost.CreateTab();
198-
if (!string.IsNullOrEmpty(path))
199-
tab.SetWorkingDirectoryOnStartup(path);
209+
if (entry.WorkspaceId is { } wsId)
210+
{
211+
var ws = workspaceService.GetById(wsId);
212+
if (ws != null)
213+
tab.SetActiveWorkspace(ws);
214+
}
200215
}
201216

202217
// Restore the previously active tab
@@ -214,6 +229,47 @@ private async void Application_Startup(object sender, StartupEventArgs e)
214229
mainWindow.SetKnowledgeBaseService(knowledgeBaseService);
215230
mainWindow.SetDevKbService(devKbService);
216231

232+
// Speech Recognition (Whisper) — on-demand, only if enabled
233+
var speechService = new SpeechRecognitionService();
234+
mainWindow.SetSpeechService(speechService);
235+
if (settings.VoiceInputEnabled && WhisperModelManager.IsModelDownloaded(settings.VoiceInputModel))
236+
{
237+
_ = Task.Run(async () =>
238+
{
239+
try
240+
{
241+
await speechService.LoadModelAsync(settings.VoiceInputModel);
242+
mainWindow.Dispatcher.InvokeAsync(() => mainWindow.UpdateMicButtonVisibility());
243+
}
244+
catch (Exception ex) { DiagnosticLogger.Log("VOICE_MODEL_LOAD_ERROR", ex.Message); }
245+
});
246+
}
247+
248+
// Project Onboarding + Technical Writer
249+
var onboardingService = new OnboardingService(projectRegistry, scriptService, taskRunnerService, knowledgeBaseService);
250+
onboardingService.Configure(claudeExePath);
251+
var technicalWriterService = new TechnicalWriterService(knowledgeBaseService);
252+
technicalWriterService.Configure(claudeExePath);
253+
254+
foreach (var tab in tabHost.Tabs)
255+
{
256+
tab.SetOnboardingService(onboardingService);
257+
tab.SetTechnicalWriter(technicalWriterService);
258+
}
259+
260+
// Single CollectionChanged handler for all new-tab initialization
261+
tabHost.Tabs.CollectionChanged += (_, args) =>
262+
{
263+
if (args.NewItems is null) return;
264+
foreach (MainViewModel tab in args.NewItems)
265+
{
266+
tab.SetTaskRunner(taskRunnerService, mainWindow);
267+
tab.PersistWorkspacePrimary = (wsId, path) => workspaceService.SetPrimary(wsId, path);
268+
tab.SetOnboardingService(onboardingService);
269+
tab.SetTechnicalWriter(technicalWriterService);
270+
}
271+
};
272+
217273
// Update check (welcome screen removed — always start fresh chat)
218274
await tabHost.Update.CheckOnStartupAsync();
219275

src/ClaudeCodeWin/ClaudeCodeWin.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525

2626
<ItemGroup>
2727
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="9.0.1" />
28+
<PackageReference Include="Whisper.net" Version="1.9.0" />
29+
<PackageReference Include="Whisper.net.Runtime" Version="1.9.0" />
2830
</ItemGroup>
2931

3032
</Project>

src/ClaudeCodeWin/MainWindow.xaml

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
xmlns:vm="clr-namespace:ClaudeCodeWin.ViewModels"
66
xmlns:models="clr-namespace:ClaudeCodeWin.Models"
77
xmlns:views="clr-namespace:ClaudeCodeWin.Views"
8+
x:Name="RootWindow"
89
Title="Claude Code for Windows (CCW)"
910
MinHeight="500" MinWidth="600"
1011
Background="{StaticResource BackgroundBrush}"
@@ -75,7 +76,7 @@
7576
<StackPanel Grid.Row="0">
7677
<Menu Background="{StaticResource SurfaceBrush}">
7778
<MenuItem Header="New _Chat" x:Name="NewChatMenuItem" Click="NewChatMenuItem_Click"
78-
ToolTip="Start a new chat session (Ctrl+N)."/>
79+
ToolTip="Open a new chat tab (Ctrl+N)."/>
7980
<MenuItem Header="S_cripts" x:Name="ScriptsMenu" Visibility="Collapsed"/>
8081
<MenuItem Header="_Deploy Scripts" x:Name="DeployScriptsMenu"
8182
ToolTip="Shell commands and deploy scripts for your projects.&#x0a;Output appears in a separate window. Customize via tasks.json."/>
@@ -97,6 +98,23 @@
9798
<MenuItem Header="_Knowledge Base..." Click="MenuItem_KnowledgeBase_Click"
9899
ToolTip="View Knowledge Base, browse Marketplace, or teach Claude a new skill."/>
99100
</MenuItem>
101+
<MenuItem Header="_Workspace"
102+
ToolTip="Multi-project workspace management"
103+
SubmenuOpened="WorkspaceMenu_SubmenuOpened">
104+
<MenuItem Header="_Create Workspace..." Click="MenuItem_CreateWorkspace_Click"
105+
ToolTip="Create a new workspace grouping multiple related projects."/>
106+
<MenuItem Header="_Open Workspace" x:Name="OpenWorkspaceMenu"
107+
ToolTip="Open a saved workspace."/>
108+
<Separator/>
109+
<MenuItem Header="_Edit Workspace..." x:Name="EditWorkspaceMenuItem"
110+
Click="MenuItem_EditWorkspace_Click"
111+
IsEnabled="False"
112+
ToolTip="Edit the active workspace (add/remove projects, change primary)."/>
113+
<MenuItem Header="C_lose Workspace" x:Name="CloseWorkspaceMenuItem"
114+
Click="MenuItem_CloseWorkspace_Click"
115+
IsEnabled="False"
116+
ToolTip="Close workspace and revert to single-project mode."/>
117+
</MenuItem>
100118
<MenuItem Header="_App"
101119
ToolTip="Project management, updates, and application information">
102120
<MenuItem Header="Check for _Updates..." Command="{Binding Update.CheckForUpdatesCommand}"
@@ -317,6 +335,38 @@
317335
Background="{StaticResource SurfaceBrush}"
318336
MouseLeftButtonUp="SubTab_Click" Tag="{Binding}">
319337
<StackPanel Orientation="Horizontal">
338+
<!-- Working indicator (blue dot) -->
339+
<Ellipse x:Name="WorkingDot" Width="6" Height="6" Margin="0,0,5,0"
340+
Fill="{StaticResource PrimaryBrush}"
341+
VerticalAlignment="Center"
342+
Visibility="{Binding IsWorking, Converter={StaticResource BoolToVis}}"/>
343+
<!-- Needs attention indicator (green dot with blink) -->
344+
<Ellipse x:Name="AttentionDot" Width="6" Height="6" Margin="0,0,5,0"
345+
Fill="{StaticResource SuccessBrush}"
346+
VerticalAlignment="Center"
347+
Visibility="{Binding NeedsAttention, Converter={StaticResource BoolToVis}}">
348+
<Ellipse.Style>
349+
<Style TargetType="Ellipse">
350+
<Style.Triggers>
351+
<DataTrigger Binding="{Binding NeedsAttention}" Value="True">
352+
<DataTrigger.EnterActions>
353+
<BeginStoryboard x:Name="BlinkStoryboard">
354+
<Storyboard RepeatBehavior="Forever">
355+
<DoubleAnimation
356+
Storyboard.TargetProperty="Opacity"
357+
From="1.0" To="0.2" Duration="0:0:0.6"
358+
AutoReverse="True"/>
359+
</Storyboard>
360+
</BeginStoryboard>
361+
</DataTrigger.EnterActions>
362+
<DataTrigger.ExitActions>
363+
<StopStoryboard BeginStoryboardName="BlinkStoryboard"/>
364+
</DataTrigger.ExitActions>
365+
</DataTrigger>
366+
</Style.Triggers>
367+
</Style>
368+
</Ellipse.Style>
369+
</Ellipse>
320370
<TextBlock x:Name="SubTabText" Text="{Binding DisplayTitle}" FontSize="12"
321371
Foreground="{StaticResource TextBrush}"
322372
VerticalAlignment="Center"
@@ -850,12 +900,12 @@
850900
<ColumnDefinition Width="*"/>
851901
</Grid.ColumnDefinitions>
852902
<Button Grid.Column="0"
853-
Content="New Chat"
903+
Content="Restart Chat"
854904
Command="{Binding NewSessionCommand}"
855905
Style="{StaticResource SecondaryButton}"
856906
FontSize="11" Padding="8,3"
857907
BorderBrush="#5BA65B" BorderThickness="1"
858-
ToolTip="Start a new chat (Ctrl+N)"
908+
ToolTip="Restart current chat session"
859909
VerticalAlignment="Center" Margin="0,0,4,0"/>
860910
<Button Grid.Column="1"
861911
Content="History"
@@ -1057,6 +1107,17 @@
10571107
Style="{StaticResource SecondaryButton}"
10581108
Click="SaveToNotepad_Click"
10591109
Margin="0,0,4,4"/>
1110+
<!-- Microphone button (voice input) -->
1111+
<Button x:Name="MicButton"
1112+
FontSize="14" Padding="6,4"
1113+
ToolTip="Hold to record voice (speech-to-text)"
1114+
Style="{StaticResource SecondaryButton}"
1115+
PreviewMouseLeftButtonDown="Mic_MouseDown"
1116+
PreviewMouseLeftButtonUp="Mic_MouseUp"
1117+
Margin="0,0,4,4"
1118+
Visibility="Collapsed">
1119+
<TextBlock x:Name="MicIcon" Text="&#x1F3A4;"/>
1120+
</Button>
10601121
<Button Content="Send"
10611122
Style="{StaticResource PrimaryButton}"
10621123
Command="{Binding SendCommand}"
@@ -1370,13 +1431,14 @@
13701431
<StackPanel Grid.Column="5" Orientation="Horizontal"
13711432
VerticalAlignment="Center" Margin="0,0,16,0"
13721433
ToolTip="Usage limits from your Anthropic subscription.&#x0a;Session resets every 5 hours, weekly every 7 days."
1373-
Visibility="{Binding DataContext.SessionPctText, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource StringToVis}}">
1434+
DataContext="{Binding DataContext, ElementName=RootWindow}"
1435+
Visibility="{Binding SessionPctText, Converter={StaticResource StringToVis}}">
13741436
<TextBlock Text="Session: " FontSize="11" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center"/>
1375-
<TextBlock Text="{Binding DataContext.SessionPctText, RelativeSource={RelativeSource AncestorType=Window}, Mode=OneWay}" FontSize="11" Foreground="{StaticResource AccentBrush}" VerticalAlignment="Center"/>
1376-
<TextBlock Text="{Binding DataContext.SessionExtraText, RelativeSource={RelativeSource AncestorType=Window}, Mode=OneWay}" FontSize="11" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center"/>
1437+
<TextBlock Text="{Binding SessionPctText, Mode=OneWay}" FontSize="11" Foreground="{StaticResource AccentBrush}" VerticalAlignment="Center"/>
1438+
<TextBlock Text="{Binding SessionExtraText, Mode=OneWay}" FontSize="11" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center"/>
13771439
<TextBlock Text="Week: " FontSize="11" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center"/>
1378-
<TextBlock Text="{Binding DataContext.WeekPctText, RelativeSource={RelativeSource AncestorType=Window}, Mode=OneWay}" FontSize="11" Foreground="{StaticResource AccentBrush}" VerticalAlignment="Center"/>
1379-
<TextBlock Text="{Binding DataContext.WeekExtraText, RelativeSource={RelativeSource AncestorType=Window}, Mode=OneWay}" FontSize="11" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center"/>
1440+
<TextBlock Text="{Binding WeekPctText, Mode=OneWay}" FontSize="11" Foreground="{StaticResource AccentBrush}" VerticalAlignment="Center"/>
1441+
<TextBlock Text="{Binding WeekExtraText, Mode=OneWay}" FontSize="11" Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center"/>
13801442
</StackPanel>
13811443
<Border Grid.Column="7" VerticalAlignment="Center"
13821444
Background="Transparent"

0 commit comments

Comments
 (0)