Repository navigation
Expand file tree
/
Copy pathNativeModeComposerTests.cs
More file actions
1151 lines (1013 loc) · 72.9 KB
/
Copy pathNativeModeComposerTests.cs
File metadata and controls
1151 lines (1013 loc) · 72.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* *******************************************************************************************************************
* Application: ClaudeCodeExtension
*
* Autor: Daniel Carvalho Liedke / Claude Code
*
* Copyright © Daniel Carvalho Liedke 2026
* Usage and reproduction in any manner whatsoever without the written permission of Daniel Carvalho Liedke is strictly forbidden.
*
* Purpose: Covers the pure decision helpers behind issue #151 (native mode's selectors/prompt box)
* plus source-level guards for the surrounding wiring that cannot run without a VS shell.
*
* *******************************************************************************************************************/
using ClaudeCodeVS;
using ClaudeCodeVS.UI;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace ClaudeCodeExtension.Tests
{
/// <summary>
/// <see cref="ClaudeCodeControl.ResolveComposerMode"/> and
/// <see cref="ClaudeCodeControl.ShouldHidePromptBox"/> decide what issue #151 was actually about —
/// whether the chat composer's selectors have an anchor, and whether the panel's prompt box shows —
/// and both are pure enough to test directly, unlike the rest of the native-mode plumbing (which
/// needs a live WPF tree and is guarded at the source level instead, in the style of
/// <see cref="NativeSessionLifecycleTests"/>).
/// </summary>
[TestClass]
public class NativeModeComposerTests
{
[TestMethod]
public void ResolveComposerMode_OutsideNativeMode_IsHiddenRegardlessOfTabState()
{
Assert.AreEqual(ChatTranscriptView.ComposerMode.Hidden, ClaudeCodeControl.ResolveComposerMode(false, false));
Assert.AreEqual(ChatTranscriptView.ComposerMode.Hidden, ClaudeCodeControl.ResolveComposerMode(false, true));
}
[TestMethod]
public void ResolveComposerMode_NativeAndDockedInPanel_IsActionsOnly()
{
// This is the exact issue #151 scenario: native mode on, chat not detached to its own tab.
// ActionsOnly is what keeps the Agent/Model/Effort/Permissions selectors reachable here.
Assert.AreEqual(ChatTranscriptView.ComposerMode.ActionsOnly, ClaudeCodeControl.ResolveComposerMode(true, false));
}
[TestMethod]
public void ResolveComposerMode_NativeAndInItsOwnTab_IsFull()
{
Assert.AreEqual(ChatTranscriptView.ComposerMode.Full, ClaudeCodeControl.ResolveComposerMode(true, true));
}
[TestMethod]
public void ShouldHidePromptBox_ExplicitSettingAlwaysHidesIt()
{
// hidePromptPanel=true must win no matter what native mode/auto-hide say.
Assert.IsTrue(ClaudeCodeControl.ShouldHidePromptBox(true, false, false, false));
Assert.IsTrue(ClaudeCodeControl.ShouldHidePromptBox(true, true, true, false));
Assert.IsTrue(ClaudeCodeControl.ShouldHidePromptBox(true, true, false, true));
}
[TestMethod]
public void ShouldHidePromptBox_NotNativeMode_NeverAutoHides()
{
Assert.IsFalse(ClaudeCodeControl.ShouldHidePromptBox(false, false, true, true));
Assert.IsFalse(ClaudeCodeControl.ShouldHidePromptBox(false, false, false, true));
}
[TestMethod]
public void ShouldHidePromptBox_NativeButChatStillDockedInPanel_KeepsTheBoxVisible()
{
// The chat's own composer only shows its action row while docked (ComposerMode.ActionsOnly);
// the panel's prompt box is still the only place to type, so it must not auto-hide here.
Assert.IsFalse(ClaudeCodeControl.ShouldHidePromptBox(false, true, false, true));
}
[TestMethod]
public void ShouldHidePromptBox_NativeAndChatInTab_AutoHidesOnlyWhenTheSettingIsOn()
{
Assert.IsTrue(ClaudeCodeControl.ShouldHidePromptBox(false, true, true, true));
Assert.IsFalse(ClaudeCodeControl.ShouldHidePromptBox(false, true, true, false));
}
private static string NativeChatSource => RepositoryLayout.ReadText("Controls", "ClaudeCodeControl.NativeChat.cs");
private static string SettingsSource => RepositoryLayout.ReadText("Controls", "ClaudeCodeControl.Settings.cs");
private static string ProviderManagementSource => RepositoryLayout.ReadText("Controls", "ClaudeCodeControl.ProviderManagement.cs");
private static string ChatTranscriptXaml => RepositoryLayout.ReadText("UI", "ChatTranscriptView.xaml");
private static string ChatTranscriptCode => RepositoryLayout.ReadText("UI", "ChatTranscriptView.xaml.cs");
private static string PanelXaml => RepositoryLayout.ReadText("UI", "ClaudeCodeControl.xaml");
[TestMethod]
public void ReturnNativeChatToPanel_NoLongerFullyHidesTheComposer()
{
// The old ShowComposer(false) call is what killed GetSelectorAnchor and caused issue #151 —
// it must be gone from this method, replaced by SetComposerMode(ResolveComposerMode(...)).
string body = ExtractMethodBody(NativeChatSource, "private void ReturnNativeChatToPanel()");
StringAssert.DoesNotMatch(body, new System.Text.RegularExpressions.Regex(@"ShowComposer\s*\(\s*false\s*\)"),
"ReturnNativeChatToPanel must not fully hide the composer — that also hides the selectors (issue #151).");
StringAssert.Contains(body, "SetComposerMode(ResolveComposerMode(",
"The composer's visibility must be driven by ResolveComposerMode so it agrees with ShowNativeChatTabAsync.");
}
[TestMethod]
public void ApplyPromptPanelHiddenState_ComputesHiddenThroughTheSharedHelper()
{
string body = ExtractMethodBody(SettingsSource, "private void ApplyPromptPanelHiddenState()");
StringAssert.Contains(body, "ShouldHidePromptBox(",
"The hide decision must go through the shared, testable helper rather than an inline expression.");
}
/// <summary>
/// v177.0 re-enabled ⧉ for native mode as the deliberate dock/undock control: closing the
/// chat tab now reopens it instead of leaving the conversation docked in the panel, so the
/// button is the only route that puts it there and back. Issue #151 round 4's "lone floating
/// ⧉ in an empty panel" concern is still handled — while the chat owns its tab the whole
/// panel toolbar collapses (IsChatDetachedToOwnTab), and the reachable copy is the composer
/// mirror instead.
/// </summary>
[TestMethod]
public void RefreshToolbarLayout_EnablesTheDetachButtonForNativeMode()
{
string body = ExtractMethodBody(ProviderManagementSource, "private void RefreshToolbarLayout()");
StringAssert.Contains(body, "Apply(ToolbarButton.DetachTerminal, true, DetachToolbarButton, DetachTerminalMenuItem);",
"⧉ must be available in native mode again so the chat can be docked into the panel and back.");
}
/// <summary>
/// v177.0 round 3 (Daniel: "Leave detach icon only in the tools menu please, it will not be
/// very used"): while the chat is docked in the panel the ⧉ control stays a ☰ Tools menu item
/// only — RefreshToolbarLayout must NOT force the panel's own ⧉ toolbar button visible. The
/// round-2 forced-visible block was reverted.
/// </summary>
[TestMethod]
public void RefreshToolbarLayout_DoesNotForceTheDetachButtonVisibleWhileTheChatIsDockedInThePanel()
{
string body = ExtractMethodBody(ProviderManagementSource, "private void RefreshToolbarLayout()");
StringAssert.DoesNotMatch(body,
new System.Text.RegularExpressions.Regex(@"DetachToolbarButton\.Visibility\s*=\s*Visibility\.Visible"),
"⧉ must stay in the ☰ Tools menu unless the user promoted it — RefreshToolbarLayout must not force the panel's own ⧉ button on.");
}
/// <summary>
/// The chat tab already has a restart-agent button (↻ ComposerClearButton), so the mirrored
/// toolbar feed's promoted loop must not add a second one — RestartAgent stays skipped.
/// DetachTerminal is skipped by that loop too, then re-added unconditionally right after: the
/// panel's own ⧉ is inside the collapsed toolbar while the chat owns its tab, so the composer
/// mirror is the only reachable way to dock the conversation back (v177.0).
/// </summary>
[TestMethod]
public void RefreshToolbarLayout_PromotedButtonMirrorSkipsRestart_AndNeverStealsTheLiveDetachIcon()
{
string body = ExtractMethodBody(ProviderManagementSource, "private void RefreshToolbarLayout()");
// v177.0 round 4 (Daniel: "leave detach icon only in the tools menu" extended to the tab
// surface too): ⧉ is no longer force-mirrored into the composer as a standalone icon — the
// composer's ☰ Tools button reopens the same ToolsContextMenu as the panel, so ⧉ now follows
// the ordinary promoted/menu rule for both surfaces.
StringAssert.DoesNotMatch(body,
new System.Text.RegularExpressions.Regex(@"mirrored\.Insert\(0,\s*\(ToolbarButton\.DetachTerminal\.ToString\(\),\s*""⧉"""),
"⧉ must no longer be force-inserted into the composer mirror — the ☰ Tools menu is the only dock/undock control now.");
StringAssert.Contains(body, "if (id == ToolbarButton.RestartAgent) continue;",
"RestartAgent must still be skipped from the promoted mirror loop (it has its own ↻ composer button).");
// DetachTerminal is allowed through the ordinary loop now, but its Content is a live Viewbox
// (DetachButtonIcon) that WPF can only parent once — the mirror must substitute a plain glyph
// rather than reusing source.Content directly, or promoting ⧉ would strip the icon off the
// panel's own button.
StringAssert.Contains(body, "id == ToolbarButton.DetachTerminal ? \"⧉\" : source.Content",
"A promoted ⧉ must be mirrored with an independent glyph, not the panel's live Viewbox reference.");
StringAssert.Contains(body, "ChatTranscript.SetPromotedButtons(",
"RefreshToolbarLayout must push the mirrored set into the chat tab's composer.");
}
[TestMethod]
public void ChatTranscriptXaml_DeclaresTheMirroredConfigButtons()
{
string xaml = ChatTranscriptXaml;
StringAssert.Contains(xaml, "x:Name=\"ComposerSettingsButton\"");
StringAssert.Contains(xaml, "x:Name=\"ComposerToolsButton\"");
StringAssert.Contains(xaml, "x:Name=\"ComposerCustomCommandsButton\"");
StringAssert.Contains(xaml, "x:Name=\"ComposerPromotedButtons\"");
}
/// <summary>
/// The Send button was removed in issue #151 (Enter already sends), then reinstated in issue #186:
/// with "Send with Enter" off the chat tab had no way to send besides Ctrl+Enter. It must exist,
/// raise the same event as the Enter key, and only show while Enter does not send.
/// </summary>
[TestMethod]
public void ChatTranscriptView_SendButton_ShowsOnlyInButtonOnlyMode()
{
string xaml = ChatTranscriptXaml;
string cs = RepositoryLayout.ReadText("UI", "ChatTranscriptView.xaml.cs");
StringAssert.Contains(xaml, "x:Name=\"ComposerSendButton\"");
StringAssert.Contains(cs, "ComposerSendButton_Click");
StringAssert.Contains(cs, "ComposerSendButton.Visibility = _composerMode == ComposerMode.Full && !_sendWithEnter && !_sendWithCtrlEnter",
"The Send button must show only in the full composer while Enter does not send (issue #186).");
}
/// <summary>
/// Issue #186: the send key (Enter / Ctrl+Enter / button only) must reach every surface when it
/// changes — the chat composers (panel transcript + parallel tabs) via ApplyChatSendKeySettings,
/// and the terminal/docked-chat panel via its own ▶ button visibility. Both the Settings Apply
/// and a Backup import must trigger it.
/// </summary>
[TestMethod]
public void SendKeySetting_ReachesComposersAndPanelButton_OnApplyAndImport()
{
string dialog = RepositoryLayout.ReadText("Controls", "ClaudeCodeControl.SettingsDialog.cs");
string backup = RepositoryLayout.ReadText("Controls", "ClaudeCodeControl.SettingsBackup.cs");
string chat = RepositoryLayout.ReadText("Controls", "ClaudeCodeControl.NativeChat.cs");
StringAssert.Contains(dialog, "ApplyChatSendKeySettings();");
StringAssert.Contains(backup, "ApplyChatSendKeySettings();");
StringAssert.Contains(chat, "private void ApplyChatSendKeySettings()");
StringAssert.Contains(dialog, "SendPromptButton.Visibility = ShouldShowSendButton",
"The panel's own ▶ button (terminal mode / docked chat) must follow the same setting.");
}
/// <summary>
/// Reinstated after issue #151 round 3 removed it: a file picker is still the only way to
/// browse to a file outside the editor (drag-and-drop and Ctrl+V paste don't cover that), and
/// its absence was reported directly. The button, its click handler, its raised event and the
/// panel's subscriptions to it must all be present, and reuse the same file-picking helper the
/// terminal panel's own attach button uses so both paths share one implementation.
/// </summary>
[TestMethod]
public void ChatTranscriptView_HasAnAttachButton()
{
string xaml = ChatTranscriptXaml;
string cs = RepositoryLayout.ReadText("UI", "ChatTranscriptView.xaml.cs");
string nativeChatCs = RepositoryLayout.ReadText("Controls", "ClaudeCodeControl.NativeChat.cs");
StringAssert.Matches(xaml, new System.Text.RegularExpressions.Regex("ComposerAttachButton"),
"The attach button must be present in the composer bar.");
StringAssert.Contains(cs, "public event EventHandler AttachRequested;");
StringAssert.Contains(cs, "ComposerAttachButton_Click");
StringAssert.Contains(nativeChatCs, "ChatTranscript.AttachRequested += OnComposerAttachRequested;");
StringAssert.Contains(nativeChatCs, "transcript.AttachRequested += OnComposerAttachRequested;");
StringAssert.Contains(nativeChatCs, "PickAttachmentFiles()");
// Drag-and-drop and Ctrl+V paste remain independent of the button and must still work.
StringAssert.Contains(cs, "public event EventHandler<string[]> FilesDropped;");
}
/// <summary>
/// Issue #151 round 3: a customized toolbar plus every selector and mirrored config button can
/// be wider than the tab. The action row must be reachable by horizontal scroll rather than
/// clipping silently, and the mouse wheel (vertical by default) must be remapped to it since
/// there is nothing vertical in this row for the wheel to scroll.
/// </summary>
[TestMethod]
public void ComposerActionRow_ScrollsHorizontallyWhenButtonsDontFit()
{
string xaml = ChatTranscriptXaml;
string cs = RepositoryLayout.ReadText("UI", "ChatTranscriptView.xaml.cs");
StringAssert.Contains(xaml, "x:Name=\"ComposerActionsScroller\"");
StringAssert.Contains(xaml, "PreviewMouseWheel=\"ComposerActionsScroller_PreviewMouseWheel\"");
StringAssert.Contains(cs, "private void ComposerActionsScroller_PreviewMouseWheel(");
StringAssert.Contains(cs, "ComposerActionsScroller.ScrollToHorizontalOffset(");
}
/// <summary>
/// Round 4: a full-width scrollbar track read as "ugly" under a row this thin, so it's
/// replaced with small ◀/▶ buttons that only appear once there's actually something to
/// scroll to in that direction — never a permanently-visible pair of arrows.
/// </summary>
[TestMethod]
public void ComposerActionRow_UsesSmallArrowButtonsInsteadOfAScrollbar()
{
string xaml = ChatTranscriptXaml;
string cs = RepositoryLayout.ReadText("UI", "ChatTranscriptView.xaml.cs");
StringAssert.Contains(xaml, "HorizontalScrollBarVisibility=\"Hidden\"",
"The native scrollbar track must be hidden — the ◀/▶ buttons are the only scroll affordance shown.");
StringAssert.Contains(xaml, "x:Name=\"ComposerActionsScrollLeftButton\"");
StringAssert.Contains(xaml, "x:Name=\"ComposerActionsScrollRightButton\"");
StringAssert.Contains(xaml, "ScrollChanged=\"ComposerActionsScroller_ScrollChanged\"");
StringAssert.Contains(cs, "private void ComposerActionsScroller_ScrollChanged(");
StringAssert.Contains(cs, "ComposerActionsScrollLeftButton.Visibility = canScroll && ComposerActionsScroller.HorizontalOffset > 0.5");
StringAssert.Contains(cs, "ComposerActionsScrollRightButton.Visibility = canScroll && ComposerActionsScroller.HorizontalOffset < ComposerActionsScroller.ScrollableWidth - 0.5");
}
/// <summary>
/// ScrollChanged bubbles, so scrolling inside an expanded tool card (a long command's
/// horizontal scrollbar) reached the transcript handler with the inner viewer's offsets.
/// Those always read as "at the bottom", re-armed following, and the next streamed line
/// yanked the view back down while the user was reading the card.
/// </summary>
[TestMethod]
public void TranscriptScrollChanged_IgnoresScrollingInsideNestedViewers()
{
string cs = RepositoryLayout.ReadText("UI", "ChatTranscriptView.xaml.cs");
string body = ExtractMethodBody(cs,
"private void TranscriptScroll_ScrollChanged(object sender, ScrollChangedEventArgs e)");
int guard = body.IndexOf("if (!ReferenceEquals(e.OriginalSource, TranscriptScroll))", System.StringComparison.Ordinal);
int rearm = body.IndexOf("_autoScroll = atBottom;", System.StringComparison.Ordinal);
Assert.IsTrue(guard >= 0, "The handler must ignore ScrollChanged bubbling up from nested scroll viewers.");
Assert.IsTrue(rearm > guard, "The nested-viewer guard must run before following is re-armed.");
}
/// <summary>
/// Round 5 originally toggled the arrows with <c>Visibility.Hidden</c> instead of
/// <c>Collapsed</c>, reasoning that a collapsed "Auto" Grid column would widen the
/// scroller column and shift the toolbar's footprint whenever an arrow appeared or
/// disappeared. Round 11: a screenshot showed that reserved space rendering as a visible
/// dead gap next to the scroller whenever an arrow wasn't needed — worse than the
/// footprint concern, since Column 2 (the scroller) is this row's only Star column and
/// already bounded by <c>ComposerBar</c>, so collapsing an arrow's column only hands its
/// few pixels to the scroller rather than resizing the row itself. Switched both the XAML
/// default and the ScrollChanged handler to <c>Visibility.Collapsed</c> so unused arrow
/// space is never reserved.
/// </summary>
[TestMethod]
public void ComposerActionRow_ArrowButtonsCollapseInsteadOfReservingSpace()
{
string xaml = ChatTranscriptXaml;
string cs = RepositoryLayout.ReadText("UI", "ChatTranscriptView.xaml.cs");
StringAssert.Contains(xaml, "x:Name=\"ComposerActionsScrollLeftButton\"");
StringAssert.Contains(xaml, "x:Name=\"ComposerActionsScrollRightButton\"");
int leftIndex = xaml.IndexOf("x:Name=\"ComposerActionsScrollLeftButton\"", System.StringComparison.Ordinal);
int rightIndex = xaml.IndexOf("x:Name=\"ComposerActionsScrollRightButton\"", System.StringComparison.Ordinal);
Assert.IsTrue(leftIndex >= 0 && xaml.IndexOf("Visibility=\"Collapsed\"", leftIndex, System.StringComparison.Ordinal) - leftIndex < 300,
"ComposerActionsScrollLeftButton must default to Visibility=\"Collapsed\".");
Assert.IsTrue(rightIndex >= 0 && xaml.IndexOf("Visibility=\"Collapsed\"", rightIndex, System.StringComparison.Ordinal) - rightIndex < 300,
"ComposerActionsScrollRightButton must default to Visibility=\"Collapsed\".");
StringAssert.Matches(cs, new System.Text.RegularExpressions.Regex(
"ComposerActionsScrollLeftButton\\.Visibility = canScroll && ComposerActionsScroller\\.HorizontalOffset > 0\\.5\\s*\\?\\s*Visibility\\.Visible\\s*:\\s*Visibility\\.Collapsed;"),
"ComposerActionsScrollLeftButton must toggle to Collapsed, not Hidden.");
StringAssert.Matches(cs, new System.Text.RegularExpressions.Regex(
"ComposerActionsScrollRightButton\\.Visibility = canScroll && ComposerActionsScroller\\.HorizontalOffset < ComposerActionsScroller\\.ScrollableWidth - 0\\.5\\s*\\?\\s*Visibility\\.Visible\\s*:\\s*Visibility\\.Collapsed;"),
"ComposerActionsScrollRightButton must toggle to Collapsed, not Hidden.");
}
/// <summary>
/// Round 6: a screenshot showed the session actions and the Agent/Model/Effort/Permissions
/// selectors scrolling out of view along with everything else. Only the mirrored config
/// buttons (⚙/☰/⚡ and the promoted-toolbar mirrors) are meant to give way when space runs
/// out — the session actions and selectors are what actually drive the agent.
///
/// v174.0 keeps that guarantee but moves where it comes from: the whole row now scrolls (so a
/// tab too narrow even for the shortest captions can still reach every control), and the
/// essentials are protected by coming *first* in scroll order plus the density tiers that
/// shrink them before anything is clipped. So the invariant this test enforces is the
/// ordering: essentials in ComposerEssentialGroup ahead of the mirrors in ComposerMirrorGroup,
/// both inside the scroller, with the arrows bookending the row.
/// </summary>
[TestMethod]
public void ComposerActionRow_EssentialsScrollAheadOfTheMirroredConfigButtons()
{
string xaml = ChatTranscriptXaml;
int scrollerOpenIdx = xaml.IndexOf("<ScrollViewer x:Name=\"ComposerActionsScroller\"", System.StringComparison.Ordinal);
int scrollerCloseIdx = xaml.IndexOf("</ScrollViewer>", scrollerOpenIdx, System.StringComparison.Ordinal);
Assert.IsTrue(scrollerOpenIdx >= 0 && scrollerCloseIdx > scrollerOpenIdx,
"ComposerActionsScroller must exist as a ScrollViewer with a matching close tag.");
int essentialIdx = xaml.IndexOf("x:Name=\"ComposerEssentialGroup\"", System.StringComparison.Ordinal);
int mirrorIdx = xaml.IndexOf("x:Name=\"ComposerMirrorGroup\"", System.StringComparison.Ordinal);
Assert.IsTrue(essentialIdx > scrollerOpenIdx && essentialIdx < scrollerCloseIdx,
"ComposerEssentialGroup must sit inside ComposerActionsScroller.");
Assert.IsTrue(mirrorIdx > essentialIdx && mirrorIdx < scrollerCloseIdx,
"ComposerMirrorGroup must follow ComposerEssentialGroup inside the scroller, so the mirrors are what runs off the right edge first.");
// The controls that drive the agent lead the scrolled content.
foreach (string essential in new[]
{
"ComposerOverflowButton", "ComposerClearButton", "ComposerNewChatButton",
"ComposerRenameSessionButton", "ComposerColorButton", "ComposerProviderButton",
"ComposerModelButton", "ComposerEffortButton", "ComposerPermissionButton"
})
{
int idx = xaml.IndexOf($"x:Name=\"{essential}\"", System.StringComparison.Ordinal);
Assert.IsTrue(idx >= 0, $"{essential} not found.");
Assert.IsTrue(idx > essentialIdx && idx < mirrorIdx,
$"{essential} must sit in ComposerEssentialGroup, ahead of the mirrored config buttons.");
}
// The mirrors trail behind — they are duplicates of controls the panel already has.
foreach (string mirror in new[]
{
"ComposerPromotedButtons", "ComposerCustomCommandsButton", "ComposerToolsButton", "ComposerSettingsButton"
})
{
int idx = xaml.IndexOf($"x:Name=\"{mirror}\"", System.StringComparison.Ordinal);
Assert.IsTrue(idx >= 0, $"{mirror} not found.");
Assert.IsTrue(idx > mirrorIdx && idx < scrollerCloseIdx,
$"{mirror} must sit in ComposerMirrorGroup so it scrolls out of view before the selectors do.");
}
int leftArrowIdx = xaml.IndexOf("x:Name=\"ComposerActionsScrollLeftButton\"", System.StringComparison.Ordinal);
int rightArrowIdx = xaml.IndexOf("x:Name=\"ComposerActionsScrollRightButton\"", System.StringComparison.Ordinal);
Assert.IsTrue(leftArrowIdx >= 0 && leftArrowIdx < scrollerOpenIdx,
"The ◀ arrow must precede the scrollable section.");
Assert.IsTrue(rightArrowIdx > scrollerCloseIdx,
"The ▶ arrow must follow the scrollable section.");
}
/// <summary>
/// v174.0: a narrow chat tab used to clip the row — "Sonnet ▾" was cut in half and the effort,
/// permission and mirrored buttons after it were unreachable. The density pass is what prevents
/// that, so it must measure the essential group (never the mirrors, which are meant to scroll)
/// and must walk its tiers from the widest one every time, or a tab that is widened again would
/// stay stuck on the short captions.
/// </summary>
[TestMethod]
public void ComposerDensity_MeasuresTheEssentialGroupAndStartsFromTheWidestTier()
{
string cs = ChatTranscriptCode;
StringAssert.Contains(cs, "ComposerEssentialGroup.Measure(",
"RefreshComposerDensity must measure ComposerEssentialGroup to choose the tier.");
Assert.IsFalse(cs.Contains("ComposerMirrorGroup.Measure("),
"The mirrored config buttons must not drive the density tier — they are meant to scroll out of view instead.");
int orderIdx = cs.IndexOf("_composerDensityOrder", System.StringComparison.Ordinal);
Assert.IsTrue(orderIdx >= 0, "The density tiers must be walked from a declared order.");
int fullIdx = cs.IndexOf("ComposerDensity.Full", orderIdx, System.StringComparison.Ordinal);
int compactIdx = cs.IndexOf("ComposerDensity.Compact", orderIdx, System.StringComparison.Ordinal);
int tightIdx = cs.IndexOf("ComposerDensity.Tight", orderIdx, System.StringComparison.Ordinal);
Assert.IsTrue(fullIdx >= 0 && fullIdx < compactIdx && compactIdx < tightIdx,
"The tier order must run widest first (Full, Compact, Tight) so widening the tab restores the full captions.");
// The ⋯ menu is the only place the folded session actions remain reachable, so it has to
// invoke the same handlers rather than a second copy of the logic.
string xaml = ChatTranscriptXaml;
int overflowIdx = xaml.IndexOf("x:Name=\"ComposerOverflowButton\"", System.StringComparison.Ordinal);
int overflowEndIdx = xaml.IndexOf("</Button>", overflowIdx, System.StringComparison.Ordinal);
string overflow = xaml.Substring(overflowIdx, overflowEndIdx - overflowIdx);
foreach (string handler in new[]
{
"ComposerClearButton_Click", "ComposerNewChatButton_Click",
"ComposerRenameSessionButton_Click", "ComposerColorButton_Click"
})
{
StringAssert.Contains(overflow, handler,
$"The ⋯ menu must reuse {handler} instead of duplicating the action.");
}
}
/// <summary>
/// Issue #151 follow-up: the composer's mirrored config buttons (⚙/☰/⚡ and the promoted-
/// toolbar mirrors) are redundant while the chat is still docked in the panel — the panel's
/// own copies are right there in the same window — so they must only appear once the chat has
/// its own tab (<c>ComposerMode.Full</c>), not in <c>ActionsOnly</c>.
/// </summary>
[TestMethod]
public void SetComposerMode_RestrictsMirroredConfigButtonsToFullMode()
{
string body = ExtractMethodBody(
RepositoryLayout.ReadText("UI", "ChatTranscriptView.xaml.cs"),
"public void SetComposerMode(ComposerMode mode)");
StringAssert.Contains(body, "ComposerSettingsButton.Visibility = fullOnly",
"⚙ must only show once the chat is in its own tab.");
StringAssert.Contains(body, "UpdateCustomCommandsButtonVisibility()",
"⚡ visibility must be recomputed on every mode change, not fixed to fullOnly alone " +
"(issue #151 round 7: it must also stay hidden when no custom commands are configured).");
StringAssert.Contains(body, "ComposerPromotedButtons.Visibility = fullOnly",
"The promoted-toolbar mirrors must only show once the chat is in its own tab.");
}
/// <summary>
/// Issue #151 round 7: "the ⚡ menu should show only if we have actions configured" — the
/// composer's ⚡ mirror was gated only on composer mode (fullOnly), unlike the panel's own ⚡
/// (CustomCommandsButton), which already collapses when <c>_settings.CustomCommands</c> is
/// empty (ClaudeCodeControl.CustomCommands.cs's RefreshCustomCommandsButton). The mirror must
/// track the same "has any commands" signal, not just the composer mode.
/// </summary>
[TestMethod]
public void SetCustomCommandsMenuHasItems_FeedsTheSameGateAsTheFullOnlyVisibility()
{
string cs = RepositoryLayout.ReadText("UI", "ChatTranscriptView.xaml.cs");
StringAssert.Contains(cs, "public void SetCustomCommandsMenuHasItems(bool hasItems)",
"The chat view needs a way to hear whether any custom commands are configured.");
StringAssert.Contains(cs, "_composerMode == ComposerMode.Full && _customCommandsHasItems",
"⚡ must be gated by both Full mode and having at least one configured command, not either alone.");
}
[TestMethod]
public void RefreshCustomCommandsButton_FeedsHasAnyIntoTheComposerMirror()
{
string body = ExtractMethodBody(
RepositoryLayout.ReadText("Controls", "ClaudeCodeControl.CustomCommands.cs"),
"private void RefreshCustomCommandsButton()");
StringAssert.Contains(body, "ChatTranscript?.SetCustomCommandsMenuHasItems(hasAny)",
"The composer's ⚡ mirror must collapse under the same condition as the panel's own ⚡.");
}
/// <summary>
/// "The tools menu should not appear in case there are no items" — the composer's ☰ mirror
/// must track the same <c>anyInDropdown</c> computation the panel's own ☰ uses, not just the
/// composer mode.
/// </summary>
[TestMethod]
public void SetToolsMenuHasItems_FeedsTheSameGateAsTheFullOnlyVisibility()
{
string cs = RepositoryLayout.ReadText("UI", "ChatTranscriptView.xaml.cs");
StringAssert.Contains(cs, "public void SetToolsMenuHasItems(bool hasItems)",
"The chat view needs a way to hear whether the Tools menu would be empty.");
StringAssert.Contains(cs, "_composerMode == ComposerMode.Full && _toolsMenuHasItems",
"☰ must be gated by both Full mode and having at least one item, not either alone.");
}
[TestMethod]
public void RefreshToolbarLayout_FeedsAnyInDropdownIntoTheComposerMirror()
{
string body = ExtractMethodBody(ProviderManagementSource, "private void RefreshToolbarLayout()");
StringAssert.Contains(body, "ChatTranscript?.SetToolsMenuHasItems(anyInDropdown)",
"The composer's ☰ mirror must collapse under the same condition as the panel's own ☰.");
}
/// <summary>
/// The core ask: "duplicated buttons in the native window and in the claude code panel tab,
/// it should be only in the native window." Once the chat has actually left for its own tab,
/// every one of the panel's own promoted buttons/⚙/☰/⚡/🤖/⧉ must collapse — round 4 dropped
/// ⧉ from native mode's toolbar entirely (see <see cref="RefreshToolbarLayout_NeverShowsTheDetachButtonInNativeMode"/>),
/// so nothing needs to be spared from this loop anymore.
/// </summary>
[TestMethod]
public void RefreshToolbarLayout_CollapsesThePanelsOwnControlsOnceTheChatIsInItsOwnTab()
{
string body = ExtractMethodBody(ProviderManagementSource, "private void RefreshToolbarLayout()");
StringAssert.Contains(body, "if (IsChatDetachedToOwnTab)",
"The collapse must be gated on the chat having actually left the panel, not just native mode being on — ActionsOnly still needs the panel's own toolbar.");
StringAssert.DoesNotMatch(body, new System.Text.RegularExpressions.Regex("if \\(id == ToolbarButton\\.DetachTerminal\\) continue;"),
"DetachTerminal no longer needs sparing from this loop — it's unconditionally collapsed in native mode before this point.");
StringAssert.Contains(body, "if (ModelDropdownButton != null) ModelDropdownButton.Visibility = Visibility.Collapsed;");
StringAssert.Contains(body, "if (ToolsDropdownButton != null) ToolsDropdownButton.Visibility = Visibility.Collapsed;");
StringAssert.Contains(body, "if (CustomCommandsButton != null) CustomCommandsButton.Visibility = Visibility.Collapsed;");
StringAssert.Contains(body, "if (MenuDropdownButton != null) MenuDropdownButton.Visibility = Visibility.Collapsed;");
StringAssert.Contains(body, "if (AttachDropdownButton != null) AttachDropdownButton.Visibility = Visibility.Collapsed;",
"AttachDropdownButton lives in ControlsRow, not inside the auto-hidden PromptGroupBox, so it needs its own collapse.");
StringAssert.Contains(body, "if (SendPromptButton != null) SendPromptButton.Visibility = Visibility.Collapsed;",
"SendPromptButton lives in ControlsRow, not inside the auto-hidden PromptGroupBox, so it needs its own collapse.");
}
/// <summary>
/// The v170.0 collapse logic above only ever ran inside RefreshToolbarLayout, but nothing
/// called that method when the chat actually moved in or out of its own tab — so the panel
/// kept showing its full toolbar until some unrelated event happened to refresh it (the exact
/// duplication the reporter's second screenshot caught). Both transition points must trigger it.
/// </summary>
[TestMethod]
public void ChatTabTransitions_RefreshTheToolbarLayoutSoThePanelActuallyCollapses()
{
string showBody = ExtractMethodBody(NativeChatSource, "private async Task ShowNativeChatTabAsync(bool focusComposer)");
string returnBody = ExtractMethodBody(NativeChatSource, "private void ReturnNativeChatToPanel()");
StringAssert.Contains(showBody, "RefreshToolbarLayout();",
"Entering the chat tab must refresh the panel's toolbar so its own controls collapse.");
StringAssert.Contains(returnBody, "RefreshToolbarLayout();",
"Returning the chat to the panel must refresh the toolbar so the panel's controls reappear.");
}
/// <summary>
/// The "blank panel, only usage bars" regression: ShowNativeChatTabAsync collapsed the panel
/// (SetPanelTerminalAreaVisible(false) + RefreshToolbarLayout) and then called frame?.Show() on
/// a frame that could be null — a stale pane handed back by FindToolWindow after its tab was
/// torn down. The result was a collapsed panel wrapped around a tab that never appeared. The
/// method must now drop a stale window, verify it has a real frame before collapsing anything,
/// and fall back to the panel (never leave it collapsed with the chat nowhere) when it does not.
/// </summary>
[TestMethod]
public void ShowNativeChatTabAsync_FallsBackToThePanelWhenThereIsNoUsableTabFrame()
{
string showBody = ExtractMethodBody(NativeChatSource, "private async Task ShowNativeChatTabAsync(bool focusComposer)");
StringAssert.Contains(showBody, "!(_nativeChatWindow.Frame is IVsWindowFrame)",
"A cached window whose frame VS already tore down must be dropped, not reused into a null-frame no-op.");
StringAssert.Contains(showBody, "_nativeChatWindow == null || frame == null",
"The panel must not be collapsed until a real document-tab frame has been obtained.");
StringAssert.Contains(showBody, "EnsureNativeChatVisibleInPanel();",
"No usable frame must fall back to a working panel rather than a blank one.");
StringAssert.Contains(showBody, "ErrorHandler.Failed(showHr)",
"A frame that will not surface must trigger the same panel fallback.");
}
/// <summary>
/// The panel's steady state in native mode is "usage bars only", with the chat in its own
/// document tab beside it. Closing the chat tab docks the conversation into the panel; closing
/// and reopening the panel must then pull it back out into a tab. That restore must fire only
/// on a real panel close (FRAMESHOW_WinHidden then a show), never on a plain tab activation,
/// or a docked chat would jump to a tab every time the panel got focus.
/// </summary>
[TestMethod]
public void PanelFrameShow_RestoresTheNativeChatTab_OnlyAfterThePanelWasClosed()
{
string detachCs = RepositoryLayout.ReadText("Controls", "ClaudeCodeControl.Detach.cs");
string showBody = ExtractMethodBody(detachCs, "private void OnToolWindowFrameShow(object sender, int fShow)");
StringAssert.Contains(showBody, "__FRAMESHOW.FRAMESHOW_WinHidden",
"Closing the panel hides its frame — that is the signal a restore is due on the next show.");
StringAssert.Contains(showBody, "_panelHiddenSinceShow = true;",
"The hide must be latched so the following show can consume it.");
StringAssert.Contains(showBody, "if (activated && _panelHiddenSinceShow)",
"The reconcile must be gated on the panel having actually been closed, not on every activation.");
StringAssert.Contains(showBody, "ReconcileNativeChatHomeOnPanelShow();",
"The panel's frame-show handler must reconcile the native chat's home after a close.");
string reconcileBody = ExtractMethodBody(detachCs, "private void ReconcileNativeChatHomeOnPanelShow()");
StringAssert.Contains(reconcileBody, "if (liveTab)",
"A chat that already has a live tab is left alone; anything else is pulled into a tab.");
StringAssert.DoesNotMatch(reconcileBody, new System.Text.RegularExpressions.Regex(@"dockedInPanel"),
"A chat docked in the panel is no longer an accepted resting state on panel reopen — it must go back to its tab.");
StringAssert.Contains(reconcileBody, "await ShowNativeChatTabAsync(focusComposer: false);",
"A chat without a live tab must be put back into its own tab.");
}
/// <summary>
/// Closing the chat's document tab just closes it (issue #168): the conversation is released
/// from the dying pane but NOT docked into the panel, and the tab does not reopen itself. The
/// panel's Show Chat button (and a panel send / agent question) is what brings it back.
/// </summary>
[TestMethod]
public void OnNativeChatWindowClosed_ClosesTheTabWithoutDocking()
{
string body = ExtractMethodBody(NativeChatSource, "private void OnNativeChatWindowClosed(object sender, EventArgs e)");
StringAssert.Contains(body, "_nativeChatWindow.SetChatContent(null);",
"The transcript must be released from the closing pane so it can be re-hosted later.");
StringAssert.Contains(body, "_nativeChatWindow = null;",
"The dead pane reference must be dropped so the next show creates a fresh tab.");
StringAssert.DoesNotMatch(body, new System.Text.RegularExpressions.Regex(@"ReturnNativeChatToPanel|EnsureNativeChatVisibleInPanel"),
"Closing the tab must not dock the chat into the panel anymore.");
StringAssert.DoesNotMatch(body, new System.Text.RegularExpressions.Regex(@"reopenAsTab|_reopeningNativeChatTab|ShowNativeChatTabAsync"),
"The tab must not reopen itself on close.");
StringAssert.Contains(NativeChatSource, "private bool IsChatTabClosed => IsChatDetachedToOwnTab && _nativeChatWindow == null;",
"A closed tab is recognized by the chat still being detached while its pane is gone.");
StringAssert.Contains(NativeChatSource, "private void ReopenClosedChatTab()",
"Show Chat / panel send / agent question need a helper to reopen a closed tab.");
}
/// <summary>
/// Two other call sites unconditionally reset <c>SendPromptButton.Visibility</c> from the
/// <c>SendWithEnter</c> setting (initial load and the Settings dialog's Apply). Both must defer
/// to <c>IsChatDetachedToOwnTab</c> or a settings change while the chat is in its own tab would
/// bring the Send button back next to a prompt box that is no longer even shown.
/// </summary>
[TestMethod]
public void SendPromptButtonVisibility_DefersToChatDetachedState()
{
string settingsCs = RepositoryLayout.ReadText("Controls", "ClaudeCodeControl.Settings.cs");
string settingsDialogCs = RepositoryLayout.ReadText("Controls", "ClaudeCodeControl.SettingsDialog.cs");
StringAssert.Contains(settingsCs, "_settings != null && !IsChatDetachedToOwnTab");
StringAssert.Contains(settingsDialogCs, "if (!IsChatDetachedToOwnTab)");
}
/// <summary>
/// The action row (session actions, selectors, mirrored config buttons) must sit above the
/// prompt text box, not below it — the second screenshot showed it crammed at the very bottom
/// of the window, underneath the box it controls.
/// </summary>
[TestMethod]
public void ComposerBar_ActionRowSitsAboveTheInputBoxNotBelowIt()
{
string xaml = ChatTranscriptXaml;
// Grid.Row assignments decide visual order regardless of document order (WPF doesn't
// require rows to be declared in order), so read the row number each element actually
// declares rather than comparing where they appear in the file.
int resizeGripRow = GridRowOfNamedElement(xaml, "ComposerResizeGrip");
int attachmentsRow = GridRowOfNamedElement(xaml, "ComposerAttachments");
int inputBorderRow = GridRowOfNamedElement(xaml, "ComposerInputBorder");
// The action row is a Grid now (arrow buttons flanking the scrollable button strip), which
// is what carries the Grid.Row — not the DockPanel or ScrollViewer nested inside it.
int actionRowRow = GridRowOfNamedElement(xaml, "ComposerActionRow");
Assert.IsTrue(resizeGripRow >= 0 && attachmentsRow >= 0 && inputBorderRow >= 0 && actionRowRow >= 0,
"One of the composer's rows could not be found — update this guard with the rename.");
Assert.IsTrue(resizeGripRow < actionRowRow && actionRowRow < attachmentsRow && attachmentsRow < inputBorderRow,
"Expected row order: resize grip, then the action row, then attachment chips, then the text box.");
}
/// <summary>
/// Issue #151 round 7: "In terminal, the settings button is gone." Root cause — ControlsRow's
/// right-hand column held the whole RightButtonsPanel as "Auto" width, which never shrinks no
/// matter how narrow the tool window gets; the panel just rendered past the visible edge with
/// no scrollbar and no indication anything was hidden, and ⚙ (declared last, so pushed
/// furthest toward the edge) was the first casualty. The column must be Star so the row
/// actually has a bounded width to scroll within.
/// </summary>
[TestMethod]
public void ControlsRow_RightColumnIsStar_SoTheRightButtonsRowHasABoundedWidthToScrollWithin()
{
string xaml = PanelXaml;
int gridIdx = xaml.IndexOf("x:Name=\"ControlsRow\"", System.StringComparison.Ordinal);
Assert.IsTrue(gridIdx >= 0, "ControlsRow not found — update this guard with the rename.");
int colDefsIdx = xaml.IndexOf("<Grid.ColumnDefinitions>", gridIdx, System.StringComparison.Ordinal);
int colDefsEnd = xaml.IndexOf("</Grid.ColumnDefinitions>", colDefsIdx, System.StringComparison.Ordinal);
string colDefs = xaml.Substring(colDefsIdx, colDefsEnd - colDefsIdx);
int starIdx = colDefs.IndexOf("Width=\"*\"", System.StringComparison.Ordinal);
int lastAutoIdx = colDefs.LastIndexOf("Width=\"Auto\"", System.StringComparison.Ordinal);
Assert.IsTrue(starIdx >= 0 && starIdx > lastAutoIdx,
"The last (rightmost) column, which holds the right-buttons row, must be Star — an " +
"Auto column always grows to fit its content regardless of the tool window's width.");
}
/// <summary>
/// v177.0: the whole extension-panel toolbar is one horizontally-scrollable strip. Every
/// button — the customizable feature buttons AND the always-present controls (☰ Tools,
/// ⚡ Custom Commands, 🤖 Model, ⚙ Settings/Agent, 📎 Attach, ▶ Send) — lives inside
/// RightButtonsScroller, so the single ◀ / ▶ pair scrolls all of them. There is no separate
/// right-pinned FixedRightButtonsPanel anymore (that split was the source of the mid-row gap).
/// </summary>
[TestMethod]
public void RightButtonsPanel_HoldsTheEntirePanelToolbarInOneScroller()
{
string xaml = PanelXaml;
Assert.IsFalse(xaml.Contains("FixedRightButtonsPanel"),
"The right-pinned fixed group was folded into RightButtonsScroller in v177.0 — no element " +
"should reference FixedRightButtonsPanel anymore.");
int leftArrowIdx = xaml.IndexOf("x:Name=\"RightButtonsScrollLeftButton\"", System.StringComparison.Ordinal);
int scrollerOpen = xaml.IndexOf("x:Name=\"RightButtonsScroller\"", System.StringComparison.Ordinal);
int scrollerClose = xaml.IndexOf("</ScrollViewer>", scrollerOpen, System.StringComparison.Ordinal);
int rightArrowIdx = xaml.IndexOf("x:Name=\"RightButtonsScrollRightButton\"", System.StringComparison.Ordinal);
Assert.IsTrue(leftArrowIdx >= 0 && scrollerOpen >= 0 && scrollerClose > scrollerOpen && rightArrowIdx >= 0,
"One of the right-buttons row's elements could not be found — update this guard with the rename.");
foreach (string name in new[]
{
"MenuDropdownButton", "ModelDropdownButton", "ToolsDropdownButton", "CustomCommandsButton",
"AttachDropdownButton",
"UpdateAgentToolbarButton", "GenerateCommitMessageToolbarButton",
})
{
int idx = xaml.IndexOf($"x:Name=\"{name}\"", System.StringComparison.Ordinal);
Assert.IsTrue(idx > scrollerOpen && idx < scrollerClose,
$"{name} must live inside RightButtonsScroller so the single ◀/▶ pair scrolls the whole toolbar.");
}
Assert.IsTrue(leftArrowIdx < scrollerOpen && rightArrowIdx < scrollerOpen,
"Expected document order: ◀ arrow (docked left) and ▶ arrow (docked right) both before the " +
"scroller, which is the DockPanel's last child (the fill child).");
}
/// <summary>
/// Issue #151 round 9: the right-buttons row must be a DockPanel, not a Grid. A Grid splits its
/// width between Star columns proportionally regardless of what the content actually needs, which
/// arranged ⚙ past the tool window's visible right edge and handed the ScrollViewer a viewport
/// wider than the panel — so it never had anything to scroll and the ◀/▶ arrows never appeared.
/// A DockPanel measures each docked child against the space genuinely left over and gives the fill
/// child exactly the remainder, so the scroller's viewport is always the real available width.
/// </summary>
[TestMethod]
public void RightButtonsRow_IsADockPanel_SoTheScrollerViewportIsTheRealAvailableWidth()
{
string xaml = PanelXaml;
int rowIdx = xaml.IndexOf("x:Name=\"RightButtonsRow\"", System.StringComparison.Ordinal);
Assert.IsTrue(rowIdx >= 0, "RightButtonsRow not found.");
int openIdx = xaml.LastIndexOf("<", rowIdx, System.StringComparison.Ordinal);
int closeIdx = xaml.IndexOf(">", rowIdx, System.StringComparison.Ordinal);
string openTag = xaml.Substring(openIdx, closeIdx - openIdx);
StringAssert.StartsWith(openTag, "<DockPanel",
"RightButtonsRow must be a DockPanel — a Grid's Star columns consume their full proportional " +
"share whether the content needs it or not, which pushed ⚙ off screen (issue #151 round 9).");
StringAssert.Contains(openTag, "LastChildFill=\"True\"",
"The ScrollViewer is the last child and needs the leftover width; without LastChildFill it " +
"would only get its own desired size and would never scroll.");
int leftArrow = xaml.IndexOf("x:Name=\"RightButtonsScrollLeftButton\"", System.StringComparison.Ordinal);
Assert.IsTrue(leftArrow >= 0, "RightButtonsScrollLeftButton not found.");
StringAssert.Contains(xaml.Substring(leftArrow, 200), "DockPanel.Dock=\"Left\"",
"The ◀ arrow must be docked left.");
int rightArrow = xaml.IndexOf("x:Name=\"RightButtonsScrollRightButton\"", System.StringComparison.Ordinal);
Assert.IsTrue(rightArrow >= 0, "RightButtonsScrollRightButton not found.");
StringAssert.Contains(xaml.Substring(rightArrow, 200), "DockPanel.Dock=\"Right\"",
"The ▶ arrow must be docked right so it is laid out before the fill child and can never be " +
"pushed outside the panel.");
// The scroller must be the panel's last child, otherwise LastChildFill hands the leftover
// width to something else and the scroller falls back to its (unbounded) desired size.
int scrollerIdx = xaml.IndexOf("x:Name=\"RightButtonsScroller\"", System.StringComparison.Ordinal);
int scrollerEnd = xaml.IndexOf("</ScrollViewer>", scrollerIdx, System.StringComparison.Ordinal);
int rowEnd = xaml.IndexOf("</DockPanel>", scrollerEnd, System.StringComparison.Ordinal);
Assert.IsTrue(rowEnd > scrollerEnd, "RightButtonsRow's closing </DockPanel> not found.");
string afterScroller = xaml.Substring(scrollerEnd + "</ScrollViewer>".Length, rowEnd - scrollerEnd - "</ScrollViewer>".Length);
Assert.IsFalse(afterScroller.Contains("x:Name="),
"RightButtonsScroller must be the DockPanel's last child so LastChildFill gives it the " +
"remaining width (issue #151 round 9).");
}
[TestMethod]
public void RightButtonsScroller_HasArrowScrollHandlersMirroringTheComposer()
{
string cs = RepositoryLayout.ReadText("Controls", "ClaudeCodeControl.ProviderManagement.cs");
StringAssert.Contains(cs, "private void RightButtonsScrollLeftButton_Click(object sender, RoutedEventArgs e)");
StringAssert.Contains(cs, "private void RightButtonsScrollRightButton_Click(object sender, RoutedEventArgs e)");
StringAssert.Contains(cs, "private void RightButtonsScroller_ScrollChanged(object sender, ScrollChangedEventArgs e)");
string scrollChangedBody = ExtractMethodBody(cs,
"private void RightButtonsScroller_ScrollChanged(object sender, ScrollChangedEventArgs e)");
StringAssert.Contains(scrollChangedBody, "Visibility.Hidden",
"Arrow buttons must toggle Hidden, not Collapsed, so the row's own footprint never changes " +
"size when an arrow appears or disappears (same reasoning as the composer's action row, round 6).");
}
/// <summary>
/// v177.0: with every panel-toolbar button inside the one scroller, the scroller must be
/// Left-aligned so that when all buttons fit, the strip starts flush against the ◀ arrow and
/// any unused slack pools on the right edge — not as a gap between buttons. (Right alignment
/// only made sense while a separate fixed group was pinned to the right.)
/// </summary>
[TestMethod]
public void RightButtonsScroller_IsLeftAligned_SoTheStripStartsFlushWithNoMidRowGap()
{
string xaml = PanelXaml;
int scrollerIdx = xaml.IndexOf("x:Name=\"RightButtonsScroller\"", System.StringComparison.Ordinal);
int scrollerTagEnd = xaml.IndexOf(">", scrollerIdx, System.StringComparison.Ordinal);
Assert.IsTrue(scrollerIdx >= 0 && scrollerTagEnd > scrollerIdx, "RightButtonsScroller not found.");
string openTag = xaml.Substring(scrollerIdx, scrollerTagEnd - scrollerIdx);
StringAssert.Contains(openTag, "HorizontalAlignment=\"Left\"",
"The unified toolbar strip must start flush against the ◀ arrow; slack pools on the right, " +
"never as a gap between buttons.");
}
/// <summary>
/// The attach (📎) button (and the send ▶ one, until issue #186 moved it into the prompt box) used to sit in ControlsRow's column 0, pinned far left
/// while every other toolbar button hugged the right edge — so the Star column's unused width
/// showed as one wide gap down the middle of the toolbar. v177.0 folds them into the single
/// scrollable strip with every other button, so there is no mid-row split at all.
/// </summary>
[TestMethod]
public void AttachButton_LivesInTheScrollableStrip_SoTheToolbarHasNoMidRowGap()
{
string xaml = PanelXaml;
int scrollerOpen = xaml.IndexOf("x:Name=\"RightButtonsScroller\"", System.StringComparison.Ordinal);
int scrollerClose = xaml.IndexOf("</ScrollViewer>", scrollerOpen, System.StringComparison.Ordinal);
Assert.IsTrue(scrollerOpen >= 0 && scrollerClose > scrollerOpen, "RightButtonsScroller not found.");
foreach (string name in new[] { "AttachDropdownButton" })
{
int idx = xaml.IndexOf($"x:Name=\"{name}\"", System.StringComparison.Ordinal);
Assert.IsTrue(idx > scrollerOpen && idx < scrollerClose,
$"{name} must live inside RightButtonsScroller so the panel toolbar is one contiguous " +
"scrollable strip with no wide Star-column gap (v177.0).");
}
int controlsRowIdx = xaml.IndexOf("x:Name=\"ControlsRow\"", System.StringComparison.Ordinal);
int controlsRowEnd = xaml.IndexOf("x:Name=\"CheckboxRow\"", controlsRowIdx, System.StringComparison.Ordinal);
Assert.IsTrue(controlsRowIdx >= 0 && controlsRowEnd > controlsRowIdx, "ControlsRow / CheckboxRow not found.");
string controlsRow = xaml.Substring(controlsRowIdx, controlsRowEnd - controlsRowIdx);
StringAssert.DoesNotMatch(controlsRow, new System.Text.RegularExpressions.Regex("Grid\\.Column=\"0\""),
"ControlsRow no longer has a left-pinned column-0 cluster — that split was the mid-row gap.");
}
/// <summary>
/// Issue #186: the panel's ▶ Send button sits in the bottom-right corner of the prompt box (like
/// the chat composer's), not in the toolbar strip.
/// </summary>
[TestMethod]
public void SendPromptButton_LivesInsideThePromptBox_NotTheToolbar()
{
string xaml = PanelXaml;
int groupOpen = xaml.IndexOf("x:Name=\"PromptGroupBox\"", System.StringComparison.Ordinal);
int groupClose = xaml.IndexOf("</GroupBox>", groupOpen, System.StringComparison.Ordinal);
int idx = xaml.IndexOf("x:Name=\"SendPromptButton\"", System.StringComparison.Ordinal);
Assert.IsTrue(groupOpen >= 0 && idx > groupOpen && idx < groupClose,
"SendPromptButton must live inside PromptGroupBox, next to the text box it sends.");
}
/// <summary>
/// Issue #151 round 8: RefreshCustomCommandsButton() runs on its own (from
/// ApplyLoadedSettings and after the configure-commands dialog closes) and used to set
/// CustomCommandsButton.Visibility unconditionally from "hasAny" alone. If it ran while the
/// chat was already detached to its own tab, it silently undid RefreshToolbarLayout()'s
/// IsChatDetachedToOwnTab collapse, popping ⚡ back onto the panel toolbar on its own — with
/// nothing else in that row visible, it rendered as a single stray button floating near the
/// panel's title bar.
/// </summary>
[TestMethod]
public void RefreshCustomCommandsButton_RespectsChatDetachedToOwnTab()
{
string body = ExtractMethodBody(
RepositoryLayout.ReadText("Controls", "ClaudeCodeControl.CustomCommands.cs"),
"private void RefreshCustomCommandsButton()");
StringAssert.Contains(body, "hasAny && !IsChatDetachedToOwnTab",
"The panel's own ⚡ must stay collapsed while the chat is detached, even when this method " +
"runs independently of RefreshToolbarLayout — the composer's mirror is what should show instead.");
}
/// <summary>
/// Issue #151 round 9: collapsing every individual control inside ControlsRow and CheckboxRow was
/// not enough — the ◀/▶ arrows are Hidden (not Collapsed) by design, and the empty file-chips row
/// still contributes its margins, so both rows kept reserving height. With the prompt box also
/// auto-hidden in this state, that showed as a dead strip between the panel's title bar and the
/// usage bars, which are the only thing the panel still owns once the chat has its own tab.
/// </summary>
[TestMethod]
public void RefreshToolbarLayout_CollapsesTheWholeControlsAndCheckboxRows_WhenChatIsDetached()
{
string body = ExtractMethodBody(
RepositoryLayout.ReadText("Controls", "ClaudeCodeControl.ProviderManagement.cs"),
"private void RefreshToolbarLayout()");
StringAssert.Contains(body, "ControlsRow.Visibility = panelRows",
"ControlsRow itself must collapse when the chat owns its tab — its Hidden scroll arrows " +
"keep reserving height otherwise (issue #151 round 9).");
StringAssert.Contains(body, "CheckboxRow.Visibility = panelRows",
"CheckboxRow itself must collapse when the chat owns its tab — an empty file-chips row " +
"still contributes its own margins otherwise (issue #151 round 9).");
StringAssert.Contains(body, "IsChatDetachedToOwnTab ? Visibility.Collapsed : Visibility.Visible",
"Both rows must come back the moment the chat is docked back in the panel.");
}
/// <summary>
/// Issue #151 round 9: hiding whichever arrow had nowhere left to go meant the row usually showed
/// a single lone arrow, which reads as decoration rather than as "there is more over here" — the
/// reporter didn't recognize the row as scrollable at all. Both arrows now appear together as soon
/// as the row can scroll, with the dead direction greyed out instead of vanishing.
/// </summary>
[TestMethod]
public void RightButtonsScroller_ShowsBothArrowsTogether_AndGreysOutTheDeadDirection()
{
string body = ExtractMethodBody(
RepositoryLayout.ReadText("Controls", "ClaudeCodeControl.ProviderManagement.cs"),
"private void RightButtonsScroller_ScrollChanged(object sender, ScrollChangedEventArgs e)");
StringAssert.Contains(body, "RightButtonsScrollLeftButton.Visibility = arrows",
"Both arrows must share one visibility decision so neither ever appears alone.");
// Since #162 the right arrow may also collapse (right-aligned strip with nothing to scroll),
// but whenever it is shown it still takes the shared "arrows" decision.
StringAssert.Contains(body, "RightButtonsScrollRightButton.Visibility =");
StringAssert.Contains(body, ": arrows;");
StringAssert.Contains(body, "RightButtonsScrollLeftButton.IsEnabled",
"The direction with nowhere to go must be disabled rather than hidden.");
StringAssert.Contains(body, "RightButtonsScrollRightButton.IsEnabled");
}
/// <summary>
/// Issue #151 round 10: the IsChatDetachedToOwnTab block collapses ⚙, ⚡, 📎 and ▶ Send, but
/// unlike everything else it touches, nothing earlier in RefreshToolbarLayout recomputes those
/// four from scratch — so the collapse was one-way and they never came back. Switching native
/// mode off left the classic terminal panel permanently without its ⚙ Settings/Agent button,
/// with no other route to it. Every control collapsed in that branch must have a restoring
/// counterpart in the else-branch.
/// </summary>
[TestMethod]
public void RefreshToolbarLayout_RestoresTheDetachOnlyCollapses_WhenTheChatIsNotDetached()
{
string body = ExtractMethodBody(
RepositoryLayout.ReadText("Controls", "ClaudeCodeControl.ProviderManagement.cs"),
"private void RefreshToolbarLayout()");
int elseIdx = body.IndexOf("MenuDropdownButton.Visibility = Visibility.Visible", System.StringComparison.Ordinal);
Assert.IsTrue(elseIdx > 0,
"⚙ MenuDropdownButton has no other Visibility authority anywhere in the codebase — if " +
"RefreshToolbarLayout collapses it for the detached chat, it must also restore it here, " +
"or the button is gone for good (issue #151 round 10).");
StringAssert.Contains(body, "AttachDropdownButton.Visibility = Visibility.Visible",
"📎 Attach is collapsed with the prompt box in the detached state and has no other " +
"Visibility authority — it must be restored explicitly.");
StringAssert.Contains(body, "RefreshCustomCommandsButton()",
"⚡ must be re-evaluated (it hides when no custom commands exist), not forced Visible.");
StringAssert.Contains(body, "ShouldShowSendButton",
"▶ Send must be restored per its own send-key rule, not forced Visible.");
}
/// <summary>
/// Issue #151 round 11: an agent that will not run on the selected model is the one start
/// failure native mode cannot recover from on its own — the agent is healthy, so retrying lands
/// in the same place, and the fix (the 🤖 model menu) is hidden while native mode is active.
/// Both entry points must therefore hand the user back to the embedded terminal.