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:
Mika Kuns
2026-06-26 10:42:04 +02:00
parent bdda98eccd
commit 786eb2877f
11 changed files with 94 additions and 7 deletions

View File

@@ -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>

View File

@@ -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": {

View File

@@ -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": {

View 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();
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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="&#x276F;" <TextBlock Grid.Column="0" Text="&#x276F;"
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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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]);
}
} }