tenseleyflow/shithub / 09fc23c

Browse files

Align PR comment composer with GitHub

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
09fc23c6fef261efd0f9c966fad84abbcf1cad98
Parents
bb6c35c
Tree
0ba710a

8 changed files

StatusFile+-
M docs/internal/pr-review.md 8 0
A internal/web/handlers/repo/comment_editor.go 182 0
A internal/web/handlers/repo/comment_editor_test.go 35 0
M internal/web/handlers/repo/pulls.go 38 0
M internal/web/static/css/shithub.css 340 0
A internal/web/static/js/comment-editor.js 509 0
M internal/web/templates/_layout.html 1 0
M internal/web/templates/repo/pull_view.html 75 10
docs/internal/pr-review.mdmodified
@@ -81,6 +81,8 @@ without starting a review" path) is `pending=false` from the start,
8181
 | `POST /{owner}/{repo}/pulls/{n}/reviews`                          | RequireUser  |
8282
 | `POST /{owner}/{repo}/pulls/{n}/reviews/{rid}/dismiss`            | RequireUser  |
8383
 | `POST /{owner}/{repo}/pulls/{n}/reviewers`                        | RequireUser  |
84
+| `GET /{owner}/{repo}/pulls/{n}.diff`                              | Public read  |
85
+| `GET /{owner}/{repo}/pulls/{n}.patch`                             | Public read  |
8486
 
8587
 The settings/branches handler now also accepts
8688
 `required_review_count` and `dismiss_stale_reviews_on_push`.
@@ -91,6 +93,12 @@ the viewer. Merge and close controls are similarly driven by
9193
 `pull:merge` and `pull:close` decisions. Public viewers who can read a
9294
 PR should not see forms that only lead to 403s.
9395
 
96
+The Conversation tab uses the same markdown renderer as repo web-edit
97
+previews for comment preview. The composer suggestion payload is
98
+server-rendered from known PR participants, assignees, reviewers, and
99
+recent same-repo issues/PRs. Copilot users or actions are intentionally
100
+not included.
101
+
94102
 ## Required-review gate
95103
 
96104
 `internal/pulls/review/required.go::Evaluate` is the authoritative
internal/web/handlers/repo/comment_editor.goadded
@@ -0,0 +1,182 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"context"
7
+	"encoding/json"
8
+	"html/template"
9
+	"net/url"
10
+	"sort"
11
+	"strings"
12
+
13
+	"github.com/jackc/pgx/v5/pgtype"
14
+
15
+	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
16
+	pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
17
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
18
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
19
+)
20
+
21
+type commentEditorConfig struct {
22
+	Mentions   []commentEditorMention   `json:"mentions"`
23
+	References []commentEditorReference `json:"references"`
24
+}
25
+
26
+type commentEditorMention struct {
27
+	Username    string `json:"username"`
28
+	DisplayName string `json:"displayName,omitempty"`
29
+	AvatarURL   string `json:"avatarUrl,omitempty"`
30
+}
31
+
32
+type commentEditorReference struct {
33
+	Number int64  `json:"number"`
34
+	Title  string `json:"title"`
35
+	Kind   string `json:"kind"`
36
+	State  string `json:"state"`
37
+}
38
+
39
+func commentEditorConfigJSON(config commentEditorConfig) template.JS {
40
+	body, err := json.Marshal(config)
41
+	if err != nil {
42
+		return template.JS(`{"mentions":[],"references":[]}`) //nolint:gosec // constant fallback
43
+	}
44
+	return template.JS(body) //nolint:gosec // json.Marshal escapes script-breaking characters
45
+}
46
+
47
+func commentEditorAvatarURL(username string) string {
48
+	if strings.TrimSpace(username) == "" {
49
+		return ""
50
+	}
51
+	return "/avatars/" + url.PathEscape(username)
52
+}
53
+
54
+func (h *Handlers) pullCommentEditorConfig(
55
+	ctx context.Context,
56
+	row reposdb.Repo,
57
+	pr pullsdb.GetPullRequestByRepoAndNumberRow,
58
+	viewer middleware.CurrentUser,
59
+	comments []issuesdb.IssueComment,
60
+	assignees []issuesdb.ListIssueAssigneesRow,
61
+	reviews []pullsdb.PrReview,
62
+	requests []pullsdb.PrReviewRequest,
63
+) commentEditorConfig {
64
+	config := commentEditorConfig{}
65
+	mentions := map[string]commentEditorMention{}
66
+
67
+	addUserID := func(id int64) {
68
+		if id == 0 {
69
+			return
70
+		}
71
+		u, err := h.uq.GetUserByID(ctx, h.d.Pool, id)
72
+		if err != nil || strings.EqualFold(u.Username, "copilot") {
73
+			return
74
+		}
75
+		mentions[strings.ToLower(u.Username)] = commentEditorMention{
76
+			Username:    u.Username,
77
+			DisplayName: u.DisplayName,
78
+			AvatarURL:   commentEditorAvatarURL(u.Username),
79
+		}
80
+	}
81
+	addUsername := func(username, displayName string) {
82
+		username = strings.TrimSpace(username)
83
+		if username == "" || strings.EqualFold(username, "copilot") {
84
+			return
85
+		}
86
+		key := strings.ToLower(username)
87
+		if _, ok := mentions[key]; ok {
88
+			return
89
+		}
90
+		mentions[key] = commentEditorMention{
91
+			Username:    username,
92
+			DisplayName: displayName,
93
+			AvatarURL:   commentEditorAvatarURL(username),
94
+		}
95
+	}
96
+
97
+	addUsername(viewer.Username, "")
98
+	if pr.IAuthorUserID.Valid {
99
+		addUserID(pr.IAuthorUserID.Int64)
100
+	}
101
+	if pr.MergedByUserID.Valid {
102
+		addUserID(pr.MergedByUserID.Int64)
103
+	}
104
+	for _, comment := range comments {
105
+		if comment.AuthorUserID.Valid {
106
+			addUserID(comment.AuthorUserID.Int64)
107
+		}
108
+	}
109
+	for _, assignee := range assignees {
110
+		addUsername(assignee.Username, assignee.DisplayName)
111
+	}
112
+	for _, review := range reviews {
113
+		if review.AuthorUserID.Valid {
114
+			addUserID(review.AuthorUserID.Int64)
115
+		}
116
+	}
117
+	for _, request := range requests {
118
+		if request.RequestedUserID.Valid {
119
+			addUserID(request.RequestedUserID.Int64)
120
+		}
121
+		if request.RequestedByUserID.Valid {
122
+			addUserID(request.RequestedByUserID.Int64)
123
+		}
124
+	}
125
+
126
+	config.Mentions = make([]commentEditorMention, 0, len(mentions))
127
+	for _, mention := range mentions {
128
+		config.Mentions = append(config.Mentions, mention)
129
+	}
130
+	sort.SliceStable(config.Mentions, func(i, j int) bool {
131
+		if strings.EqualFold(config.Mentions[i].Username, viewer.Username) {
132
+			return true
133
+		}
134
+		if strings.EqualFold(config.Mentions[j].Username, viewer.Username) {
135
+			return false
136
+		}
137
+		return strings.ToLower(config.Mentions[i].Username) < strings.ToLower(config.Mentions[j].Username)
138
+	})
139
+
140
+	seenRefs := map[int64]struct{}{}
141
+	addRef := func(number int64, title, kind, state string) {
142
+		if number == 0 {
143
+			return
144
+		}
145
+		if _, ok := seenRefs[number]; ok {
146
+			return
147
+		}
148
+		seenRefs[number] = struct{}{}
149
+		config.References = append(config.References, commentEditorReference{
150
+			Number: number,
151
+			Title:  title,
152
+			Kind:   kind,
153
+			State:  state,
154
+		})
155
+	}
156
+	addRef(pr.INumber, pr.ITitle, "pull request", string(pr.IState))
157
+	if prs, err := h.pq.ListPullRequestsByRepo(ctx, h.d.Pool, pullsdb.ListPullRequestsByRepoParams{
158
+		RepoID:      row.ID,
159
+		Limit:       8,
160
+		StateFilter: pgtype.Text{},
161
+		Draft:       pgtype.Bool{},
162
+	}); err == nil {
163
+		for _, item := range prs {
164
+			addRef(item.Number, item.Title, "pull request", string(item.State))
165
+		}
166
+	}
167
+	if issues, err := h.iq.ListIssues(ctx, h.d.Pool, issuesdb.ListIssuesParams{
168
+		RepoID:      row.ID,
169
+		Limit:       8,
170
+		StateFilter: pgtype.Text{},
171
+		Kind:        issuesdb.NullIssueKind{IssueKind: issuesdb.IssueKindIssue, Valid: true},
172
+	}); err == nil {
173
+		for _, item := range issues {
174
+			addRef(item.Number, item.Title, "issue", string(item.State))
175
+		}
176
+	}
177
+	if len(config.References) > 10 {
178
+		config.References = config.References[:10]
179
+	}
180
+
181
+	return config
182
+}
internal/web/handlers/repo/comment_editor_test.goadded
@@ -0,0 +1,35 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"strings"
7
+	"testing"
8
+)
9
+
10
+func TestCommentEditorConfigJSONEscapesScriptBreakout(t *testing.T) {
11
+	t.Parallel()
12
+
13
+	got := string(commentEditorConfigJSON(commentEditorConfig{
14
+		Mentions: []commentEditorMention{{
15
+			Username:    "alice",
16
+			DisplayName: `</script><script>alert(1)</script>`,
17
+		}},
18
+	}))
19
+
20
+	if strings.Contains(got, "</script>") {
21
+		t.Fatalf("config JSON contains raw script terminator: %s", got)
22
+	}
23
+	if !strings.Contains(got, `\u003c/script\u003e`) {
24
+		t.Fatalf("config JSON did not preserve escaped display name: %s", got)
25
+	}
26
+}
27
+
28
+func TestCommentEditorAvatarURLPathEscapesUsername(t *testing.T) {
29
+	t.Parallel()
30
+
31
+	got := commentEditorAvatarURL("team/user")
32
+	if got != "/avatars/team%2Fuser" {
33
+		t.Fatalf("avatar URL = %q, want escaped path segment", got)
34
+	}
35
+}
internal/web/handlers/repo/pulls.gomodified
@@ -84,6 +84,8 @@ type pullCommitGroup struct {
8484
 // KindPRMerge in the audit remediation sprint.)
8585
 func (h *Handlers) MountPulls(r chi.Router) {
8686
 	r.Get("/{owner}/{repo}/pulls", h.pullsList)
87
+	r.Get("/{owner}/{repo}/pulls/{number}.diff", h.pullRawDiff)
88
+	r.Get("/{owner}/{repo}/pulls/{number}.patch", h.pullRawDiff)
8789
 	r.Get("/{owner}/{repo}/pulls/{number}", h.pullView)
8890
 	r.Get("/{owner}/{repo}/pulls/{number}/files", h.pullFiles)
8991
 	r.Get("/{owner}/{repo}/pulls/{number}/commits", h.pullCommits)
@@ -537,6 +539,9 @@ func (h *Handlers) pullView(w http.ResponseWriter, r *http.Request) {
537539
 		"CanEditIssueAssignees": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueAssign, repoRef).Allow,
538540
 		"CanEditIssueMilestone": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueLabel, repoRef).Allow,
539541
 		"CanLockIssue":          policy.Can(r.Context(), pdeps, actor, policy.ActionIssueClose, repoRef).Allow,
542
+		"UseCommentEditor":      true,
543
+		"ViewerAvatarURL":       commentEditorAvatarURL(viewer.Username),
544
+		"CommentEditorConfig":   commentEditorConfigJSON(h.pullCommentEditorConfig(r.Context(), row, pr, viewer, comments, assignees, reviews, requests)),
540545
 	})
