feat(ui): highlight user chat messages + opt-in interrupt (stop) button
LogKindForegroundConverter drives the log message foreground via a local binding (beats the dim local value), so user messages render in the accent color instead of vanishing into the transcript. Adds a small stop (Icon.Stop) button next to Send in both composers (SessionTerminalView + WorkConsole) wired to InterruptInteractiveCommand → InterruptInteractiveSessionAsync. Adds session.composer.interrupt (en/de).
This commit is contained in:
@@ -21,6 +21,7 @@
|
|||||||
<converters:DotBrushConverter x:Key="DotBrush"/>
|
<converters:DotBrushConverter x:Key="DotBrush"/>
|
||||||
<converters:BoolToItalicConverter x:Key="BoolToItalic"/>
|
<converters:BoolToItalicConverter x:Key="BoolToItalic"/>
|
||||||
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
|
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
|
||||||
|
<converters:LogKindForegroundConverter x:Key="LogKindForeground"/>
|
||||||
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
|
|||||||
@@ -235,7 +235,8 @@
|
|||||||
"composer": {
|
"composer": {
|
||||||
"placeholder": "Nachricht an die Sitzung…",
|
"placeholder": "Nachricht an die Sitzung…",
|
||||||
"send": "Senden",
|
"send": "Senden",
|
||||||
"stop": "Sitzung beenden"
|
"stop": "Sitzung beenden",
|
||||||
|
"interrupt": "Aktuellen Zug unterbrechen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"missionControl": {
|
"missionControl": {
|
||||||
|
|||||||
@@ -235,7 +235,8 @@
|
|||||||
"composer": {
|
"composer": {
|
||||||
"placeholder": "Message the session…",
|
"placeholder": "Message the session…",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"stop": "Stop session"
|
"stop": "Stop session",
|
||||||
|
"interrupt": "Interrupt current turn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"missionControl": {
|
"missionControl": {
|
||||||
|
|||||||
43
src/ClaudeDo.Ui/Converters/LogKindForegroundConverter.cs
Normal file
43
src/ClaudeDo.Ui/Converters/LogKindForegroundConverter.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Styling;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public sealed class LogKindForegroundConverter : IValueConverter
|
||||||
|
{
|
||||||
|
private static IBrush? Resolve(string key)
|
||||||
|
{
|
||||||
|
if (Application.Current is { } app &&
|
||||||
|
app.Resources.TryGetResource(key, app.ActualThemeVariant, out var res) &&
|
||||||
|
res is IBrush brush)
|
||||||
|
{
|
||||||
|
return brush;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
var key = value is LogKind kind ? kind switch
|
||||||
|
{
|
||||||
|
LogKind.Sys => "TextMuteBrush",
|
||||||
|
LogKind.Tool => "SageBrush",
|
||||||
|
LogKind.Claude => "TextBrush",
|
||||||
|
LogKind.Stdout => "TextDimBrush",
|
||||||
|
LogKind.Stderr => "BloodBrush",
|
||||||
|
LogKind.Done => "MossBrightBrush",
|
||||||
|
LogKind.Msg => "TextDimBrush",
|
||||||
|
LogKind.User => "AccentBrush",
|
||||||
|
_ => "TextDimBrush",
|
||||||
|
} : "TextDimBrush";
|
||||||
|
|
||||||
|
return Resolve(key) ?? AvaloniaProperty.UnsetValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
}
|
||||||
@@ -84,6 +84,9 @@
|
|||||||
<!-- Icon.X — filled X outline (PathIcon fills, so a stroke-only X renders invisible) -->
|
<!-- Icon.X — filled X outline (PathIcon fills, so a stroke-only X renders invisible) -->
|
||||||
<StreamGeometry x:Key="Icon.X">M6.4 4.6 L12 10.2 L17.6 4.6 L19.4 6.4 L13.8 12 L19.4 17.6 L17.6 19.4 L12 13.8 L6.4 19.4 L4.6 17.6 L10.2 12 L4.6 6.4 Z</StreamGeometry>
|
<StreamGeometry x:Key="Icon.X">M6.4 4.6 L12 10.2 L17.6 4.6 L19.4 6.4 L13.8 12 L19.4 17.6 L17.6 19.4 L12 13.8 L6.4 19.4 L4.6 17.6 L10.2 12 L4.6 6.4 Z</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Stop — filled square (stop / interrupt) -->
|
||||||
|
<StreamGeometry x:Key="Icon.Stop">M4 4 H20 V20 H4 Z</StreamGeometry>
|
||||||
|
|
||||||
<!-- Icon.Check -->
|
<!-- Icon.Check -->
|
||||||
<StreamGeometry x:Key="Icon.Check">M4 12l5 5 11-11</StreamGeometry>
|
<StreamGeometry x:Key="Icon.Check">M4 12l5 5 11-11</StreamGeometry>
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,13 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
|||||||
await _worker.StopInteractiveSessionAsync(_subscribedTaskId);
|
await _worker.StopInteractiveSessionAsync(_subscribedTaskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async System.Threading.Tasks.Task InterruptInteractive()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(_subscribedTaskId) && IsInteractiveLive)
|
||||||
|
await _worker.InterruptInteractiveSessionAsync(_subscribedTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
private void ClearPendingQuestion()
|
private void ClearPendingQuestion()
|
||||||
{
|
{
|
||||||
PendingQuestionId = null;
|
PendingQuestionId = null;
|
||||||
|
|||||||
@@ -267,7 +267,7 @@
|
|||||||
only while an interactive session is running for this task. -->
|
only while an interactive session is running for this task. -->
|
||||||
<Grid DockPanel.Dock="Bottom"
|
<Grid DockPanel.Dock="Bottom"
|
||||||
IsVisible="{Binding Monitor.IsInteractiveLive}"
|
IsVisible="{Binding Monitor.IsInteractiveLive}"
|
||||||
ColumnDefinitions="Auto,*,Auto"
|
ColumnDefinitions="Auto,*,Auto,Auto"
|
||||||
Margin="12,2,12,8">
|
Margin="12,2,12,8">
|
||||||
<TextBlock Grid.Column="0" Text="❯"
|
<TextBlock Grid.Column="0" Text="❯"
|
||||||
FontFamily="{StaticResource MonoFont}"
|
FontFamily="{StaticResource MonoFont}"
|
||||||
@@ -288,8 +288,14 @@
|
|||||||
<KeyBinding Gesture="Enter" Command="{Binding Monitor.SubmitComposerCommand}" />
|
<KeyBinding Gesture="Enter" Command="{Binding Monitor.SubmitComposerCommand}" />
|
||||||
</TextBox.KeyBindings>
|
</TextBox.KeyBindings>
|
||||||
</TextBox>
|
</TextBox>
|
||||||
<Button Grid.Column="2" Classes="prompt-action accent" Content="[Send]"
|
<Button Grid.Column="2" Classes="prompt-action"
|
||||||
VerticalAlignment="Center" Margin="12,0,0,0"
|
VerticalAlignment="Center" Margin="12,0,0,0"
|
||||||
|
Command="{Binding Monitor.InterruptInteractiveCommand}"
|
||||||
|
ToolTip.Tip="{loc:Tr session.composer.interrupt}">
|
||||||
|
<PathIcon Data="{StaticResource Icon.Stop}" Width="10" Height="10"/>
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="3" Classes="prompt-action accent" Content="[Send]"
|
||||||
|
VerticalAlignment="Center" Margin="4,0,0,0"
|
||||||
Command="{Binding Monitor.SubmitComposerCommand}" />
|
Command="{Binding Monitor.SubmitComposerCommand}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -306,7 +312,7 @@
|
|||||||
Text="{Binding TimestampFormatted}" />
|
Text="{Binding TimestampFormatted}" />
|
||||||
<SelectableTextBlock Grid.Column="1"
|
<SelectableTextBlock Grid.Column="1"
|
||||||
Text="{Binding Text}" Tag="{Binding ClassName}"
|
Text="{Binding Text}" Tag="{Binding ClassName}"
|
||||||
Foreground="{DynamicResource TextDimBrush}"
|
Foreground="{Binding Kind, Converter={StaticResource LogKindForeground}}"
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
BorderBrush="{DynamicResource LineBrush}"
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
BorderThickness="0,1,0,0"
|
BorderThickness="0,1,0,0"
|
||||||
Padding="6,5">
|
Padding="6,5">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||||
<TextBox Grid.Column="0"
|
<TextBox Grid.Column="0"
|
||||||
Text="{Binding #Root.ComposerText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
Text="{Binding #Root.ComposerText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||||
PlaceholderText="{Binding #Root.ComposerPlaceholder}"
|
PlaceholderText="{Binding #Root.ComposerPlaceholder}"
|
||||||
@@ -67,6 +67,13 @@
|
|||||||
</TextBox.KeyBindings>
|
</TextBox.KeyBindings>
|
||||||
</TextBox>
|
</TextBox>
|
||||||
<Button Grid.Column="1"
|
<Button Grid.Column="1"
|
||||||
|
Margin="6,0,0,0"
|
||||||
|
Classes="title-ctrl"
|
||||||
|
Command="{Binding #Root.InterruptCommand}"
|
||||||
|
ToolTip.Tip="{loc:Tr session.composer.interrupt}">
|
||||||
|
<PathIcon Data="{StaticResource Icon.Stop}" Width="10" Height="10"/>
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="2"
|
||||||
Margin="6,0,0,0"
|
Margin="6,0,0,0"
|
||||||
Content="{loc:Tr session.composer.send}"
|
Content="{loc:Tr session.composer.send}"
|
||||||
Command="{Binding #Root.SubmitCommand}"/>
|
Command="{Binding #Root.SubmitCommand}"/>
|
||||||
@@ -89,7 +96,7 @@
|
|||||||
<!-- Message text — selectable so the user can copy raw output -->
|
<!-- Message text — selectable so the user can copy raw output -->
|
||||||
<SelectableTextBlock Grid.Column="1"
|
<SelectableTextBlock Grid.Column="1"
|
||||||
Text="{Binding Text}" Tag="{Binding ClassName}"
|
Text="{Binding Text}" Tag="{Binding ClassName}"
|
||||||
Foreground="{DynamicResource TextDimBrush}"
|
Foreground="{Binding Kind, Converter={StaticResource LogKindForeground}}"
|
||||||
TextWrapping="Wrap"/>
|
TextWrapping="Wrap"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ public partial class SessionTerminalView : UserControl
|
|||||||
AvaloniaProperty.Register<SessionTerminalView, string?>(nameof(ComposerText), defaultBindingMode: BindingMode.TwoWay);
|
AvaloniaProperty.Register<SessionTerminalView, string?>(nameof(ComposerText), defaultBindingMode: BindingMode.TwoWay);
|
||||||
public static readonly StyledProperty<ICommand?> SubmitCommandProperty =
|
public static readonly StyledProperty<ICommand?> SubmitCommandProperty =
|
||||||
AvaloniaProperty.Register<SessionTerminalView, ICommand?>(nameof(SubmitCommand));
|
AvaloniaProperty.Register<SessionTerminalView, ICommand?>(nameof(SubmitCommand));
|
||||||
|
public static readonly StyledProperty<ICommand?> InterruptCommandProperty =
|
||||||
|
AvaloniaProperty.Register<SessionTerminalView, ICommand?>(nameof(InterruptCommand));
|
||||||
public static readonly StyledProperty<string?> ComposerPlaceholderProperty =
|
public static readonly StyledProperty<string?> ComposerPlaceholderProperty =
|
||||||
AvaloniaProperty.Register<SessionTerminalView, string?>(nameof(ComposerPlaceholder));
|
AvaloniaProperty.Register<SessionTerminalView, string?>(nameof(ComposerPlaceholder));
|
||||||
|
|
||||||
@@ -36,6 +38,7 @@ public partial class SessionTerminalView : UserControl
|
|||||||
public bool IsComposerVisible { get => GetValue(IsComposerVisibleProperty); set => SetValue(IsComposerVisibleProperty, value); }
|
public bool IsComposerVisible { get => GetValue(IsComposerVisibleProperty); set => SetValue(IsComposerVisibleProperty, value); }
|
||||||
public string? ComposerText { get => GetValue(ComposerTextProperty); set => SetValue(ComposerTextProperty, value); }
|
public string? ComposerText { get => GetValue(ComposerTextProperty); set => SetValue(ComposerTextProperty, value); }
|
||||||
public ICommand? SubmitCommand { get => GetValue(SubmitCommandProperty); set => SetValue(SubmitCommandProperty, value); }
|
public ICommand? SubmitCommand { get => GetValue(SubmitCommandProperty); set => SetValue(SubmitCommandProperty, value); }
|
||||||
|
public ICommand? InterruptCommand { get => GetValue(InterruptCommandProperty); set => SetValue(InterruptCommandProperty, value); }
|
||||||
public string? ComposerPlaceholder { get => GetValue(ComposerPlaceholderProperty); set => SetValue(ComposerPlaceholderProperty, value); }
|
public string? ComposerPlaceholder { get => GetValue(ComposerPlaceholderProperty); set => SetValue(ComposerPlaceholderProperty, value); }
|
||||||
|
|
||||||
private INotifyCollectionChanged? _subscribedCollection;
|
private INotifyCollectionChanged? _subscribedCollection;
|
||||||
|
|||||||
@@ -111,6 +111,7 @@
|
|||||||
IsComposerVisible="{Binding IsInteractiveLive}"
|
IsComposerVisible="{Binding IsInteractiveLive}"
|
||||||
ComposerText="{Binding ComposerDraft, Mode=TwoWay}"
|
ComposerText="{Binding ComposerDraft, Mode=TwoWay}"
|
||||||
SubmitCommand="{Binding SubmitComposerCommand}"
|
SubmitCommand="{Binding SubmitComposerCommand}"
|
||||||
|
InterruptCommand="{Binding InterruptInteractiveCommand}"
|
||||||
ComposerPlaceholder="{loc:Tr session.composer.placeholder}" />
|
ComposerPlaceholder="{loc:Tr session.composer.placeholder}" />
|
||||||
|
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -297,4 +297,18 @@ public class TaskMonitorViewModelTests : IDisposable
|
|||||||
Assert.Equal(LogKind.User, vm.Log[0].Kind);
|
Assert.Equal(LogKind.User, vm.Log[0].Kind);
|
||||||
Assert.Equal("do the thing", vm.Log[0].Text);
|
Assert.Equal("do the thing", vm.Log[0].Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InterruptInteractiveCommand_WhenLive_RecordsOneCall()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
using var vm = Build(worker);
|
||||||
|
vm.SetTaskId("t1");
|
||||||
|
worker.RaiseInteractiveStarted("t1");
|
||||||
|
|
||||||
|
await vm.InterruptInteractiveCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Single(worker.InterruptedInteractive);
|
||||||
|
Assert.Equal("t1", worker.InterruptedInteractive[0]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user