| 1 | #nullable enable |
| 2 | using System.Text.Json; |
| 3 | using CascadeIDE.Models.AgentChat; |
| 4 | using CascadeIDE.Services; |
| 5 | using CommunityToolkit.Mvvm.ComponentModel; |
| 6 | |
| 7 | namespace CascadeIDE.Features.Chat; |
| 8 | |
| 9 | public partial class ChatPanelViewModel |
| 10 | { |
| 11 | private bool _suppressProductSpinePersistence; |
| 12 | |
| 13 | [ObservableProperty] |
| 14 | private string _productSpineLineTitle = ""; |
| 15 | |
| 16 | [ObservableProperty] |
| 17 | private string _productSpineCurrentFocus = ""; |
| 18 | |
| 19 | [ObservableProperty] |
| 20 | private string _productSpineMilestonesText = ""; |
| 21 | |
| 22 | [ObservableProperty] |
| 23 | private bool _includeProductSpineInAgentContext; |
| 24 | |
| 25 | partial void OnProductSpineLineTitleChanged(string value) => OnProductSpineFieldChanged(); |
| 26 | |
| 27 | partial void OnProductSpineCurrentFocusChanged(string value) => OnProductSpineFieldChanged(); |
| 28 | |
| 29 | partial void OnProductSpineMilestonesTextChanged(string value) => OnProductSpineFieldChanged(); |
| 30 | |
| 31 | partial void OnIncludeProductSpineInAgentContextChanged(bool value) => OnProductSpineFieldChanged(); |
| 32 | |
| 33 | public string ToggleProductSpineInAgentContext() |
| 34 | { |
| 35 | IncludeProductSpineInAgentContext = !IncludeProductSpineInAgentContext; |
| 36 | return IncludeProductSpineInAgentContext ? "ProductSpineInAgentContext=on" : "ProductSpineInAgentContext=off"; |
| 37 | } |
| 38 | |
| 39 | public string GetProductSpineJson() |
| 40 | { |
| 41 | var spine = BuildProductSpine(); |
| 42 | return JsonSerializer.Serialize(new |
| 43 | { |
| 44 | line_title = spine.LineTitle, |
| 45 | current_focus = spine.CurrentFocus, |
| 46 | milestones = spine.Milestones, |
| 47 | include_in_agent_context = spine.IncludeInAgentContext, |
| 48 | has_content = spine.HasContent |
| 49 | }, ChatPanelJson); |
| 50 | } |
| 51 | |
| 52 | public string SetProductSpineFromMcp(IReadOnlyDictionary<string, JsonElement>? args) |
| 53 | { |
| 54 | if (args is null || args.Count == 0) |
| 55 | return "No spine fields provided"; |
| 56 | |
| 57 | var hasAny = false; |
| 58 | _suppressProductSpinePersistence = true; |
| 59 | try |
| 60 | { |
| 61 | if (args.ContainsKey("line_title")) |
| 62 | { |
| 63 | ProductSpineLineTitle = McpCommandJsonArgs.String(args, "line_title") ?? ""; |
| 64 | hasAny = true; |
| 65 | } |
| 66 | |
| 67 | if (args.ContainsKey("current_focus")) |
| 68 | { |
| 69 | ProductSpineCurrentFocus = McpCommandJsonArgs.String(args, "current_focus") ?? ""; |
| 70 | hasAny = true; |
| 71 | } |
| 72 | |
| 73 | if (args.ContainsKey("milestones")) |
| 74 | { |
| 75 | ProductSpineMilestonesText = McpCommandJsonArgs.String(args, "milestones") ?? ""; |
| 76 | hasAny = true; |
| 77 | } |
| 78 | |
| 79 | if (McpCommandJsonArgs.OptionalBool(args, "include_in_agent_context") is { } include) |
| 80 | { |
| 81 | IncludeProductSpineInAgentContext = include; |
| 82 | hasAny = true; |
| 83 | } |
| 84 | } |
| 85 | finally |
| 86 | { |
| 87 | _suppressProductSpinePersistence = false; |
| 88 | } |
| 89 | |
| 90 | if (!hasAny) |
| 91 | return "No spine fields provided"; |
| 92 | |
| 93 | _ = PersistProductSpineMetadataAsync(); |
| 94 | RefreshChatSurfaceSnapshot(); |
| 95 | return "OK"; |
| 96 | } |
| 97 | |
| 98 | private ChatProductSpine BuildProductSpine() => |
| 99 | new( |
| 100 | ProductSpineLineTitle.Trim(), |
| 101 | ProductSpineCurrentFocus.Trim(), |
| 102 | ChatProductSpine.ParseMilestonesText(ProductSpineMilestonesText), |
| 103 | IncludeProductSpineInAgentContext); |
| 104 | |
| 105 | private string ApplyProductSpineToOutboundMessage(string input) |
| 106 | { |
| 107 | var prefix = BuildProductSpine().BuildAgentContextPrefix(); |
| 108 | return prefix is null ? input : prefix + Environment.NewLine + input; |
| 109 | } |
| 110 | |
| 111 | private void OnProductSpineFieldChanged() |
| 112 | { |
| 113 | RefreshChatSurfaceSnapshot(); |
| 114 | if (!_suppressProductSpinePersistence) |
| 115 | _ = PersistProductSpineMetadataAsync(); |
| 116 | } |
| 117 | |
| 118 | private void ApplyProductSpineFromMetadata(ChatSessionMetadata meta) |
| 119 | { |
| 120 | _suppressProductSpinePersistence = true; |
| 121 | try |
| 122 | { |
| 123 | ProductSpineLineTitle = string.IsNullOrWhiteSpace(meta.ProductSpineLineTitle) |
| 124 | ? ResolveDefaultProductSpineLineTitle() |
| 125 | : meta.ProductSpineLineTitle.Trim(); |
| 126 | ProductSpineCurrentFocus = meta.ProductSpineCurrentFocus?.Trim() ?? ""; |
| 127 | ProductSpineMilestonesText = meta.ProductSpineMilestones?.Trim() ?? ""; |
| 128 | IncludeProductSpineInAgentContext = meta.ProductSpineIncludeInAgentContext; |
| 129 | } |
| 130 | finally |
| 131 | { |
| 132 | _suppressProductSpinePersistence = false; |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | private string ResolveDefaultProductSpineLineTitle() |
| 137 | { |
| 138 | var root = _getWorkspaceRoot().Trim(); |
| 139 | if (string.IsNullOrEmpty(root)) |
| 140 | return ChatProductSpinePresentation.DefaultLineTitle; |
| 141 | try |
| 142 | { |
| 143 | return new DirectoryInfo(root).Name; |
| 144 | } |
| 145 | catch |
| 146 | { |
| 147 | return ChatProductSpinePresentation.DefaultLineTitle; |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | private async Task PersistProductSpineMetadataAsync() |
| 152 | { |
| 153 | try |
| 154 | { |
| 155 | var meta = await _sessionStore.LoadOrCreateMetadataAsync(_sessionId, CancellationToken.None).ConfigureAwait(false); |
| 156 | var updated = meta with |
| 157 | { |
| 158 | UpdatedAtUtc = DateTimeOffset.UtcNow, |
| 159 | ProductSpineLineTitle = string.IsNullOrWhiteSpace(ProductSpineLineTitle) ? null : ProductSpineLineTitle.Trim(), |
| 160 | ProductSpineCurrentFocus = string.IsNullOrWhiteSpace(ProductSpineCurrentFocus) ? null : ProductSpineCurrentFocus.Trim(), |
| 161 | ProductSpineMilestones = string.IsNullOrWhiteSpace(ProductSpineMilestonesText) ? null : ProductSpineMilestonesText.Trim(), |
| 162 | ProductSpineIncludeInAgentContext = IncludeProductSpineInAgentContext |
| 163 | }; |
| 164 | await _sessionStore.SaveMetadataAsync(updated, CancellationToken.None).ConfigureAwait(false); |
| 165 | } |
| 166 | catch |
| 167 | { |
| 168 | // best-effort |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | |