Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/Capacitor.App/App.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,15 @@
ring and fill would read as a second box. Fluent's default placeholder follows the
system theme and goes near-black on our dark surfaces — pin a readable muted tone. -->
<Style Selector="TextBox.kcapEmbedded">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource KcapTextBrush}" />
<Setter Property="CaretBrush" Value="{StaticResource KcapTextBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="PlaceholderForeground" Value="{StaticResource KcapMutedBrush}" />
</Style>
<Style Selector="TextBox.kcapEmbedded:focus /template/ Border#PART_BorderElement, TextBox.kcapEmbedded:pointerover /template/ Border#PART_BorderElement">
<Style Selector="TextBox.kcapEmbedded /template/ Border#PART_BorderElement,
TextBox.kcapEmbedded:focus /template/ Border#PART_BorderElement,
TextBox.kcapEmbedded:pointerover /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
</Style>
Expand Down Expand Up @@ -378,6 +384,11 @@
<Style Selector="Border.attachTarget.dragOver">
<Setter Property="BorderBrush" Value="{StaticResource KcapPrimaryBrush}" />
</Style>
<Style Selector="TextBlock.toolKindChip">
<Setter Property="FontSize" Value="10" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{StaticResource KcapMutedBrush}" />
</Style>
<!-- Live tool-call status: a soft pulse so an in-flight row is never a blank gap. -->
<Style Selector="Border.toolRunning">
<Style.Animations>
Expand Down
21 changes: 21 additions & 0 deletions src/Capacitor.App/Services/PendingPermissionRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,27 @@ static PendingPermissionRequest FromReconciledClaude(string sessionId, PendingIn
internal long LiveSequence { get; set; }
public bool IsQuestion => Questions is not null || AcpQuestion is not null;

/// Local AskUserQuestion and the hub elicitation share no id until the daemon maps them;
/// same session plus overlapping question text is the join. Permission prompts never match.
internal bool SameQuestionAs(PendingPermissionRequest other) {
if (!IsQuestion || !other.IsQuestion) return false;
if (!string.Equals(SessionId, other.SessionId, StringComparison.Ordinal)) return false;
return QuestionFingerprints().ToHashSet(StringComparer.Ordinal).Overlaps(other.QuestionFingerprints());
}

IEnumerable<string> QuestionFingerprints() {
if (AcpQuestion?.Prompt?.Trim() is { Length: > 0 } prompt)
yield return prompt;
if (Questions is not { Questions.Length: > 0 } parsed) yield break;
var question = parsed.Questions[0].Question.Trim();
if (question.Length == 0) yield break;
yield return question;
if (parsed.Questions[0].Header is { Length: > 0 } header) {
yield return $"{header}\n{question}";
yield return $"{header}\n\n{question}";
}
}

static DateTimeOffset ParseTime(string s) =>
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var t) ? t : DateTimeOffset.MinValue;
}
25 changes: 25 additions & 0 deletions src/Capacitor.App/Services/PermissionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ void UpsertLocal(PermissionPendingDto dto) {
var item = new PendingPermissionRequest(dto);
_cache.AddOrUpdate(item);
Shadow(dto.ServerRequestId);
ShadowMatchingServerQuestions(item);
}
}