541546
 }
542547
 
@@ -701,6 +706,39 @@ func pullFileAnchor(p string) string {
701706
 	return b.String()
702707
 }
703708
 
709
+func (h *Handlers) pullRawDiff(w http.ResponseWriter, r *http.Request) {
710
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullRead)
711
+	if !ok {
712
+		return
713
+	}
714
+	pr, ok := h.loadPullByNumber(w, r, row.ID)
715
+	if !ok {
716
+		return
717
+	}
718
+	if pr.BaseOid == "" || pr.HeadOid == "" {
719
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
720
+		return
721
+	}
722
+	gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
723
+	if err != nil {
724
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
725
+		return
726
+	}
727
+	patch, err := compareSourceMergeBase(r, gitDir, pr.BaseOid, pr.HeadOid)
728
+	if err != nil {
729
+		h.d.Logger.WarnContext(r.Context(), "pulls: raw diff", "error", err, "repo_id", row.ID, "pr", pr.INumber)
730
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
731
+		return
732
+	}
733
+	ext := ".diff"
734
+	if strings.HasSuffix(r.URL.Path, ".patch") {
735
+		ext = ".patch"
736
+	}
737
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
738
+	w.Header().Set("Content-Disposition", "inline; filename=\""+row.Name+"-"+strconv.FormatInt(pr.INumber, 10)+ext+"\"")
739
+	_, _ = w.Write(patch)
740
+}
741
+
704742
 // pullChecks renders the Checks tab. Loads suites + runs grouped by
705743
 // suite for the PR's head_oid, plus the markdown-rendered output.summary
706744
 // for each run.
internal/web/static/css/shithub.cssmodified
@@ -8371,6 +8371,346 @@ button.shithub-repo-action {
83718371
   padding: 0.4rem; border: 1px solid var(--border-default); border-radius: 6px; font: inherit;
83728372
 }
83738373
 .shithub-pull-comment-form { margin-top: 1.25rem; }
