@bridge_gpt/mcp-server 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,13 @@ function safeJsonForScript(value) {
25
25
  return JSON.stringify(value).replace(/</g, "\\u003c");
26
26
  }
27
27
  // ---------------------------------------------------------------------------
28
+ // UI copy constants
29
+ // ---------------------------------------------------------------------------
30
+ const COPY_SUCCESS_LABEL = "Copied!";
31
+ const COPY_FALLBACK_PROMPT_LABEL = "Auto-copy unavailable. Press Ctrl+C / Cmd+C to copy.";
32
+ const PAGE_INTRO_ASK_COPY = "Have a question about an item? Choose 'Ask about this' for that card; we'll discuss before the changes are made.";
33
+ const PAGE_INTRO_NO_DECISIONS = "All suggestions were confirmed as improvements. No decisions are needed from you.";
34
+ // ---------------------------------------------------------------------------
28
35
  // Template
29
36
  // ---------------------------------------------------------------------------
30
37
  const DEFAULT_ASSETS = { faviconBase64: "", logoBase64: "", fontsRelPath: "" };
@@ -35,6 +42,7 @@ export function generateDecisionPageHtml(data, assets = DEFAULT_ASSETS) {
35
42
  ? `<link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,${assets.faviconBase64}">`
36
43
  : "";
37
44
  const fontFaces = assets.fontsRelPath ? renderFontFaces(assets.fontsRelPath) : "";
45
+ const pageIntro = hasDecisions ? PAGE_INTRO_ASK_COPY : PAGE_INTRO_NO_DECISIONS;
38
46
  return `<!DOCTYPE html>
39
47
  <html lang="en">
40
48
  <head>
@@ -143,14 +151,20 @@ ${fontFaces}
143
151
  font-weight: 600;
144
152
  margin: 0 0 0.5rem 0;
145
153
  }
146
- .card-context {
147
- background: #eef2ff;
148
- border-left: 4px solid var(--primary-color);
149
- padding: 0.75rem 1rem;
154
+ /* Card sub-sections (Original question, Why it matters, Why the AI recommended this) */
155
+ .card-section {
150
156
  margin-bottom: 1rem;
157
+ }
158
+ .card-section-label {
151
159
  font-size: 0.875rem;
152
- color: #374151;
153
- border-radius: 0 0.25rem 0.25rem 0;
160
+ font-weight: 600;
161
+ color: var(--secondary-color);
162
+ margin-bottom: 0.25rem;
163
+ text-transform: none;
164
+ }
165
+ .card-section-body {
166
+ font-size: 0.95rem;
167
+ color: var(--text-color);
154
168
  white-space: pre-wrap;
155
169
  }
156
170
 
@@ -158,8 +172,13 @@ ${fontFaces}
158
172
  .radio-group {
159
173
  display: flex;
160
174
  flex-direction: column;
161
- gap: 0.5rem;
162
- margin-bottom: 0.5rem;
175
+ gap: 0.75rem;
176
+ margin-bottom: 1rem;
177
+ }
178
+ .radio-option-wrapper {
179
+ display: flex;
180
+ flex-direction: column;
181
+ gap: 0.25rem;
163
182
  }
164
183
  .radio-option {
165
184
  display: flex;
@@ -176,9 +195,42 @@ ${fontFaces}
176
195
  font-weight: 400;
177
196
  margin-bottom: 0;
178
197
  }
198
+ .radio-option-consequence {
199
+ font-size: 0.9rem;
200
+ color: var(--secondary-color);
201
+ margin-left: 1.75rem;
202
+ white-space: pre-wrap;
203
+ }
179
204
  .ai-recommendation {
180
205
  color: var(--primary-color);
181
206
  font-weight: 600;
207
+ white-space: nowrap;
208
+ }
209
+
210
+ /* Codebase evidence disclosure */
211
+ .codebase-evidence {
212
+ margin-bottom: 1rem;
213
+ border: 1px solid var(--border-color);
214
+ border-radius: 0.25rem;
215
+ background: #fafafa;
216
+ }
217
+ .codebase-evidence summary {
218
+ cursor: pointer;
219
+ padding: 0.5rem 0.75rem;
220
+ font-size: 0.875rem;
221
+ font-weight: 600;
222
+ color: var(--secondary-color);
223
+ user-select: none;
224
+ }
225
+ .codebase-evidence summary:hover {
226
+ color: var(--text-color);
227
+ }
228
+ .codebase-evidence-body {
229
+ padding: 0.75rem 1rem;
230
+ border-top: 1px solid var(--border-color);
231
+ font-size: 0.875rem;
232
+ color: #374151;
233
+ white-space: pre-wrap;
182
234
  }
183
235
 
184
236
  /* Comment textareas */
@@ -356,7 +408,7 @@ ${renderHeader(assets)}
356
408
  <main class="main-content">
357
409
  <div class="container">
358
410
  <h1>Review Decisions: ${escapeHtml(ticket_key)}</h1>
359
- <p class="page-intro">The AI has analyzed this ticket and needs your input to finalize the review. Please make your selections below, then submit to generate the JSON output.</p>
411
+ <p class="page-intro">${escapeHtml(pageIntro)}</p>
360
412
 
361
413
  ${hasDecisions ? renderForm(data) : renderNoDecisions()}
362
414
 
@@ -371,6 +423,13 @@ ${hasDecisions ? renderScript(data) : ""}
371
423
  // ---------------------------------------------------------------------------
372
424
  // Font faces
373
425
  // ---------------------------------------------------------------------------
426
+ // fontsRelPath comes from server-controlled env vars, so this is not an
427
+ // injection vector — but a deploy path containing a single quote or backslash
428
+ // would silently break CSS parsing and stop fonts from loading. Escape both
429
+ // so the url('...') stays well-formed regardless of deploy layout.
430
+ function escapeCssUrl(value) {
431
+ return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
432
+ }
374
433
  function renderFontFaces(fontsRelPath) {
375
434
  const weights = [
376
435
  { weight: "400", style: "normal", file: "SourceSansPro-Regular.ttf" },
@@ -380,9 +439,10 @@ function renderFontFaces(fontsRelPath) {
380
439
  { weight: "700", style: "normal", file: "SourceSansPro-Bold.ttf" },
381
440
  { weight: "700", style: "italic", file: "SourceSansPro-BoldItalic.ttf" },
382
441
  ];
442
+ const safePath = escapeCssUrl(fontsRelPath);
383
443
  return weights.map(({ weight, style, file }) => ` @font-face {
384
444
  font-family: 'Source Sans Pro';
385
- src: url('${fontsRelPath}/${file}') format('truetype');
445
+ src: url('${safePath}/${escapeCssUrl(file)}') format('truetype');
386
446
  font-weight: ${weight};
387
447
  font-style: ${style};
388
448
  }`).join("\n");
@@ -420,11 +480,11 @@ function renderForm(data) {
420
480
  html += `
421
481
  <div class="general-comment-area">
422
482
  <label for="general-comment">General Comment (optional)</label>
423
- <textarea id="general-comment" name="general_comment" placeholder="Any overall feedback or guidance..."></textarea>
483
+ <textarea id="general-comment" name="general_comment" placeholder="Any overall feedback or guidance..." data-testid="general-comment"></textarea>
424
484
  </div>
425
485
 
426
486
  <div class="submit-area">
427
- <button type="button" id="submit-btn" class="btn btn-primary">Submit Decisions</button>
487
+ <button type="button" id="submit-btn" class="btn btn-primary" data-testid="submit-decisions">Submit Decisions</button>
428
488
  </div>
429
489
  </form>
430
490
  </div>
@@ -435,7 +495,7 @@ function renderForm(data) {
435
495
  <button type="button" class="btn btn-secondary" data-copy-json-btn data-default-label="Copy JSON">Copy JSON</button>
436
496
  </div>
437
497
  <p>Copy the JSON below and paste it back to the agent.</p>
438
- <pre id="json-output"></pre>
498
+ <pre id="json-output" data-testid="decision-output-json"></pre>
439
499
  <div class="copy-area">
440
500
  <button type="button" id="copy-btn" class="btn btn-secondary" data-copy-json-btn data-default-label="Copy to Clipboard">Copy to Clipboard</button>
441
501
  </div>
@@ -446,39 +506,74 @@ function renderDecisionItem(item) {
446
506
  const id = escapeHtml(item.id);
447
507
  const radioName = `radio-${id}`;
448
508
  const textareaId = `comment-${id}`;
509
+ const titleId = `card-title-${id}`;
510
+ const originalQuestionLabelId = `original-question-label-${id}`;
511
+ const whyItMattersLabelId = `why-it-matters-label-${id}`;
512
+ const radioGroupLabelId = `radio-group-label-${id}`;
513
+ const recommendationLabelId = `recommendation-label-${id}`;
449
514
  let optionsHtml = "";
450
515
  for (let i = 0; i < item.options.length; i++) {
451
516
  const val = `opt-${i}`;
452
517
  const label = item.options[i];
518
+ const consequence = item.option_consequences[i] ?? "";
453
519
  const isRecommended = i === item.recommendation_index;
454
520
  const checked = isRecommended ? " checked" : "";
455
- const labelClass = isRecommended ? ' class="ai-recommendation"' : "";
456
- const labelText = escapeHtml(label) + (isRecommended ? " (AI recommendation)" : "");
521
+ const labelText = escapeHtml(label);
522
+ const recommendationMarker = isRecommended
523
+ ? ` <span class="ai-recommendation" data-testid="ai-recommendation-marker">(AI recommended)</span>`
524
+ : "";
457
525
  optionsHtml += `
458
- <div class="radio-option">
459
- <input type="radio" id="${radioName}-${val}" name="${radioName}" value="${val}"${checked}>
460
- <label for="${radioName}-${val}"${labelClass}>${labelText}</label>
526
+ <div class="radio-option-wrapper" data-testid="option-wrapper" data-option-index="${i}">
527
+ <div class="radio-option">
528
+ <input type="radio" id="${radioName}-${val}" name="${radioName}" value="${val}"${checked} data-testid="decision-option">
529
+ <label for="${radioName}-${val}">${labelText}</label>${recommendationMarker}
530
+ </div>
531
+ <div class="radio-option-consequence" data-testid="option-consequence">${escapeHtml(consequence)}</div>
461
532
  </div>`;
462
533
  }
463
- // Auto-append "None of these"
534
+ // Auto-append "Ask about this" then "None of these" — neither has a consequence line.
464
535
  optionsHtml += `
465
- <div class="radio-option">
466
- <input type="radio" id="${radioName}-none" name="${radioName}" value="none">
467
- <label for="${radioName}-none">None of these</label>
536
+ <div class="radio-option-wrapper" data-testid="option-wrapper" data-option-index="ask">
537
+ <div class="radio-option">
538
+ <input type="radio" id="${radioName}-ask" name="${radioName}" value="ask" data-testid="decision-option">
539
+ <label for="${radioName}-ask">Ask about this</label>
540
+ </div>
541
+ </div>
542
+ <div class="radio-option-wrapper" data-testid="option-wrapper" data-option-index="none">
543
+ <div class="radio-option">
544
+ <input type="radio" id="${radioName}-none" name="${radioName}" value="none" data-testid="decision-option">
545
+ <label for="${radioName}-none">None of these</label>
546
+ </div>
468
547
  </div>`;
469
548
  // Encode option labels for client-side JS to resolve chosen_label
470
549
  const labelsJson = escapeHtml(JSON.stringify(item.options));
471
550
  return `
472
- <div class="card" data-item-id="${id}" data-item-type="decision" data-source="${escapeHtml(item.source)}" data-labels="${labelsJson}">
473
- <div class="card-title">${escapeHtml(item.question)}</div>
474
- <div class="card-context">${escapeHtml(item.context)}</div>
475
- <div class="radio-group">${optionsHtml}
476
- </div>
551
+ <section class="card" data-item-id="${id}" data-item-type="decision" data-source="${escapeHtml(item.source)}" data-labels="${labelsJson}" data-testid="decision-card" aria-labelledby="${titleId}">
552
+ <h3 class="card-title" id="${titleId}" data-testid="decision-card-title">${escapeHtml(item.question)}</h3>
553
+ <section class="card-section" data-testid="original-question-section" aria-labelledby="${originalQuestionLabelId}">
554
+ <div class="card-section-label" id="${originalQuestionLabelId}">Original question</div>
555
+ <div class="card-section-body" data-testid="original-question-body">${escapeHtml(item.original_question)}</div>
556
+ </section>
557
+ <section class="card-section" data-testid="why-it-matters-section" aria-labelledby="${whyItMattersLabelId}">
558
+ <div class="card-section-label" id="${whyItMattersLabelId}">Why it matters</div>
559
+ <div class="card-section-body" data-testid="why-it-matters-body">${escapeHtml(item.why_it_matters)}</div>
560
+ </section>
561
+ <section class="radio-group" role="radiogroup" data-testid="decision-radio-group" aria-labelledby="${radioGroupLabelId}">
562
+ <div class="card-section-label" id="${radioGroupLabelId}">Choose an option</div>${optionsHtml}
563
+ </section>
564
+ <section class="card-section" data-testid="recommendation-explanation-section" aria-labelledby="${recommendationLabelId}">
565
+ <div class="card-section-label" id="${recommendationLabelId}">Why the AI recommended this</div>
566
+ <div class="card-section-body" data-testid="recommendation-explanation-body">${escapeHtml(item.recommendation_explanation)}</div>
567
+ </section>
477
568
  <div class="comment-area">
478
569
  <label for="${textareaId}">Comment</label>
479
- <textarea id="${textareaId}" name="${textareaId}" placeholder="Required for None of these selections..."></textarea>
570
+ <textarea id="${textareaId}" name="${textareaId}" placeholder="Required for None of these selections..." data-testid="decision-comment"></textarea>
480
571
  </div>
481
- </div>`;
572
+ <details class="codebase-evidence" data-testid="codebase-evidence">
573
+ <summary>Codebase evidence</summary>
574
+ <div class="codebase-evidence-body" data-testid="codebase-evidence-body">${escapeHtml(item.codebase_evidence)}</div>
575
+ </details>
576
+ </section>`;
482
577
  }
483
578
  function renderImprovements(improvements) {
484
579
  if (improvements.length === 0)
@@ -491,16 +586,18 @@ function renderImprovements(improvements) {
491
586
  ? "confidence-medium"
492
587
  : "confidence-low";
493
588
  items += `
494
- <li>
589
+ <li data-testid="confirmed-improvement-item">
495
590
  <span class="improvement-title">${escapeHtml(imp.title)}</span>
496
591
  <span class="confidence-tag ${confClass}">${escapeHtml(imp.confidence)}</span>
497
592
  <div class="improvement-action">${escapeHtml(imp.action)}</div>
498
593
  </li>`;
499
594
  }
500
- return ` <h2>Confirmed Improvements</h2>
595
+ return ` <section data-testid="confirmed-improvements">
596
+ <h2>Confirmed Improvements</h2>
501
597
  <p>These improvements have been confirmed and will be applied. No action needed from you.</p>
502
598
  <ul class="improvements-list">${items}
503
- </ul>`;
599
+ </ul>
600
+ </section>`;
504
601
  }
505
602
  // ---------------------------------------------------------------------------
506
603
  // Embedded script
@@ -508,92 +605,102 @@ function renderImprovements(improvements) {
508
605
  function renderScript(data) {
509
606
  return ` <script>
510
607
  (function() {
511
- var submitBtn = document.getElementById("submit-btn");
512
- var copyButtons = Array.prototype.slice.call(document.querySelectorAll("[data-copy-json-btn]"));
513
- var formContainer = document.getElementById("form-container");
514
- var postSubmitContainer = document.getElementById("post-submit-container");
515
- var jsonOutput = document.getElementById("json-output");
516
-
517
- submitBtn.addEventListener("click", function() {
518
- var cards = document.querySelectorAll(".card[data-item-id]");
519
- var valid = true;
520
-
521
- // Validate: every card must have a selection, and "None of these" requires a comment.
522
- cards.forEach(function(card) {
523
- var itemId = card.getAttribute("data-item-id");
524
- var selected = card.querySelector('input[type="radio"]:checked');
525
- var textarea = document.getElementById("comment-" + itemId);
526
- var existingMsg = card.querySelector(".validation-msg");
527
- if (existingMsg) existingMsg.remove();
528
- textarea.classList.remove("validation-error");
529
-
530
- if (!selected) {
531
- var radioGroup = card.querySelector(".radio-group");
532
- var msg = document.createElement("div");
533
- msg.className = "validation-msg";
534
- msg.textContent = "Please select an option.";
535
- radioGroup.appendChild(msg);
536
- valid = false;
537
- return;
538
- }
539
-
540
- if (selected.value === "none" && !textarea.value.trim()) {
541
- textarea.classList.add("validation-error");
542
- var msg = document.createElement("div");
543
- msg.className = "validation-msg";
544
- msg.textContent = "A comment is required when selecting None of these.";
545
- textarea.parentNode.appendChild(msg);
546
- valid = false;
547
- }
548
- });
608
+ var submitBtn;
609
+ var copyButtons;
610
+ var formContainer;
611
+ var postSubmitContainer;
612
+ var jsonOutput;
549
613
 
550
- if (!valid) return;
551
-
552
- // Build output JSON
553
- var decisions = {};
554
- cards.forEach(function(card) {
555
- var itemId = card.getAttribute("data-item-id");
556
- var selected = card.querySelector('input[type="radio"]:checked');
557
- var textarea = document.getElementById("comment-" + itemId);
558
- var source = card.getAttribute("data-source") || "";
559
- var labels = JSON.parse(card.getAttribute("data-labels") || "[]");
560
- var chosenLabel = "";
561
- if (selected.value === "none") {
562
- chosenLabel = "None of these";
563
- } else {
564
- var idx = parseInt(selected.value.replace("opt-", ""), 10);
565
- chosenLabel = labels[idx] || "";
566
- }
567
- decisions[itemId] = {
568
- choice: selected.value,
569
- chosen_label: chosenLabel,
570
- comment: textarea ? textarea.value : "",
571
- source: source
572
- };
573
- });
614
+ function init() {
615
+ submitBtn = document.getElementById("submit-btn");
616
+ copyButtons = Array.prototype.slice.call(document.querySelectorAll("[data-copy-json-btn]"));
617
+ formContainer = document.getElementById("form-container");
618
+ postSubmitContainer = document.getElementById("post-submit-container");
619
+ jsonOutput = document.getElementById("json-output");
574
620
 
575
- var output = {
576
- ticket_key: ${safeJsonForScript(data.ticket_key)},
577
- decisions: decisions,
578
- general_comment: document.getElementById("general-comment").value
579
- };
621
+ submitBtn.addEventListener("click", function() {
622
+ var cards = document.querySelectorAll(".card[data-item-id]");
623
+ var valid = true;
580
624
 
581
- jsonOutput.textContent = JSON.stringify(output, null, 2);
582
- formContainer.classList.add("hidden");
583
- postSubmitContainer.classList.remove("hidden");
584
- });
625
+ // Validate: every card must have a selection, and "None of these" requires a comment.
626
+ cards.forEach(function(card) {
627
+ var itemId = card.getAttribute("data-item-id");
628
+ var selected = card.querySelector('input[type="radio"]:checked');
629
+ var textarea = document.getElementById("comment-" + itemId);
630
+ var existingMsg = card.querySelector(".validation-msg");
631
+ if (existingMsg) existingMsg.remove();
632
+ textarea.classList.remove("validation-error");
585
633
 
586
- copyButtons.forEach(function(copyBtn) {
587
- copyBtn.addEventListener("click", function() {
588
- copyJson();
634
+ if (!selected) {
635
+ var radioGroup = card.querySelector(".radio-group");
636
+ var msg = document.createElement("div");
637
+ msg.className = "validation-msg";
638
+ msg.textContent = "Please select an option.";
639
+ radioGroup.appendChild(msg);
640
+ valid = false;
641
+ return;
642
+ }
643
+
644
+ if (selected.value === "none" && !textarea.value.trim()) {
645
+ textarea.classList.add("validation-error");
646
+ var msg = document.createElement("div");
647
+ msg.className = "validation-msg";
648
+ msg.textContent = "A comment is required when selecting None of these.";
649
+ textarea.parentNode.appendChild(msg);
650
+ valid = false;
651
+ }
652
+ });
653
+
654
+ if (!valid) return;
655
+
656
+ // Build output JSON
657
+ var decisions = {};
658
+ cards.forEach(function(card) {
659
+ var itemId = card.getAttribute("data-item-id");
660
+ var selected = card.querySelector('input[type="radio"]:checked');
661
+ var textarea = document.getElementById("comment-" + itemId);
662
+ var source = card.getAttribute("data-source") || "";
663
+ var labels = JSON.parse(card.getAttribute("data-labels") || "[]");
664
+ var chosenLabel = "";
665
+ if (selected.value === "none") {
666
+ chosenLabel = "None of these";
667
+ } else if (selected.value === "ask") {
668
+ chosenLabel = "Ask about this";
669
+ } else {
670
+ var idx = parseInt(selected.value.replace("opt-", ""), 10);
671
+ chosenLabel = labels[idx] || "";
672
+ }
673
+ decisions[itemId] = {
674
+ choice: selected.value,
675
+ chosen_label: chosenLabel,
676
+ comment: textarea ? textarea.value : "",
677
+ source: source
678
+ };
679
+ });
680
+
681
+ var output = {
682
+ ticket_key: ${safeJsonForScript(data.ticket_key)},
683
+ decisions: decisions,
684
+ general_comment: document.getElementById("general-comment").value
685
+ };
686
+
687
+ jsonOutput.textContent = JSON.stringify(output, null, 2);
688
+ formContainer.classList.add("hidden");
689
+ postSubmitContainer.classList.remove("hidden");
589
690
  });
590
- });
691
+
692
+ copyButtons.forEach(function(copyBtn) {
693
+ copyBtn.addEventListener("click", function() {
694
+ copyJson();
695
+ });
696
+ });
697
+ }
591
698
 
592
699
  function copyJson() {
593
700
  var text = jsonOutput.textContent;
594
701
  try {
595
702
  navigator.clipboard.writeText(text).then(function() {
596
- setCopyButtonLabel("Copied!");
703
+ setCopyButtonLabel(${safeJsonForScript(COPY_SUCCESS_LABEL)});
597
704
  setTimeout(resetCopyButtonLabels, 2000);
598
705
  }).catch(function() {
599
706
  selectAndPrompt();
@@ -621,7 +728,13 @@ function renderScript(data) {
621
728
  var sel = window.getSelection();
622
729
  sel.removeAllRanges();
623
730
  sel.addRange(range);
624
- setCopyButtonLabel("Auto-copy unavailable. Press Ctrl+C / Cmd+C to copy.");
731
+ setCopyButtonLabel(${safeJsonForScript(COPY_FALLBACK_PROMPT_LABEL)});
732
+ }
733
+
734
+ if (document.readyState !== "loading") {
735
+ init();
736
+ } else {
737
+ document.addEventListener("DOMContentLoaded", init);
625
738
  }
626
739
  })();
627
740
  </script>`;