Expand All @@ -258,6 +259,29 @@ void Shadow(string? serverRequestId) {
// Caller holds _lock.
bool IsClaimed(string serverRequestId) => _cache.Items.Any(i => i.Lane == PermissionLane.Local && i.ServerRequestId == serverRequestId);

// Caller holds _lock. Prompt-text join: the daemon has not yet written ServerRequestId.
void ShadowMatchingServerQuestions(PendingPermissionRequest local) {
if (!local.IsQuestion) return;
foreach (var twin in _cache.Items.Where(i => i.Lane == PermissionLane.Server && local.SameQuestionAs(i)).ToList())
ShadowTwin(local, twin);
Comment on lines +265 to +266

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Repeated prompts hide live requests 🔗 Cross-repo conflict ≡ Correctness

SameQuestionAs, ShadowMatchingServerQuestions, and LocalQuestionClaimant use same-session
first-prompt text as request identity, allowing one local card to remove multiple server
elicitations while ShadowTwin retains only the first ServerRequestId. When distinct concurrent
requests share that prompt, settling the visible card concludes only its recorded ID and leaves the
other request pending but inaccessible in _shadowed until the local lane disconnects.
Agent Prompt
## Issue description
Prompt equality is not a unique cross-repository identity. Multiple kcap-server interactions in one session may have identical prompts and distinct request IDs, but the current reconciliation can shadow all of them behind one local card while retaining and concluding only one ID.

## Fix Focus Areas
- src/Capacitor.App/Services/PermissionService.cs[262-283]
- src/Capacitor.App/Services/PendingPermissionRequest.cs[103-122]

## Recommended Fix
Make heuristic prompt-based reconciliation strictly one-to-one. Only consider unmatched local and server questions, never prompt-match a local card that already has a different `ServerRequestId`, and shadow only when exactly one unmatched local candidate and one unmatched server candidate share the fingerprint; stop after assigning one server request to one local request. Preserve exact identifier-based matching for confirmed daemon mappings, leave ambiguous same-prompt requests visible until authoritative request-ID correlation is available, and add tests for repeated concurrent prompts arriving in both orders.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prompt-text join is the correlation we have until the daemon writes ServerRequestId. Hosted Claude has one live AskUserQuestion; a Bash permission in the same session does not match. Two distinct concurrent questions with the same first-prompt text is not a path here, and requiring a unique unmatched pair would leave a duplicate hub replay as a second card.

}

// Caller holds _lock. The twin may already be in the cache or only arriving now.
void ShadowTwin(PendingPermissionRequest local, PendingPermissionRequest twin) {
if (local.ServerRequestId is null) {
local.ServerRequestId = twin.RequestId;
_cache.Refresh(local);
Comment on lines +271 to +273

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Old prompts return after reconnecting 🐞 Bug ≡ Correctness

ShadowTwin writes a heuristic server identifier into the local request, but the later exact daemon
mapping overwrites it without releasing the previously shadowed server item. If the text join
initially selects the wrong same-prompt request, that request remains hidden and is restored as
pending when the local subscription reconnects.
Agent Prompt
## Issue description
A prompt-derived server mapping can be superseded by the daemon's exact mapping while its old server request remains indefinitely in the shadow dictionary.

## Fix Focus Areas
- src/Capacitor.App/Services/PermissionService.cs[230-248]
- src/Capacitor.App/Services/PermissionService.cs[269-276]
- src/Capacitor.App/Services/PermissionService.cs[285-295]

## Recommended Fix
Track whether a local mapping was inferred from prompt text. Before replacing an inferred identifier with a different daemon-provided identifier, remove the old twin from `_shadowed` and restore it to the cache when it is still pending and not tombstoned, then shadow only the confirmed identifier.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same case as the prompt-join thread: overwriting ServerRequestId with a different id only happens if the text join already picked the wrong twin. One local card plus one server elicitation is a no-op overwrite; a leftover shadow does not arise on reconnect.

}
_shadowed[twin.Key] = twin;
_cache.Remove(twin.Key);
}

// Caller holds _lock.
PendingPermissionRequest? LocalQuestionClaimant(PendingPermissionRequest server) {
if (!server.IsQuestion) return null;
return _cache.Items.FirstOrDefault(i => i.Lane == PermissionLane.Local && i.SameQuestionAs(server));
}

void DropLocalLane() {
lock (_lock) {
if (_disposed) return;
Expand Down Expand Up @@ -303,6 +327,7 @@ internal void UpsertServer(PendingPermissionRequest item) {
item.AgentId = _sessionAgents.GetValueOrDefault(item.SessionId, "");
item.LiveSequence = ++_liveSequence;
if (IsClaimed(item.RequestId)) { _shadowed[item.Key] = item; return; }
if (LocalQuestionClaimant(item) is { } claimant) { ShadowTwin(claimant, item); return; }
if (_cache.Lookup(item.Key) is { HasValue: true, Value: var live }) {
live.LiveSequence = item.LiveSequence; // a live card keeps its instance, not its stamp
return;
Expand Down
16 changes: 15 additions & 1 deletion src/Capacitor.App/ViewModels/ChatItems.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

namespace Capacitor.App.ViewModels;

/// One row of the Chat tab. Five shapes, matched by DataTemplates on the concrete type.
/// One row of the Chat tab. Matched by DataTemplates on the concrete type.
public abstract class ChatItemViewModel : ReactiveObject { }

public sealed class UserTurnItem(string text) : ChatItemViewModel {
Expand Down Expand Up @@ -143,6 +143,14 @@ public string SummaryLine {
bool _hasFailure;
public bool HasFailure { get => _hasFailure; private set => this.RaiseAndSetIfChanged(ref _hasFailure, value); }

bool _packsWithCard;
/// A live card answering this group sits in the next row; the view drops the paragraph gap
/// so the two read as one block.
public bool PacksWithCard {
get => _packsWithCard;
set => this.RaiseAndSetIfChanged(ref _packsWithCard, value);
}

public ToolGroupItem() {
ToggleCommand = ReactiveCommand.Create(Toggle);
}
Expand Down Expand Up @@ -194,3 +202,9 @@ void Recompute() {
return text.Length <= cap ? text : text[..(cap - 1)] + "…";
}
}

/// A live prompt card sitting in the transcript list so it virtualizes with the thread.
public sealed class PendingCardItem(PendingCardViewModel card, bool packsWithPrevious = false) : ChatItemViewModel {
public PendingCardViewModel Card { get; } = card;
public bool PacksWithPrevious { get; } = packsWithPrevious;
}
40 changes: 39 additions & 1 deletion src/Capacitor.App/ViewModels/ChatTabViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ private set {
}
}

public string PhaseNote => Phase switch {
public string PhaseNote => _items.Count > 0 ? "" : Phase switch {
ChatTabPhase.Waiting => "Waiting for the transcript…",
ChatTabPhase.Missing => _missingNote ?? "The transcript file is missing",
ChatTabPhase.Unavailable => _unavailableNote ?? "No chat view for this harness",
Expand Down Expand Up @@ -361,6 +361,7 @@ public ChatTabViewModel(
}
}
Reconcile();
SyncPendingCardItems();
})
.DisposeWith(_disposables);

Expand Down Expand Up @@ -550,6 +551,7 @@ void SwitchFeed(string key, Func<string, IChatTranscriptFeed> open) {
// The rows are gone, so the view has to re-read what stands in for them even when the phase
// is unchanged — and only then, since the setter itself raises the note on a real change.
if (wasWaiting) this.RaisePropertyChanged(nameof(PhaseNote));
SyncPendingCardItems();
OnTick();
}

Expand Down Expand Up @@ -649,6 +651,7 @@ void Apply(int generation, FeedRead read) {
queued.Rebase(_inputGeneration, read.SnapshotOffset ?? CurrentOffset ?? 0);
RefreshQueue();
if (read.Lines.Count == 0) {
SyncPendingCardItems();
RefreshActivityNote();
return;
}
Expand Down Expand Up @@ -699,9 +702,44 @@ void Apply(int generation, FeedRead read) {
if (fresh.Count > 0) _items.AddRange(fresh);
RefreshQueue();
Reconcile();
SyncPendingCardItems();
RefreshActivityNote();
}

/// Cards ride the same virtualizing list as the thread, always last, so a path switch or a
/// transcript reset cannot bury them in the middle of replayed rows.
void SyncPendingCardItems() {
var cards = PendingCards;
var start = _items.Count;
while (start > 0 && _items[start - 1] is PendingCardItem) start--;
if (TrailingCardsMatch(start, cards)) return;

foreach (var item in _items)
if (item is ToolGroupItem { PacksWithCard: true } packed)
packed.PacksWithCard = false;

var wasEmpty = _items.Count == 0;
for (var i = _items.Count - 1; i >= 0; i--)
if (_items[i] is PendingCardItem) _items.RemoveAt(i);

var packs = cards.Count > 0 && _items.Count > 0 && _items[^1] is ToolGroupItem;
if (packs) ((ToolGroupItem)_items[^1]).PacksWithCard = true;
var first = true;
foreach (var card in cards) {
_items.Add(new PendingCardItem(card, packsWithPrevious: first && packs));
first = false;
}
if (wasEmpty != (_items.Count == 0))
this.RaisePropertyChanged(nameof(PhaseNote));
}

bool TrailingCardsMatch(int start, ReadOnlyObservableCollection<PendingCardViewModel> cards) {
if (_items.Count - start != cards.Count) return false;
for (var i = 0; i < cards.Count; i++)
if (!ReferenceEquals(((PendingCardItem)_items[start + i]).Card, cards[i])) return false;
return true;
}

/// A row is marked iff some pending request targets it: by tool-use id when the request has
/// one, else the sole running call. Recomputed whole on every change to either set and diffed
/// against the last marks, because a settled call has already left _pendingTools by the time
Expand Down
53 changes: 30 additions & 23 deletions src/Capacitor.App/Views/ChatTabView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,21 @@
xmlns:views="clr-namespace:Capacitor.App.Views"
x:Class="Capacitor.App.Views.ChatTabView"
x:DataType="vm:ChatTabViewModel">
<Grid RowDefinitions="*,Auto,Auto,Auto,Auto">
<UserControl.Styles>
<Style Selector="Border.toolGroup">
<Setter Property="Margin" Value="0,4,0,22" />
</Style>
<Style Selector="Border.toolGroup.packsWithCard">
<Setter Property="Margin" Value="0,4,0,4" />
</Style>
<Style Selector="ContentControl.pendingCard">
<Setter Property="Margin" Value="0,8,0,12" />
</Style>
<Style Selector="ContentControl.pendingCard.packsWithPrevious">
<Setter Property="Margin" Value="0,4,0,12" />
</Style>
</UserControl.Styles>
<Grid RowDefinitions="*,Auto,Auto,Auto">
<!-- The template owns the ScrollViewer: that is the shape Avalonia virtualizes. An
ItemsControl inside an external ScrollViewer is measured at infinite height. -->
<ItemsControl x:Name="ChatItems" Grid.Row="0" ItemsSource="{Binding Items}">
Expand Down Expand Up @@ -76,7 +90,8 @@
<DataTemplate x:DataType="vm:ToolGroupItem">
<!-- Stretch to the chat column (capped like assistant text) so lone cards are
not content-width pills. -->
<Border MaxWidth="660" MinWidth="320" Margin="0,4,0,22" Padding="12,10" CornerRadius="10"
<Border Classes="toolGroup" Classes.packsWithCard="{Binding PacksWithCard}"
MaxWidth="660" MinWidth="320" Padding="12,10" CornerRadius="10"
Background="{StaticResource KcapSurfaceBrush}"
BorderBrush="{StaticResource KcapBorderBrush}" BorderThickness="1"
HorizontalAlignment="Stretch">
Expand All @@ -85,8 +100,6 @@
Plain text (no chip chrome) so it lines up with the detail. -->
<Grid ColumnDefinitions="*,Auto" IsVisible="{Binding ShowsKindChip}">
<TextBlock Classes="toolKindChip" Text="{Binding KindChip}"
FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource KcapMutedBrush}"
VerticalAlignment="Center" />
<Panel Grid.Column="1" DataContext="{Binding LoneCall}" VerticalAlignment="Center">
<Border Classes="toolStatus" Width="14" Height="8" CornerRadius="4"
Expand Down Expand Up @@ -123,6 +136,16 @@
</StackPanel>
</Border>
</DataTemplate>
<DataTemplate x:DataType="vm:PendingCardItem">
<ContentControl Classes="pendingCard" Classes.packsWithPrevious="{Binding PacksWithPrevious}"
Content="{Binding Card}" MaxWidth="660" HorizontalAlignment="Stretch">
<ContentControl.DataTemplates>
<StaticResource ResourceKey="PermissionCardTemplate" />
<StaticResource ResourceKey="QuestionCardTemplate" />
<StaticResource ResourceKey="AcpQuestionCardTemplate" />
</ContentControl.DataTemplates>
</ContentControl>
</DataTemplate>
</ItemsControl.DataTemplates>
</ItemsControl>

Expand All @@ -139,22 +162,7 @@
VerticalAlignment="Center" />
</StackPanel>

<Border x:Name="NeedsYouRow" Grid.Row="2" Margin="22,0,22,4" IsVisible="{Binding HasPendingCards}">
<StackPanel Spacing="6">
<TextBlock Text="NEEDS YOU" FontSize="10" FontWeight="SemiBold" Foreground="{StaticResource KcapMutedBrush}" />
<ScrollViewer MaxHeight="420" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding PendingCards}">
<ItemsControl.DataTemplates>
<StaticResource ResourceKey="PermissionCardTemplate" />
<StaticResource ResourceKey="QuestionCardTemplate" />
<StaticResource ResourceKey="AcpQuestionCardTemplate" />
</ItemsControl.DataTemplates>
</ItemsControl>
</ScrollViewer>
</StackPanel>
</Border>

<Border x:Name="QueuedMessagesBanner" Grid.Row="3" Margin="22,4,22,0" Padding="13,10"
<Border x:Name="QueuedMessagesBanner" Grid.Row="2" Margin="22,4,22,0" Padding="13,10"
Background="{StaticResource KcapSurfaceBrush}" BorderBrush="{StaticResource KcapBorderBrush}"
BorderThickness="1" CornerRadius="8" IsVisible="{Binding HasQueuedMessages}">
<StackPanel Spacing="6">
Expand All @@ -180,15 +188,14 @@
</StackPanel>
</Border>

<Border x:Name="ComposerCard" Classes="attachTarget" Grid.Row="4" Margin="22,8,22,18"
<Border x:Name="ComposerCard" Classes="attachTarget" Grid.Row="3" Margin="22,8,22,18"
Background="{StaticResource KcapSurfaceBrush}"
BorderThickness="1" CornerRadius="10" Padding="13,12">
<StackPanel Spacing="6">
<views:AttachmentChipStrip x:Name="ChipStrip" Tray="{Binding Tray}"
IsVisible="{Binding Tray.HasAttachments}" />
<TextBox x:Name="ComposerInput" Classes="kcapEmbedded" Text="{Binding ComposerText}" AcceptsReturn="True" TextWrapping="Wrap"
MinHeight="38" MaxHeight="160" Background="Transparent" BorderThickness="0"
PlaceholderText="Write a message" IsVisible="{Binding ShowsComposer}" />
MinHeight="38" MaxHeight="160" PlaceholderText="Write a message" IsVisible="{Binding ShowsComposer}" />
<!-- A flow participant is addressed through the flow protocol, never messaged
directly, so the banner replaces the input rather than overlaying a dead one. -->
<Border x:Name="ReadOnlyBanner" IsVisible="{Binding IsReadOnlyParticipant}"
Expand Down
12 changes: 12 additions & 0 deletions src/Capacitor.App/Views/ChatTabView.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,30 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e
}

void OnReaderGesture(object? sender, RoutedEventArgs e) {
// A TextBox in the list (Other…, free-text) is editing, not reading: arming follow-tail
// would ScrollToEnd and recycle its virtualizing row, which drops the caret.
if (OriginatesFromTextBox(e)) return;
if (_readerGesture) return;
_readerGesture = true;
Dispatcher.UIThread.Post(() => _readerGesture = false, DispatcherPriority.Background);
}

void OnScrollChanged(object? sender, ScrollChangedEventArgs e) {
if (sender is not ScrollViewer scroll) return;
if (ListTextBoxOwnsFocus()) return;
var atBottom = scroll.Offset.Y + scroll.Viewport.Height >= scroll.Extent.Height - BottomTolerance;
_followTail = _readerGesture ? atBottom : _followTail || atBottom;
if (_followTail && !atBottom) scroll.ScrollToEnd();
}

static bool OriginatesFromTextBox(RoutedEventArgs e) =>
e.Source is Visual visual && (visual is TextBox || visual.GetVisualAncestors().OfType<TextBox>().Any());

bool ListTextBoxOwnsFocus() {
if (TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() is not TextBox box) return false;
return box.GetVisualAncestors().OfType<ItemsControl>().Any(c => ReferenceEquals(c, ChatItems));
}

/// The composer wraps, so the rendered rows, not the newlines, say whether the caret has a row
/// above or below it; the newline count stands in only before the box has a text presenter.
(int Line, int Lines) ComposerCaretLine() {
Expand Down
Loading