8374
+.shithub-comment-composer-row {
8375
+  display: grid;
8376
+  grid-template-columns: 40px minmax(0, 1fr);
8377
+  gap: 1rem;
8378
+  margin-top: 1.25rem;
8379
+}
8380
+.shithub-comment-composer-avatar,
8381
+.shithub-comment-composer-avatar img {
8382
+  display: block;
8383
+  width: 40px;
8384
+  height: 40px;
8385
+  border-radius: 50%;
8386
+}
8387
+.shithub-comment-composer-avatar img {
8388
+  border: 1px solid var(--border-muted);
8389
+  background: var(--canvas-subtle);
8390
+}
8391
+.shithub-comment-composer-form {
8392
+  min-width: 0;
8393
+  margin-top: 0;
8394
+}
8395
+.shithub-comment-composer-title {
8396
+  display: block;
8397
+  margin: 0 0 0.5rem;
8398
+  color: var(--fg-default);
8399
+  font-weight: 600;
8400
+}
8401
+.shithub-comment-editor-box {
8402
+  position: relative;
8403
+  border: 1px solid var(--border-default);
8404
+  border-radius: 6px;
8405
+  background: var(--canvas-default);
8406
+}
8407
+.shithub-comment-editor-head {
8408
+  display: flex;
8409
+  align-items: center;
8410
+  justify-content: space-between;
8411
+  gap: 0.75rem;
8412
+  min-height: 44px;
8413
+  border-bottom: 1px solid var(--border-default);
8414
+  background: var(--canvas-subtle);
8415
+}
8416
+.shithub-comment-editor-tabs {
8417
+  display: flex;
8418
+  align-self: stretch;
8419
+}
8420
+.shithub-comment-editor-tabs button {
8421
+  min-width: 68px;
8422
+  padding: 0 1rem;
8423
+  border: 0;
8424
+  border-right: 1px solid var(--border-default);
8425
+  color: var(--fg-muted);
8426
+  background: transparent;
8427
+  cursor: pointer;
8428
+  font: inherit;
8429
+  font-weight: 500;
8430
+}
8431
+.shithub-comment-editor-tabs button.is-active {
8432
+  margin-bottom: -1px;
8433
+  color: var(--fg-default);
8434
+  background: var(--canvas-default);
8435
+}
8436
+.shithub-comment-toolbar {
8437
+  display: flex;
8438
+  align-items: center;
8439
+  justify-content: flex-end;
8440
+  gap: 0.15rem;
8441
+  min-width: 0;
8442
+  padding: 0.35rem 0.45rem;
8443
+  color: var(--fg-muted);
8444
+  flex-wrap: wrap;
8445
+}
8446
+.shithub-comment-tool {
8447
+  position: relative;
8448
+  display: inline-flex;
8449
+  align-items: center;
8450
+  justify-content: center;
8451
+  width: 30px;
8452
+  height: 30px;
8453
+  padding: 0;
8454
+  border: 0;
8455
+  border-radius: 6px;
8456
+  color: var(--fg-muted);
8457
+  background: transparent;
8458
+  cursor: pointer;
8459
+  font: inherit;
8460
+}
8461
+.shithub-comment-tool:hover,
8462
+.shithub-comment-tool:focus-visible,
8463
+.shithub-comment-tool.is-active {
8464
+  color: var(--fg-default);
8465
+  background: var(--button-default-hover-bg);
8466
+  outline: none;
8467
+}
8468
+.shithub-comment-tool input[type="file"] {
8469
+  position: absolute;
8470
+  inset: 0;
8471
+  width: 100%;
8472
+  height: 100%;
8473
+  opacity: 0;
8474
+  cursor: pointer;
8475
+}
8476
+.shithub-comment-tool-text {
8477
+  font-weight: 700;
8478
+  font-size: 0.9rem;
8479
+}
8480
+.shithub-comment-tool-text.is-italic {
8481
+  font-style: italic;
8482
+}
8483
+.shithub-comment-toolbar-separator {
8484
+  width: 1px;
8485
+  height: 20px;
8486
+  margin: 0 0.25rem;
8487
+  background: var(--border-default);
8488
+}
8489
+.shithub-comment-editor-write {
8490
+  position: relative;
8491
+}
8492
+.shithub-comment-editor-write textarea {
8493
+  display: block;
8494
+  width: 100%;
8495
+  min-height: 120px;
8496
+  padding: 0.8rem;
8497
+  border: 0;
8498
+  border-radius: 0;
8499
+  resize: vertical;
8500
+  background: var(--canvas-default);
8501
+  color: var(--fg-default);
8502
+  font: inherit;
8503
+  line-height: 1.5;
8504
+}
8505
+.shithub-comment-editor-write textarea:focus {
8506
+  box-shadow: inset 0 0 0 2px var(--accent-emphasis);
8507
+  outline: none;
8508
+}
8509
+.shithub-comment-editor-preview {
8510
+  min-height: 120px;
8511
+  padding: 0.8rem;
8512
+}
8513
+.shithub-comment-editor-footer {
8514
+  display: flex;
8515
+  align-items: center;
8516
+  flex-wrap: wrap;
8517
+  gap: 0.75rem;
8518
+  min-height: 36px;
8519
+  padding: 0.45rem 0.75rem;
8520
+  border-top: 1px solid var(--border-default);
8521
+  border-radius: 0 0 6px 6px;
8522
+  color: var(--fg-muted);
8523
+  background: var(--canvas-subtle);
8524
+  font-size: 0.78rem;
8525
+  font-weight: 600;
8526
+}
8527
+.shithub-comment-editor-footer span {
8528
+  display: inline-flex;
8529
+  align-items: center;
8530
+  gap: 0.3rem;
8531
+}
8532
+.shithub-comment-file-list {
8533
+  max-width: 100%;
8534
+  overflow: hidden;
8535
+  text-overflow: ellipsis;
8536
+  white-space: nowrap;
8537
+}
8538
+.shithub-comment-policy-note,
8539
+.shithub-comment-protip {
8540
+  margin: 0.75rem 0 0;
8541
+  color: var(--fg-muted);
8542
+  font-size: 0.8rem;
8543
+}
8544
+.shithub-comment-policy-note svg,
8545
+.shithub-comment-protip svg {
8546
+  vertical-align: text-bottom;
8547
+}
8548
+.shithub-comment-suggestions {
8549
+  position: absolute;
8550
+  z-index: 60;
8551
+  left: 0.75rem;
8552
+  top: 2.8rem;
8553
+  width: min(22rem, calc(100% - 1.5rem));
8554
+  max-height: 18rem;
8555
+  overflow: auto;
8556
+  border: 1px solid var(--border-default);
8557
+  border-radius: 6px;
8558
+  background: var(--canvas-overlay, var(--canvas-default));
8559
+  box-shadow: 0 8px 24px rgba(1, 4, 9, 0.35);
8560
+}
8561
+.shithub-comment-suggestions.is-slash {
8562
+  width: min(20rem, calc(100% - 1.5rem));
8563
+}
8564
+.shithub-comment-suggestion-section {
8565
+  padding: 0.45rem 0.55rem;
8566
+  border-bottom: 1px solid var(--border-default);
8567
+  color: var(--fg-muted);
8568
+  font-size: 0.75rem;
8569
+  font-weight: 600;
8570
+}
8571
+.shithub-comment-suggestion-item {
8572
+  display: grid;
8573
+  grid-template-columns: 24px minmax(0, 1fr);
8574
+  gap: 0.5rem;
8575
+  align-items: center;
8576
+  width: 100%;
8577
+  min-height: 36px;
8578
+  padding: 0.35rem 0.55rem;
8579
+  border: 0;
8580
+  border-bottom: 1px solid var(--border-muted);
8581
+  color: var(--fg-default);
8582
+  background: transparent;
8583
+  cursor: pointer;
8584
+  font: inherit;
8585
+  text-align: left;
8586
+}
8587
+.shithub-comment-suggestion-item:last-child {
8588
+  border-bottom: 0;
8589
+}
8590
+.shithub-comment-suggestion-item:hover,
8591
+.shithub-comment-suggestion-item.is-active {
8592
+  color: #fff;
8593
+  background: var(--accent-emphasis, #0969da);
8594
+}
8595
+.shithub-comment-suggestion-item img {
8596
+  width: 20px;
8597
+  height: 20px;
8598
+  border-radius: 50%;
8599
+}
8600
+.shithub-comment-suggestion-item strong,
8601
+.shithub-comment-suggestion-item span {
8602
+  overflow: hidden;
8603
+  text-overflow: ellipsis;
8604
+  white-space: nowrap;
8605
+}
8606
+.shithub-comment-suggestion-item small {
8607
+  color: inherit;
8608
+  opacity: 0.8;
8609
+}
8610
+.shithub-comment-suggestion-copy {
8611
+  min-width: 0;
8612
+  display: flex;
8613
+  flex-direction: column;
8614
+  gap: 0.1rem;
8615
+}
8616
+.shithub-comment-saved-dialog {
8617
+  width: min(32rem, calc(100vw - 2rem));
8618
+  padding: 0;
8619
+  border: 1px solid var(--border-default);
8620
+  border-radius: 8px;
8621
+  color: var(--fg-default);
8622
+  background: var(--canvas-overlay, var(--canvas-default));
8623
+  box-shadow: 0 16px 48px rgba(1, 4, 9, 0.45);
8624
+}
8625
+.shithub-comment-saved-dialog::backdrop {
8626
+  background: rgba(1, 4, 9, 0.45);
8627
+}
8628
+.shithub-comment-saved-head {
8629
+  display: flex;
8630
+  align-items: center;
8631
+  justify-content: space-between;
8632
+  padding: 0.75rem 0.9rem;
8633
+}
8634
+.shithub-comment-saved-dialog input[type="search"] {
8635
+  width: calc(100% - 1.5rem);
8636
+  margin: 0 0.75rem 0.75rem;
8637
+  padding: 0.45rem 0.6rem;
8638
+  border-radius: 6px;
8639
+}
8640
+.shithub-comment-saved-item,
8641
+.shithub-comment-saved-create {
8642
+  width: 100%;
8643
+  border: 0;
8644
+  border-top: 1px solid var(--border-default);
8645
+  color: var(--fg-default);
8646
+  background: transparent;
8647
+  cursor: pointer;
8648
+  font: inherit;
8649
+  text-align: left;
8650
+}
8651
+.shithub-comment-saved-item {
8652
+  position: relative;
8653
+  display: flex;
8654
+  flex-direction: column;
8655
+  gap: 0.2rem;
8656
+  padding: 0.75rem 4rem 0.75rem 0.9rem;
8657
+}
8658
+.shithub-comment-saved-item:hover,
8659
+.shithub-comment-saved-create:hover {
8660
+  background: var(--button-default-hover-bg);
8661
+}
8662
+.shithub-comment-saved-item span {
8663
+  color: var(--fg-muted);
8664
+  font-size: 0.82rem;
8665
+}
8666
+.shithub-comment-saved-item kbd {
8667
+  position: absolute;
8668
+  right: 0.9rem;
8669
+  top: 0.75rem;
8670
+  padding: 0.05rem 0.35rem;
8671
+  border: 1px solid var(--border-default);
8672
+  border-radius: 999px;
8673
+  color: var(--fg-muted);
8674
+  background: var(--canvas-subtle);
8675
+  font: 0.72rem ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
8676
+}
8677
+.shithub-comment-saved-create {
8678
+  display: flex;
8679
+  align-items: center;
8680
+  gap: 0.4rem;
8681
+  margin: 0.6rem;
8682
+  width: calc(100% - 1.2rem);
8683
+  padding: 0.55rem 0.7rem;
8684
+  border-radius: 6px;
8685
+  background: var(--canvas-subtle);
8686
+  font-weight: 600;
8687
+}
8688
+.shithub-comment-editor-fullscreen {
8689
+  position: fixed;
8690
+  inset: 5vh 5vw;
8691
+  z-index: 100;
8692
+  display: flex;
8693
+  flex-direction: column;
8694
+  border: 1px solid var(--border-default);
8695
+  border-radius: 8px;
8696
+  background: var(--canvas-default);
8697
+  box-shadow: 0 16px 48px rgba(1, 4, 9, 0.45);
8698
+}
8699
+.shithub-comment-editor-fullscreen .shithub-comment-editor-write,
8700
+.shithub-comment-editor-fullscreen .shithub-comment-editor-preview {
8701
+  flex: 1 1 auto;
8702
+}
8703
+.shithub-comment-editor-fullscreen textarea {
8704
+  min-height: 55vh;
8705
+  resize: none;
8706
+}
8707
+.shithub-comment-composer-row:has(.shithub-comment-editor-fullscreen)::before {
8708
+  content: "";
8709
+  position: fixed;
8710
+  inset: 0;
8711
+  z-index: 99;
8712
+  background: rgba(1, 4, 9, 0.48);
8713
+}
83748714
 .shithub-button-compact { min-height: 32px; padding: 0.25rem 0.7rem; }
83758715
 .shithub-pull-files { margin-top: 0.25rem; }
83768716
 .shithub-pull-files-toolbar {
internal/web/static/js/comment-editor.jsadded
@@ -0,0 +1,509 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+(function () {
4
+  "use strict";
5
+
6
+  const slashCommands = [
7
+    {
8
+      label: "Alerts",
9
+      description: "Add a markdown alert to emphasize important information",
10
+      snippet: "> [!NOTE]\n> Important information\n"
11
+    },
12
+    {
13
+      label: "Code block",
14
+      description: "Insert a code block formatted for a chosen syntax",
15
+      snippet: "```\ncode\n```\n"
16
+    },
17
+    {
18
+      label: "Details",
19
+      description: "Add a details tag to hide content behind a visible heading",
20
+      snippet: "<details>\n<summary>Summary</summary>\n\nDetails\n</details>\n"
21
+    },
22
+    {
23
+      label: "Saved replies",
24
+      description: "Insert one of your saved replies",
25
+      action: "saved"
26
+    },
27
+    {
28
+      label: "Table",
29
+      description: "Add markdown table",
30
+      snippet: "| Column | Value |\n| --- | --- |\n| Item | Value |\n"
31
+    }
32
+  ];
33
+
34
+  function initEditor(root) {
35
+    const form = root.querySelector("form");
36
+    const box = root.querySelector(".shithub-comment-editor-box");
37
+    const textarea = root.querySelector("[data-comment-textarea]");
38
+    const writePane = root.querySelector("[data-comment-write-pane]");
39
+    const previewPane = root.querySelector("[data-comment-preview-pane]");
40
+    const suggestions = root.querySelector("[data-comment-suggestions]");
41
+    const submit = root.querySelector("[data-comment-submit]");
42
+    const savedDialog = root.querySelector("[data-comment-saved-dialog]");
43
+    const fileInput = root.querySelector("[data-comment-file-input]");
44
+    const fileList = root.querySelector("[data-comment-file-list]");
45
+    const config = readConfig(root);
46
+    let activeToken = null;
47
+    let previewDirty = true;
48
+
49
+    if (!form || !box || !textarea) return;
50
+
51
+    function setTab(tab) {
52
+      root.querySelectorAll("[data-comment-tab]").forEach(function (button) {
53
+        const active = button.dataset.commentTab === tab;
54
+        button.classList.toggle("is-active", active);
55
+        button.setAttribute("aria-selected", active ? "true" : "false");
56
+      });
57
+      if (writePane) writePane.hidden = tab !== "write";
58
+      if (previewPane) previewPane.hidden = tab !== "preview";
59
+      if (tab === "preview") renderPreview();
60
+      if (tab === "write") textarea.focus();
61
+    }
62
+
63
+    function updateSubmit() {
64
+      if (submit) submit.disabled = textarea.value.trim() === "";
65
+    }
66
+
67
+    async function renderPreview() {
68
+      if (!previewPane || !previewDirty) return;
69
+      if (textarea.value.trim() === "") {
70
+        previewPane.innerHTML = "<p class=\"shithub-editor-preview-empty\">Nothing to preview.</p>";
71
+        previewDirty = false;
72
+        return;
73
+      }
74
+      const csrf = form.querySelector("input[name='csrf_token']");
75
+      const body = new URLSearchParams();
76
+      body.set("csrf_token", csrf ? csrf.value : "");
77
+      body.set("content", textarea.value);
78
+      body.set("ref", root.dataset.previewRef || "");
79
+      body.set("path", "comment.md");
80
+      previewPane.innerHTML = "<p class=\"shithub-editor-preview-empty\">Rendering preview...</p>";
81
+      try {
82
+        const response = await fetch(root.dataset.previewUrl || "", {
83
+          method: "POST",
84
+          headers: {
85
+            "Content-Type": "application/x-www-form-urlencoded",
86
+            "X-Requested-With": "XMLHttpRequest"
87
+          },
88
+          body: body.toString()
89
+        });
90
+        previewPane.innerHTML = response.ok ? await response.text() : "<p class=\"shithub-editor-preview-empty\">Preview failed.</p>";
91
+      } catch (error) {
92
+        previewPane.innerHTML = "<p class=\"shithub-editor-preview-empty\">Preview failed.</p>";
93
+      }
94
+      previewDirty = false;
95
+    }
96
+
97
+    function replaceSelection(before, after, fallback) {
98
+      const start = textarea.selectionStart;
99
+      const end = textarea.selectionEnd;
100
+      const selected = textarea.value.slice(start, end) || fallback;
101
+      const replacement = before + selected + after;
102
+      textarea.setRangeText(replacement, start, end, "select");
103
+      textarea.selectionStart = start + before.length;
104
+      textarea.selectionEnd = start + before.length + selected.length;
105
+      afterEdit();
106
+    }
107
+
108
+    function prefixSelection(prefix) {
109
+      const start = textarea.selectionStart;
110
+      const end = textarea.selectionEnd;
111
+      const selected = textarea.value.slice(start, end) || "";
112
+      const source = selected || currentLineText();
113
+      const replacement = source.split("\n").map(function (line) {
114
+        return line.trim() === "" ? prefix.trimEnd() : prefix + line;
115
+      }).join("\n");
116
+      if (selected) {
117
+        textarea.setRangeText(replacement, start, end, "end");
118
+      } else {
119
+        const line = currentLineRange();
120
+        textarea.setRangeText(replacement, line.start, line.end, "end");
121
+      }
122
+      afterEdit();
123
+    }
124
+
125
+    function currentLineRange() {
126
+      const pos = textarea.selectionStart;
127
+      const before = textarea.value.slice(0, pos);
128
+      const after = textarea.value.slice(pos);
129
+      const lineStart = before.lastIndexOf("\n") + 1;
130
+      let lineEnd = after.indexOf("\n");
131
+      lineEnd = lineEnd === -1 ? textarea.value.length : pos + lineEnd;
132
+      return { start: lineStart, end: lineEnd };
133
+    }
134
+
135
+    function currentLineText() {
136
+      const line = currentLineRange();
137
+      return textarea.value.slice(line.start, line.end);
138
+    }
139
+
140
+    function insertText(text) {
141
+      const start = textarea.selectionStart;
142
+      const end = textarea.selectionEnd;
143
+      textarea.setRangeText(text, start, end, "end");
144
+      afterEdit();
145
+    }
146
+
147
+    function afterEdit() {
148
+      previewDirty = true;
149
+      updateSubmit();
150
+      hideSuggestions();
151
+      textarea.focus();
152
+    }
153
+
154
+    function runAction(action) {
155
+      switch (action) {
156
+      case "heading":
157
+        prefixSelection("### ");
158
+        break;
159
+      case "bold":
160
+        replaceSelection("**", "**", "bold text");
161
+        break;
162
+      case "italic":
163
+        replaceSelection("*", "*", "italic text");
164
+        break;
165
+      case "quote":
166
+        prefixSelection("> ");
167
+        break;
168
+      case "code":
169
+        if (textarea.value.slice(textarea.selectionStart, textarea.selectionEnd).includes("\n")) {
170
+          replaceSelection("```\n", "\n```", "code");
171
+        } else {
172
+          replaceSelection("`", "`", "code");
173
+        }
174
+        break;
175
+      case "link":
176
+        replaceSelection("[", "](url)", "link text");
177
+        break;
178
+      case "list":
179
+        prefixSelection("- ");
180
+        break;
181
+      case "ordered-list":
182
+        prefixSelection("1. ");
183
+        break;
184
+      case "task-list":
185
+        prefixSelection("- [ ] ");
186
+        break;
187
+      case "mention":
188
+        showSuggestions("mention", "");
189
+        break;
190
+      case "reference":
191
+        showSuggestions("reference", "");
192
+        break;
193
+      case "fullscreen":
194
+        box.classList.toggle("shithub-comment-editor-fullscreen");
195
+        root.querySelector("[data-comment-action='fullscreen']")?.classList.toggle("is-active", box.classList.contains("shithub-comment-editor-fullscreen"));
196
+        textarea.focus();
197
+        break;
198
+      }
199
+    }
200
+
201
+    function detectToken() {
202
+      const pos = textarea.selectionStart;
203
+      const before = textarea.value.slice(0, pos);
204
+      const lineStart = before.lastIndexOf("\n") + 1;
205
+      const line = before.slice(lineStart);
206
+      const slash = line.match(/^\/([A-Za-z-]*)$/);
207
+      if (slash) {
208
+        return { kind: "slash", query: slash[1].toLowerCase(), start: lineStart, end: pos };
209
+      }
210
+      const match = before.match(/(^|[\s([])([@#])([A-Za-z0-9_.-]*)$/);
211
+      if (!match) return null;
212
+      const marker = match[2];
213
+      return {
214
+        kind: marker === "@" ? "mention" : "reference",
215
+        query: match[3].toLowerCase(),
216
+        start: pos - marker.length - match[3].length,
217
+        end: pos
218
+      };
219
+    }
220
+
221
+    function showDetectedSuggestions() {
222
+      const token = detectToken();
223
+      if (!token) {
224
+        hideSuggestions();
225
+        return;
226
+      }
227
+      showSuggestions(token.kind, token.query, token);
228
+    }
229
+
230
+    function showSuggestions(kind, query, token) {
231
+      if (!suggestions) return;
232
+      activeToken = token || {
233
+        kind: kind,
234
+        query: query || "",
235
+        start: textarea.selectionStart,
236
+        end: textarea.selectionEnd
237
+      };
238
+      let html = "";
239
+      let items = [];
240
+      suggestions.classList.toggle("is-slash", kind === "slash");
241
+      if (kind === "mention") {
242
+        items = config.mentions.filter(function (item) {
243
+          return item.username.toLowerCase().includes(query) || (item.displayName || "").toLowerCase().includes(query);
244
+        }).slice(0, 8);
245
+        html = "<div class=\"shithub-comment-suggestion-section\">Suggestions</div>" + items.map(mentionHTML).join("");
246
+      } else if (kind === "reference") {
247
+        items = config.references.filter(function (item) {
248
+          const needle = query.replace(/^#/, "");
249
+          return String(item.number).includes(needle) || item.title.toLowerCase().includes(needle);
250
+        }).slice(0, 8);
251
+        html = "<div class=\"shithub-comment-suggestion-section\">Issues and pull requests</div>" + items.map(referenceHTML).join("");
252
+      } else {
253
+        items = slashCommands.filter(function (item) {
254
+          return item.label.toLowerCase().includes(query);
255
+        });
256
+        html = "<div class=\"shithub-comment-suggestion-section\">Slash commands <span class=\"shithub-pill\">Preview</span></div>" + items.map(slashHTML).join("");
257
+      }
258
+      if (items.length === 0) {
259
+        hideSuggestions();
260
+        return;
261
+      }
262
+      suggestions.innerHTML = html;
263
+      suggestions.hidden = false;
264
+      suggestions.querySelector(".shithub-comment-suggestion-item")?.classList.add("is-active");
265
+    }
266
+
267
+    function mentionHTML(item, index) {
268
+      const display = item.displayName ? " <small>" + escapeHTML(item.displayName) + "</small>" : "";
269
+      return "<button type=\"button\" class=\"shithub-comment-suggestion-item\" data-suggestion-kind=\"mention\" data-suggestion-index=\"" + index + "\"><img src=\"" + escapeAttr(item.avatarUrl || "") + "\" alt=\"\"><span><strong>" + escapeHTML(item.username) + "</strong>" + display + "</span></button>";
270
+    }
271
+
272
+    function referenceHTML(item, index) {
273
+      return "<button type=\"button\" class=\"shithub-comment-suggestion-item\" data-suggestion-kind=\"reference\" data-suggestion-index=\"" + index + "\"><span aria-hidden=\"true\">#</span><span class=\"shithub-comment-suggestion-copy\"><strong>#" + item.number + " " + escapeHTML(item.title) + "</strong><small>" + escapeHTML(item.kind) + " " + escapeHTML(item.state) + "</small></span></button>";
274
+    }
275
+
276
+    function slashHTML(item, index) {
277
+      return "<button type=\"button\" class=\"shithub-comment-suggestion-item\" data-suggestion-kind=\"slash\" data-suggestion-index=\"" + index + "\"><span aria-hidden=\"true\">" + (item.action === "saved" ? "@" : "/") + "</span><span class=\"shithub-comment-suggestion-copy\"><strong>" + escapeHTML(item.label) + "</strong><small>" + escapeHTML(item.description) + "</small></span></button>";
278
+    }
279
+
280
+    function currentSuggestionItems(kind) {
281
+      const token = activeToken || { query: "" };
282
+      if (kind === "mention") {
283
+        return config.mentions.filter(function (item) {
284
+          return item.username.toLowerCase().includes(token.query) || (item.displayName || "").toLowerCase().includes(token.query);
285
+        }).slice(0, 8);
286
+      }
287
+      if (kind === "reference") {
288
+        return config.references.filter(function (item) {
289
+          const needle = token.query.replace(/^#/, "");
290
+          return String(item.number).includes(needle) || item.title.toLowerCase().includes(needle);
291
+        }).slice(0, 8);
292
+      }
293
+      return slashCommands.filter(function (item) {
294
+        return item.label.toLowerCase().includes(token.query);
295
+      });
296
+    }
297
+
298
+    function chooseSuggestion(button) {
299
+      const kind = button.dataset.suggestionKind;
300
+      const index = Number(button.dataset.suggestionIndex || "0");
301
+      const item = currentSuggestionItems(kind)[index];
302
+      if (!item) return;
303
+      if (kind === "mention") {
304
+        replaceToken("@" + item.username + " ");
305
+      } else if (kind === "reference") {
306
+        replaceToken("#" + item.number + " ");
307
+      } else if (item.action === "saved") {
308
+        hideSuggestions();
309
+        openSavedDialog();
310
+      } else {
311
+        replaceToken(item.snippet || "");
312
+      }
313
+    }
314
+
315
+    function replaceToken(text) {
316
+      const token = activeToken || { start: textarea.selectionStart, end: textarea.selectionEnd };
317
+      textarea.setRangeText(text, token.start, token.end, "end");
318
+      afterEdit();
319
+    }
320
+
321
+    function hideSuggestions() {
322
+      if (!suggestions) return;
323
+      suggestions.hidden = true;
324
+      suggestions.innerHTML = "";
325
+      activeToken = null;
326
+    }
327
+
328
+    function moveActiveSuggestion(delta) {
329
+      if (!suggestions || suggestions.hidden) return;
330
+      const items = Array.from(suggestions.querySelectorAll(".shithub-comment-suggestion-item"));
331
+      if (items.length === 0) return;
332
+      let index = items.findIndex(function (item) { return item.classList.contains("is-active"); });
333
+      index = index < 0 ? 0 : index + delta;
334
+      if (index < 0) index = items.length - 1;
335
+      if (index >= items.length) index = 0;
336
+      items.forEach(function (item, i) {
337
+        item.classList.toggle("is-active", i === index);
338
+      });
339
+      items[index].scrollIntoView({ block: "nearest" });
340
+    }
341
+
342
+    function openSavedDialog() {
343
+      if (!savedDialog) return;
344
+      try {
345
+        if (savedDialog.showModal) {
346
+          savedDialog.showModal();
347
+        } else {
348
+          savedDialog.setAttribute("open", "");
349
+        }
350
+      } catch (error) {
351
+        savedDialog.setAttribute("open", "");
352
+      }
353
+      savedDialog.querySelector("[data-comment-saved-filter]")?.focus();
354
+    }
355
+
356
+    function closeSavedDialog() {
357
+      if (!savedDialog) return;
358
+      if (savedDialog.close) {
359
+        savedDialog.close();
360
+      } else {
361
+        savedDialog.removeAttribute("open");
362
+      }
363
+      textarea.focus();
364
+    }
365
+
366
+    function updateFiles(files) {
367
+      if (!fileList || !files || files.length === 0) return;
368
+      const names = Array.from(files).map(function (file) { return file.name; }).slice(0, 4);
369
+      fileList.textContent = names.join(", ");
370
+      fileList.hidden = false;
371
+    }
372
+
373
+    root.addEventListener("click", function (event) {
374
+      const tab = event.target.closest("[data-comment-tab]");
375
+      if (tab) {
376
+        setTab(tab.dataset.commentTab);
377
+        return;
378
+      }
379
+      const action = event.target.closest("[data-comment-action]");
380
+      if (action) {
381
+        runAction(action.dataset.commentAction);
382
+        return;
383
+      }
384
+      if (event.target.closest("[data-comment-saved-replies-open]")) {
385
+        openSavedDialog();
386
+        return;
387
+      }
388
+      const suggestion = event.target.closest(".shithub-comment-suggestion-item");
389
+      if (suggestion) {
390
+        chooseSuggestion(suggestion);
391
+      }
392
+    });
393
+
394
+    if (suggestions) {
395
+      suggestions.addEventListener("mousedown", function (event) {
396
+        event.preventDefault();
397
+      });
398
+    }
399
+
400
+    textarea.addEventListener("input", function () {
401
+      previewDirty = true;
402
+      updateSubmit();
403
+      showDetectedSuggestions();
404
+    });
405
+    textarea.addEventListener("keyup", function (event) {
406
+      if (["ArrowUp", "ArrowDown", "Enter", "Escape"].includes(event.key)) return;
407
+      showDetectedSuggestions();
408
+    });
409
+    textarea.addEventListener("keydown", function (event) {
410
+      if (!suggestions || suggestions.hidden) return;
411
+      if (event.key === "Escape") {
412
+        event.preventDefault();
413
+        hideSuggestions();
414
+      } else if (event.key === "ArrowDown") {
415
+        event.preventDefault();
416
+        moveActiveSuggestion(1);
417
+      } else if (event.key === "ArrowUp") {
418
+        event.preventDefault();
419
+        moveActiveSuggestion(-1);
420
+      } else if (event.key === "Enter") {
421
+        const active = suggestions.querySelector(".shithub-comment-suggestion-item.is-active");
422
+        if (active) {
423
+          event.preventDefault();
424
+          chooseSuggestion(active);
425
+        }
426
+      }
427
+    });
428
+    textarea.addEventListener("blur", function () {
429
+      window.setTimeout(hideSuggestions, 120);
430
+    });
431
+
432
+    if (fileInput) {
433
+      fileInput.addEventListener("change", function () {
434
+        updateFiles(fileInput.files);
435
+      });
436
+    }
437
+    box.addEventListener("dragover", function (event) {
438
+      if (event.dataTransfer && event.dataTransfer.files.length > 0) event.preventDefault();
439
+    });
440
+    box.addEventListener("drop", function (event) {
441
+      if (event.dataTransfer && event.dataTransfer.files.length > 0) {
442
+        event.preventDefault();
443
+        updateFiles(event.dataTransfer.files);
444
+      }
445
+    });
446
+
447
+    if (savedDialog) {
448
+      savedDialog.addEventListener("click", function (event) {
449
+        if (event.target.closest("[data-comment-saved-close]")) {
450
+          closeSavedDialog();
451
+          return;
452
+        }
453
+        const insert = event.target.closest("[data-comment-saved-insert]");
454
+        if (insert) {
455
+          closeSavedDialog();
456
+          insertText(insert.dataset.commentSavedInsert || "");
457
+          return;
458
+        }
459
+        if (event.target.closest(".shithub-comment-saved-create")) {
460
+          closeSavedDialog();
461
+        }
462
+      });
463
+      savedDialog.querySelector("[data-comment-saved-filter]")?.addEventListener("input", function (event) {
464
+        const query = event.target.value.trim().toLowerCase();
465
+        savedDialog.querySelectorAll("[data-comment-saved-insert]").forEach(function (button) {
466
+          button.hidden = query !== "" && !button.textContent.toLowerCase().includes(query);
467
+        });
468
+      });
469
+    }
470
+
471
+    document.addEventListener("click", function (event) {
472
+      if (!root.contains(event.target)) hideSuggestions();
473
+    });
474
+
475
+    updateSubmit();
476
+  }
477
+
478
+  function readConfig(root) {
479
+    const fallback = { mentions: [], references: [] };
480
+    const script = root.querySelector("[data-comment-editor-config]");
481
+    if (!script) return fallback;
482
+    try {
483
+      const parsed = JSON.parse(script.textContent || "{}");
484
+      return {
485
+        mentions: Array.isArray(parsed.mentions) ? parsed.mentions.filter(function (item) {
486
+          return item && item.username && item.username.toLowerCase() !== "copilot";
487
+        }) : [],
488
+        references: Array.isArray(parsed.references) ? parsed.references : []
489
+      };
490
+    } catch (error) {
491
+      return fallback;
492
+    }
493
+  }
494
+
495
+  function escapeHTML(value) {
496
+    return String(value || "")
497
+      .replace(/&/g, "&amp;")
498
+      .replace(/</g, "&lt;")
499
+      .replace(/>/g, "&gt;")
500
+      .replace(/"/g, "&quot;")
501
+      .replace(/'/g, "&#39;");
502
+  }
503
+
504
+  function escapeAttr(value) {
505
+    return escapeHTML(value).replace(/`/g, "&#96;");
506
+  }
507
+
508
+  document.querySelectorAll("[data-comment-editor]").forEach(initEditor);
509
+})();
internal/web/templates/_layout.htmlmodified
@@ -36,6 +36,7 @@
3636
   <link rel="stylesheet" href="/static/css/shithub.css">
3737
   <link rel="stylesheet" href="/static/css/chroma.css">
3838
   {{ if flag . "UseHTMX" }}<script src="/static/vendor/htmx/htmx.min.js" defer></script>{{ end }}
39
+  {{ if flag . "UseCommentEditor" }}<script src="/static/js/comment-editor.js" defer></script>{{ end }}
3940
 </head>
4041
 <body class="shithub-body">
4142
 {{ template "nav" . }}
internal/web/templates/repo/pull_view.htmlmodified
@@ -250,16 +250,81 @@
250250
           </section>
251251
 
252252
           {{ if .CanComment }}
253
-          <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .PR.INumber }}/comments" class="shithub-comment-form shithub-pull-comment-form">
254
-            <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
255
-            <label>
256
-              <span>Add a comment</span>
257
-              <textarea name="body" rows="6" maxlength="65535" placeholder="Add your comment here..."></textarea>
258
-            </label>
259
-            <div class="shithub-form-actions">
260
-              <button type="submit" class="shithub-button shithub-button-primary">Comment</button>
261
-            </div>
262
-          </form>
253
+          <div class="shithub-comment-composer-row" data-comment-editor data-preview-url="/{{ .Owner }}/{{ .Repo.Name }}/markdown-preview" data-preview-ref="{{ .Repo.DefaultBranch }}">
254
+            <a class="shithub-comment-composer-avatar" href="/{{ .Viewer.Username }}" aria-label="@{{ .Viewer.Username }}">
255
+              <img src="{{ .ViewerAvatarURL }}" alt="" width="40" height="40">
256
+            </a>
257
+            <form method="post" action="/{{ .Owner }}/{{ .Repo.Name }}/issues/{{ .PR.INumber }}/comments" class="shithub-comment-form shithub-pull-comment-form shithub-comment-composer-form">
258
+              <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
259
+              <script type="application/json" data-comment-editor-config>{{ jsField . "CommentEditorConfig" }}</script>
260
+              <label class="shithub-comment-composer-title" for="pull-comment-body">Add a comment</label>
261
+              <div class="shithub-comment-editor-box">
262
+                <div class="shithub-comment-editor-head">
263
+                  <div class="shithub-comment-editor-tabs" role="tablist" aria-label="Comment editor tabs">
264
+                    <button type="button" class="is-active" role="tab" aria-selected="true" data-comment-tab="write">Write</button>
265
+                    <button type="button" role="tab" aria-selected="false" data-comment-tab="preview">Preview</button>
266
+                  </div>
267
+                  <div class="shithub-comment-toolbar" aria-label="Formatting tools">
268
+                    <button type="button" class="shithub-comment-tool" data-comment-action="mention" title="Mention a user">{{ octicon "people" }}</button>
269
+                    <span class="shithub-comment-toolbar-separator" aria-hidden="true"></span>
270
+                    <button type="button" class="shithub-comment-tool shithub-comment-tool-text" data-comment-action="heading" title="Add heading">H</button>
271
+                    <button type="button" class="shithub-comment-tool shithub-comment-tool-text" data-comment-action="bold" title="Add bold text">B</button>
272
+                    <button type="button" class="shithub-comment-tool shithub-comment-tool-text is-italic" data-comment-action="italic" title="Add italic text">I</button>
273
+                    <button type="button" class="shithub-comment-tool" data-comment-action="quote" title="Quote text">{{ octicon "comment" }}</button>
274
+                    <button type="button" class="shithub-comment-tool" data-comment-action="code" title="Add code">{{ octicon "code" }}</button>
275
+                    <button type="button" class="shithub-comment-tool" data-comment-action="link" title="Add link">{{ octicon "link" }}</button>
276
+                    <span class="shithub-comment-toolbar-separator" aria-hidden="true"></span>
277
+                    <button type="button" class="shithub-comment-tool" data-comment-action="list" title="Add unordered list">{{ octicon "list-unordered" }}</button>
278
+                    <button type="button" class="shithub-comment-tool shithub-comment-tool-text" data-comment-action="ordered-list" title="Add ordered list">1.</button>
279
+                    <button type="button" class="shithub-comment-tool" data-comment-action="task-list" title="Add task list">{{ octicon "checklist" }}</button>
280
+                    <span class="shithub-comment-toolbar-separator" aria-hidden="true"></span>
281
+                    <label class="shithub-comment-tool" title="Attach files">
282
+                      {{ octicon "upload" }}
283
+                      <input type="file" multiple data-comment-file-input>
284
+                    </label>
285
+                    <button type="button" class="shithub-comment-tool shithub-comment-tool-text" data-comment-action="reference" title="Reference an issue or pull request">#</button>
286
+                    <button type="button" class="shithub-comment-tool" data-comment-saved-replies-open title="Saved replies">{{ octicon "comment-discussion" }}</button>
287
+                    <button type="button" class="shithub-comment-tool" data-comment-action="fullscreen" title="Fullscreen editor">{{ octicon "screen-full" }}</button>
288
+                  </div>
289
+                </div>
290
+                <div class="shithub-comment-editor-write" data-comment-write-pane>
291
+                  <textarea id="pull-comment-body" name="body" rows="6" maxlength="65535" placeholder="Add your comment here..." data-comment-textarea></textarea>
292
+                  <div class="shithub-comment-suggestions" data-comment-suggestions hidden></div>
293
+                </div>
294
+                <div class="shithub-comment-editor-preview markdown-body" data-comment-preview-pane hidden>
295
+                  <p class="shithub-editor-preview-empty">Nothing to preview.</p>
296
+                </div>
297
+                <div class="shithub-comment-editor-footer">
298
+                  <span>{{ octicon "code-square" }} Markdown is supported</span>
299
+                  <span data-comment-attachment-copy>{{ octicon "file" }} Paste, drop, or click to add files</span>
300
+                  <span class="shithub-comment-file-list" data-comment-file-list hidden></span>
301
+                </div>
302
+              </div>
303
+              <p class="shithub-comment-policy-note">
304
+                {{ octicon "alert" }} Remember, contributions to this repository should follow its
305
+                <a href="/{{ .Owner }}/{{ .Repo.Name }}/blob/{{ .Repo.DefaultBranch }}/CONTRIBUTING.md">contributing guidelines</a>,
306
+                <a href="/{{ .Owner }}/{{ .Repo.Name }}/blob/{{ .Repo.DefaultBranch }}/SECURITY.md">security policy</a>, and
307
+                <a href="/{{ .Owner }}/{{ .Repo.Name }}/blob/{{ .Repo.DefaultBranch }}/CODE_OF_CONDUCT.md">code of conduct</a>.
308
+              </p>
309
+              <p class="shithub-comment-protip">{{ octicon "light-bulb" }} <strong>ProTip!</strong> Add <a href="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}.patch">.patch</a> or <a href="/{{ .Owner }}/{{ .Repo.Name }}/pulls/{{ .PR.INumber }}.diff">.diff</a> to the end of URLs for Git's plaintext views.</p>
310
+              <div class="shithub-form-actions">
311
+                <button type="submit" class="shithub-button shithub-button-primary" data-comment-submit>Comment</button>
312
+              </div>
313
+            </form>
314
+            <dialog class="shithub-comment-saved-dialog" data-comment-saved-dialog>
315
+              <div class="shithub-comment-saved-head">
316
+                <strong>Select a reply</strong>
317
+                <button type="button" class="shithub-icon-button" aria-label="Close" data-comment-saved-close>{{ octicon "x" }}</button>
318
+              </div>
319
+              <input type="search" placeholder="Search saved replies" data-comment-saved-filter>
320
+              <button type="button" class="shithub-comment-saved-item" data-comment-saved-insert="Duplicate of #">
321
+                <strong>Duplicate pull request</strong>
322
+                <span>Duplicate of #</span>
323
+                <kbd>ctrl 1</kbd>
324
+              </button>
325
+              <button type="button" class="shithub-comment-saved-create">{{ octicon "plus" }} Create a new saved reply</button>
326
+            </dialog>
327
+          </div>
263328
           {{ else if .Viewer.ID }}
264329
             {{ if .PR.ILocked }}<p class="shithub-issue-signedout">This conversation is locked.</p>{{ end }}
265330
           {{ else }}