| 1 | #nullable enable |
| 2 | using CascadeIDE.Features.Chat; |
| 3 | using CascadeIDE.Models; |
| 4 | using CascadeIDE.Models.Intercom; |
| 5 | using CascadeIDE.Services; |
| 6 | |
| 7 | namespace CascadeIDE.Views.Chat.Skia; |
| 8 | |
| 9 | /// <summary>Creator: snapshot → список <see cref="ISkiaChatEntity"/> (ADR entity pipeline).</summary> |
| 10 | internal static class ChatSurfaceEntityFactory |
| 11 | { |
| 12 | public static IReadOnlyList<ISkiaChatEntity> Build( |
| 13 | ChatSurfaceSnapshot snapshot, |
| 14 | bool overviewMode, |
| 15 | Guid detailThreadId, |
| 16 | bool forwardHost = false, |
| 17 | IntercomFontsSettings? intercomFonts = null, |
| 18 | bool compactSideHost = false) |
| 19 | { |
| 20 | var entities = new List<ISkiaChatEntity>(); |
| 21 | var hideNavChromeInFeed = forwardHost || compactSideHost; |
| 22 | |
| 23 | if (!hideNavChromeInFeed) |
| 24 | AppendSpine(entities, snapshot.ProductSpine, overviewMode, forwardHost, intercomFonts); |
| 25 | |
| 26 | if (snapshot.Layout.Overview.Count > 0) |
| 27 | { |
| 28 | AppendOverview(entities, snapshot, overviewMode, detailThreadId, forwardHost, hideNavChromeInFeed); |
| 29 | if (overviewMode) |
| 30 | return entities; |
| 31 | } |
| 32 | |
| 33 | if (!overviewMode && snapshot.TopicPicker != TopicPickerPresentation.None) |
| 34 | { |
| 35 | var counts = ChatThreadPresentation.MessageCountsByThread(snapshot); |
| 36 | entities.Add(new SkiaChatTopicPickerEntity( |
| 37 | snapshot.TopicPicker, |
| 38 | snapshot.State.Threads, |
| 39 | counts)); |
| 40 | } |
| 41 | |
| 42 | AppendDetailLanes(entities, snapshot, detailThreadId, forwardHost, hideNavChromeInFeed, intercomFonts); |
| 43 | return entities; |
| 44 | } |
| 45 | |
| 46 | private static void AppendSpine( |
| 47 | List<ISkiaChatEntity> entities, |
| 48 | ChatProductSpine spine, |
| 49 | bool overviewMode, |
| 50 | bool forwardHost, |
| 51 | IntercomFontsSettings? intercomFonts) |
| 52 | { |
| 53 | if (!spine.HasContent) |
| 54 | return; |
| 55 | |
| 56 | var title = ChatProductSpinePresentation.ResolveLineTitle(spine); |
| 57 | var cardTitleHeight = (intercomFonts ?? IntercomFontDefaults.Intercom).ResolveCardTitleLineHeight(forwardHost); |
| 58 | if (overviewMode) |
| 59 | { |
| 60 | entities.Add(SkiaChatTopicCard.ForSpine( |
| 61 | title, |
| 62 | spine.CurrentFocus, |
| 63 | spine.Milestones, |
| 64 | spine.IncludeInAgentContext)); |
| 65 | return; |
| 66 | } |
| 67 | |
| 68 | entities.Add(new SkiaChatBubbleEntity( |
| 69 | D( |
| 70 | new SkiaChatBubbleSpec( |
| 71 | "Spine · " + title, |
| 72 | ChatProductSpinePresentation.FormatDetailStripFocus(spine.CurrentFocus), |
| 73 | Footer: null, |
| 74 | SkiaChatBubbleKind.SpineStrip, |
| 75 | SkiaBubbleFillRole.SpineStrip, |
| 76 | SkiaChatBodyTone.Normal, |
| 77 | IsPending: false, |
| 78 | IsSelected: false, |
| 79 | StartsBranch: false, |
| 80 | MessageIndex: null, |
| 81 | MinHeight: 44, |
| 82 | MaxBodyLines: 1, |
| 83 | GapAfter: 6, |
| 84 | Padding: 8, |
| 85 | TitleHeight: cardTitleHeight, |
| 86 | LineHeight: 15), |
| 87 | forwardHost), |
| 88 | static () => new SkiaChatHit(null, null, ResetDetailMode: true))); |
| 89 | } |
| 90 | |
| 91 | private static void AppendOverview( |
| 92 | List<ISkiaChatEntity> entities, |
| 93 | ChatSurfaceSnapshot snapshot, |
| 94 | bool overviewMode, |
| 95 | Guid detailThreadId, |
| 96 | bool forwardHost, |
| 97 | bool hideNavChromeInFeed) |
| 98 | { |
| 99 | if (!overviewMode && !hideNavChromeInFeed) |
| 100 | entities.Add(OverviewBackLink(forwardHost)); |
| 101 | |
| 102 | foreach (var item in snapshot.Layout.Overview) |
| 103 | { |
| 104 | if (overviewMode) |
| 105 | { |
| 106 | entities.Add(SkiaChatTopicCard.ForThread( |
| 107 | ChatThreadOverviewPresentation.FormatTopicTitle(item), |
| 108 | item.Summary, |
| 109 | ChatThreadOverviewPresentation.FormatTopicBadges(item), |
| 110 | detailThreadId == item.ThreadId, |
| 111 | item.ThreadId)); |
| 112 | continue; |
| 113 | } |
| 114 | |
| 115 | if (hideNavChromeInFeed) |
| 116 | continue; |
| 117 | |
| 118 | var fillRole = detailThreadId == item.ThreadId |
| 119 | ? SkiaBubbleFillRole.ThreadRowActive |
| 120 | : SkiaBubbleFillRole.ThreadRow; |
| 121 | entities.Add(new SkiaChatBubbleEntity( |
| 122 | D( |
| 123 | new SkiaChatBubbleSpec( |
| 124 | ChatThreadOverviewPresentation.FormatSideThreadRowTitle(item), |
| 125 | ChatThreadOverviewPresentation.FormatSideThreadRowMeta(item), |
| 126 | Footer: null, |
| 127 | SkiaChatBubbleKind.Feed, |
| 128 | fillRole, |
| 129 | SkiaChatBodyTone.Normal, |
| 130 | IsPending: false, |
| 131 | IsSelected: false, |
| 132 | StartsBranch: false, |
| 133 | MessageIndex: null), |
| 134 | forwardHost), |
| 135 | () => new SkiaChatHit(null, item.ThreadId, ResetDetailMode: false))); |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | private static SkiaChatBubbleEntity OverviewBackLink(bool forwardHost) => |
| 140 | new( |
| 141 | D( |
| 142 | new SkiaChatBubbleSpec( |
| 143 | "Темы", |
| 144 | "Назад к обзору всех веток", |
| 145 | Footer: null, |
| 146 | SkiaChatBubbleKind.Feed, |
| 147 | SkiaBubbleFillRole.OverviewNav, |
| 148 | SkiaChatBodyTone.Normal, |
| 149 | IsPending: false, |
| 150 | IsSelected: false, |
| 151 | StartsBranch: false, |
| 152 | MessageIndex: null), |
| 153 | forwardHost), |
| 154 | static () => new SkiaChatHit(null, null, ResetDetailMode: true)); |
| 155 | |
| 156 | private static void AppendDetailLanes( |
| 157 | List<ISkiaChatEntity> entities, |
| 158 | ChatSurfaceSnapshot snapshot, |
| 159 | Guid detailThreadId, |
| 160 | bool forwardHost, |
| 161 | bool hideNavChromeInFeed, |
| 162 | IntercomFontsSettings? intercomFonts) |
| 163 | { |
| 164 | var cardTitleHeight = (intercomFonts ?? IntercomFontDefaults.Intercom).ResolveCardTitleLineHeight(forwardHost); |
| 165 | var lanes = snapshot.Layout.Lanes.OrderBy(lane => lane.Thread.Order); |
| 166 | if (detailThreadId != Guid.Empty) |
| 167 | lanes = lanes.Where(lane => lane.Thread.ThreadId == detailThreadId).OrderBy(lane => lane.Thread.Order); |
| 168 | |
| 169 | foreach (var lane in lanes) |
| 170 | { |
| 171 | if (!hideNavChromeInFeed) |
| 172 | { |
| 173 | var headerRole = lane.Thread.IsActive |
| 174 | ? SkiaBubbleFillRole.ThreadHeaderActive |
| 175 | : SkiaBubbleFillRole.ThreadHeader; |
| 176 | entities.Add(new SkiaChatBubbleEntity( |
| 177 | D( |
| 178 | new SkiaChatBubbleSpec( |
| 179 | lane.Thread.Title, |
| 180 | ChatThreadOverviewPresentation.FormatThreadHeaderMeta(lane.Thread), |
| 181 | Footer: null, |
| 182 | SkiaChatBubbleKind.Feed, |
| 183 | headerRole, |
| 184 | SkiaChatBodyTone.Normal, |
| 185 | IsPending: false, |
| 186 | IsSelected: false, |
| 187 | StartsBranch: false, |
| 188 | MessageIndex: null, |
| 189 | TitleHeight: cardTitleHeight), |
| 190 | forwardHost), |
| 191 | () => new SkiaChatHit(null, lane.Thread.ThreadId, ResetDetailMode: false))); |
| 192 | } |
| 193 | |
| 194 | ChatMessageVisualRole? previousMessageRole = null; |
| 195 | var feedOrdinal = 0; |
| 196 | var showFeedGutter = detailThreadId != Guid.Empty; |
| 197 | foreach (var entry in lane.Entries) |
| 198 | { |
| 199 | if (entry.Kind == ChatSurfaceEntryKind.Message) |
| 200 | { |
| 201 | if (showFeedGutter) |
| 202 | feedOrdinal++; |
| 203 | var suppressTitle = previousMessageRole == entry.VisualRole |
| 204 | && !entry.StartsBranch |
| 205 | && entry.VisualRole is ChatMessageVisualRole.User |
| 206 | or ChatMessageVisualRole.Assistant |
| 207 | or ChatMessageVisualRole.Thinking; |
| 208 | var gapAfter = suppressTitle |
| 209 | ? forwardHost ? 3f : 6f |
| 210 | : forwardHost ? 6f : 16f; |
| 211 | entities.Add(MessageEntity( |
| 212 | entry, |
| 213 | forwardHost, |
| 214 | suppressTitle, |
| 215 | gapAfter, |
| 216 | showFeedGutter ? feedOrdinal : 0, |
| 217 | intercomFonts)); |
| 218 | previousMessageRole = entry.VisualRole; |
| 219 | continue; |
| 220 | } |
| 221 | |
| 222 | if (entry.Kind is ChatSurfaceEntryKind.Confirmation or ChatSurfaceEntryKind.SedmCard) |
| 223 | { |
| 224 | entities.Add(ConfirmationEntity(entry, forwardHost)); |
| 225 | previousMessageRole = null; |
| 226 | continue; |
| 227 | } |
| 228 | } |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | private static SkiaChatBubbleSpec D(in SkiaChatBubbleSpec spec, bool forwardHost) => |
| 233 | SkiaChatDensity.Apply(spec, forwardHost); |
| 234 | |
| 235 | private static ISkiaChatEntity MessageEntity( |
| 236 | ChatSurfaceEntry entry, |
| 237 | bool forwardHost, |
| 238 | bool suppressTitle, |
| 239 | float gapAfter, |
| 240 | int feedOrdinal = 0, |
| 241 | IntercomFontsSettings? intercomFonts = null) |
| 242 | { |
| 243 | if (!string.IsNullOrWhiteSpace(entry.SlashCommandPath)) |
| 244 | return new SkiaChatSlashCommandEntity(entry, forwardHost, feedOrdinal, intercomFonts); |
| 245 | |
| 246 | return new SkiaChatMessageFeedEntity( |
| 247 | entry, |
| 248 | forwardHost, |
| 249 | suppressTitle, |
| 250 | gapAfter, |
| 251 | feedOrdinal, |
| 252 | intercomFonts); |
| 253 | } |
| 254 | |
| 255 | private static SkiaChatBubbleEntity ConfirmationEntity(ChatSurfaceEntry entry, bool forwardHost) |
| 256 | { |
| 257 | var fillRole = SkiaBubbleFillRoleMapping.FromMessageRole(entry.VisualRole); |
| 258 | return new SkiaChatBubbleEntity( |
| 259 | D( |
| 260 | new SkiaChatBubbleSpec( |
| 261 | entry.Title, |
| 262 | entry.Body, |
| 263 | Footer: null, |
| 264 | SkiaChatBubbleKind.Feed, |
| 265 | fillRole, |
| 266 | SkiaChatBodyTone.Normal, |
| 267 | IsPending: entry.IsPending, |
| 268 | entry.IsSelected, |
| 269 | StartsBranch: false, |
| 270 | MessageIndex: null, |
| 271 | GapAfter: 5, |
| 272 | Padding: 0, |
| 273 | TitleHeight: 14, |
| 274 | LineHeight: 14), |
| 275 | forwardHost), |
| 276 | static () => new SkiaChatHit(null, null, ResetDetailMode: false)); |
| 277 | } |
| 278 | } |
| 279 | |