Skip to content

Commit 447cd6b

Browse files
sarth6claude
andcommitted
Move PR button into right sidebar above Reviewers
Previous version injected next to .gh-header-actions, which is fragile across GitHub redesigns and competes with other extensions for the same spot. Move to the much more stable #partial-discussion-sidebar / #reviewers-select-menu anchors (same approach Refined GitHub uses). The widget renders as a native-feeling sidebar card with the heading "Conductor", a primary button using the default preset, and a dropdown listing all other presets when there are 2+. Falls back to the sidebar top, then the header, if the reviewers section isn't present. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent bbc46a7 commit 447cd6b

2 files changed

Lines changed: 190 additions & 42 deletions

File tree

src/content/content.css

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,103 @@
1-
/* Inline GitHub-PR header button — adopts GitHub's "btn" baseline and adds a
2-
Conductor-branded accent so it's discoverable next to the native actions. */
1+
/* Conductor widget — lives inside GitHub's PR right sidebar above Reviewers.
2+
We piggy-back on GitHub's `.discussion-sidebar-item` for padding / border
3+
alignment, then layer our own brand colors on top so the widget reads as
4+
a related-but-distinct extension surface. */
35

4-
#conductor-pr-button.conductor-pr-button {
6+
#conductor-pr-widget.conductor-widget {
7+
padding-top: 12px;
8+
padding-bottom: 16px;
9+
}
10+
11+
#conductor-pr-widget .conductor-widget-heading {
12+
margin: 0 0 8px;
13+
font-size: 12px;
14+
font-weight: 600;
15+
text-transform: none;
16+
letter-spacing: 0;
17+
color: var(--fgColor-muted, var(--color-fg-muted, #57606a));
18+
display: flex;
19+
align-items: center;
20+
gap: 6px;
21+
}
22+
23+
#conductor-pr-widget .conductor-widget-heading::before {
24+
content: '';
25+
display: inline-block;
26+
width: 12px;
27+
height: 12px;
28+
background-color: #5b6cff;
29+
-webkit-mask-image: radial-gradient(circle at 50% 50%, #000 60%, transparent 62%);
30+
mask-image: radial-gradient(circle at 50% 50%, #000 60%, transparent 62%);
31+
border-radius: 2px;
32+
}
33+
34+
#conductor-pr-widget .conductor-widget-row {
35+
display: flex;
36+
align-items: stretch;
37+
gap: 6px;
38+
}
39+
40+
#conductor-pr-widget .conductor-pr-button {
41+
flex: 1 1 auto;
542
display: inline-flex;
643
align-items: center;
44+
justify-content: center;
745
gap: 6px;
8-
margin-right: 8px;
946
font-weight: 600;
1047
color: #ffffff;
1148
background: linear-gradient(180deg, #5b6cff 0%, #4453e3 100%);
1249
border: 1px solid rgba(0, 0, 0, 0.15);
1350
border-radius: 6px;
14-
padding: 5px 12px;
51+
padding: 6px 12px;
1552
cursor: pointer;
1653
line-height: 20px;
1754
font-size: 12px;
55+
text-align: center;
1856
transition:
1957
filter 0.12s ease,
2058
transform 0.05s ease;
2159
}
2260

23-
#conductor-pr-button.conductor-pr-button:hover {
61+
#conductor-pr-widget .conductor-pr-button:hover {
2462
filter: brightness(1.08);
2563
}
2664

27-
#conductor-pr-button.conductor-pr-button:active {
65+
#conductor-pr-widget .conductor-pr-button:active {
2866
transform: translateY(1px);
2967
}
3068

31-
#conductor-pr-button.conductor-pr-button:focus-visible {
69+
#conductor-pr-widget .conductor-pr-button:focus-visible {
3270
outline: 2px solid #ffffff;
3371
outline-offset: 1px;
3472
box-shadow: 0 0 0 4px rgba(91, 108, 255, 0.6);
3573
}
3674

37-
#conductor-pr-button .conductor-pr-button-icon {
75+
#conductor-pr-widget .conductor-pr-button-icon {
3876
font-size: 10px;
3977
line-height: 1;
4078
}
4179

42-
#conductor-pr-button.conductor-pr-button--flash {
80+
#conductor-pr-widget .conductor-pr-button--flash {
4381
background: linear-gradient(180deg, #1f883d 0%, #1a7234 100%);
4482
}
4583

84+
#conductor-pr-widget .conductor-preset-select {
85+
flex: 0 0 auto;
86+
background: var(--bgColor-default, var(--color-canvas-default, #ffffff));
87+
color: var(--fgColor-default, var(--color-fg-default, #1f2328));
88+
border: 1px solid var(--borderColor-default, var(--color-border-default, #d0d7de));
89+
border-radius: 6px;
90+
padding: 5px 6px;
91+
font-size: 12px;
92+
cursor: pointer;
93+
}
94+
95+
#conductor-pr-widget .conductor-preset-select:hover {
96+
background: var(--bgColor-muted, var(--color-canvas-subtle, #f6f8fa));
97+
}
98+
4699
@media (prefers-color-scheme: dark) {
47-
#conductor-pr-button.conductor-pr-button {
100+
#conductor-pr-widget .conductor-pr-button {
48101
border-color: rgba(255, 255, 255, 0.12);
49102
}
50103
}

src/content/content.ts

Lines changed: 126 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { buildConductorUrl } from '../conductor-url';
33
import { createStorage } from '../storage';
44
import type { Preset, Settings } from '../types';
55

6+
const WIDGET_ID = 'conductor-pr-widget';
67
const BUTTON_ID = 'conductor-pr-button';
78
const INJECTED_ATTR = 'data-conductor-injected';
89

@@ -13,9 +14,8 @@ void (async () => {
1314
currentSettings = await storage.getSettings();
1415
storage.onChange((s) => {
1516
currentSettings = s;
16-
// Re-render so the button label reflects the latest default preset name.
17-
const existing = document.getElementById(BUTTON_ID);
18-
existing?.remove();
17+
// Re-render so the widget label reflects the latest default preset name.
18+
document.getElementById(WIDGET_ID)?.remove();
1919
tryInject();
2020
});
2121
setupNavigationListeners();
@@ -73,60 +73,117 @@ function setupNavigationListeners(): void {
7373
document.addEventListener(event, () => tryInject());
7474
}
7575

76-
// Fallback: a MutationObserver watching the header region. When GitHub
77-
// re-renders the PR header after navigation, we re-inject our button.
76+
// GitHub's PR sidebar mounts asynchronously after the initial document
77+
// render. We watch the whole body so any time the sidebar (re)appears we
78+
// try injecting again. The injection itself is idempotent — if the widget
79+
// is already present, tryInject() returns early.
7880
const observer = new MutationObserver(() => {
79-
if (!document.getElementById(BUTTON_ID)) tryInject();
81+
if (!document.getElementById(WIDGET_ID)) tryInject();
8082
});
8183
observer.observe(document.body, { childList: true, subtree: true });
8284
}
8385

8486
function tryInject(): void {
8587
if (!isPrPage(window.location)) return;
86-
if (document.getElementById(BUTTON_ID)) return;
88+
if (document.getElementById(WIDGET_ID)) return;
8789

88-
// Try several anchor points so we degrade gracefully across GitHub
89-
// redesigns. We insert next to the existing "Code" / "Subscribe" actions.
90-
const anchor = findAnchor();
91-
if (!anchor) return;
90+
const placement = findPlacement();
91+
if (!placement) return;
9292

93-
const button = createButton();
94-
anchor.parentElement?.insertBefore(button, anchor);
93+
const widget = createWidget();
94+
placement.parent.insertBefore(widget, placement.before);
9595
}
9696

97-
function findAnchor(): Element | null {
98-
// Preferred: the right-side header actions container on the PR conversation page.
99-
const selectors = [
100-
'.gh-header-actions',
101-
'.gh-header-meta + div .gh-header-actions',
102-
'[data-testid="pr-header-actions"]',
103-
];
104-
for (const sel of selectors) {
105-
const el = document.querySelector(sel);
106-
if (el) {
107-
// Insert as a sibling at the start of the actions row.
108-
return el.firstElementChild ?? el;
109-
}
97+
interface Placement {
98+
parent: Element;
99+
/** Element to insert *before*. `null` means append as last child. */
100+
before: Element | null;
101+
}
102+
103+
/**
104+
* Find where to insert the Conductor widget.
105+
*
106+
* Preferred location: directly above the Reviewers section in the PR's right
107+
* sidebar (`#reviewers-select-menu`). We fall back to the top of the sidebar,
108+
* then to the PR header — so the button stays visible across GitHub
109+
* redesigns and across the PR conversation/files/commits tabs.
110+
*
111+
* Selectors are taken from the patterns Refined GitHub uses
112+
* (`#partial-discussion-sidebar`, `#reviewers-select-menu`), which have been
113+
* stable for years because they come from server-side Rails partials.
114+
*/
115+
function findPlacement(): Placement | null {
116+
// 1. Best: insert above the Reviewers section
117+
const reviewers = document.querySelector('#reviewers-select-menu');
118+
if (reviewers?.parentElement) {
119+
return { parent: reviewers.parentElement, before: reviewers };
120+
}
121+
122+
// 2. Next best: prepend to the sidebar so it's still in the right column
123+
const sidebar = document.querySelector('#partial-discussion-sidebar');
124+
if (sidebar) {
125+
return { parent: sidebar, before: sidebar.firstElementChild };
110126
}
127+
128+
// 3. Fallback: next to the PR header actions
129+
const headerActions = document.querySelector(
130+
'.gh-header-actions, [data-testid="pr-header-actions"]',
131+
);
132+
if (headerActions) {
133+
return { parent: headerActions, before: headerActions.firstElementChild };
134+
}
135+
111136
return null;
112137
}
113138

114-
function createButton(): HTMLButtonElement {
139+
/**
140+
* Build the sidebar widget — styled like a native GitHub `discussion-sidebar-item`.
141+
*
142+
* Layout:
143+
* ┌─────────────────────────────┐
144+
* │ Conductor │ ← heading (matches native sidebar)
145+
* ├─────────────────────────────┤
146+
* │ [▶ Conductor: Review PR ⌄] │ ← primary button + preset dropdown
147+
* └─────────────────────────────┘
148+
*/
149+
function createWidget(): HTMLElement {
150+
const wrapper = document.createElement('div');
151+
wrapper.id = WIDGET_ID;
152+
wrapper.className = 'conductor-widget discussion-sidebar-item';
153+
wrapper.setAttribute(INJECTED_ATTR, 'true');
154+
155+
const heading = document.createElement('h3');
156+
heading.className = 'conductor-widget-heading discussion-sidebar-heading';
157+
heading.textContent = 'Conductor';
158+
wrapper.appendChild(heading);
159+
160+
const row = document.createElement('div');
161+
row.className = 'conductor-widget-row';
162+
wrapper.appendChild(row);
163+
164+
row.appendChild(createPrimaryButton());
165+
if (currentSettings && currentSettings.presets.length > 1) {
166+
row.appendChild(createPresetSelect());
167+
}
168+
169+
return wrapper;
170+
}
171+
172+
function createPrimaryButton(): HTMLButtonElement {
115173
const button = document.createElement('button');
116174
button.id = BUTTON_ID;
117175
button.type = 'button';
118-
button.className = 'conductor-pr-button btn btn-sm';
119-
button.setAttribute(INJECTED_ATTR, 'true');
176+
button.className = 'conductor-pr-button';
120177
button.title = 'Open this PR in a new Conductor workspace';
121178

122-
// Build the label with safe DOM construction (no innerHTML).
123179
const icon = document.createElement('span');
124180
icon.className = 'conductor-pr-button-icon';
125181
icon.setAttribute('aria-hidden', 'true');
126-
icon.textContent = '▶'; // ▶
182+
icon.textContent = '▶';
127183
button.appendChild(icon);
128184

129185
const label = document.createElement('span');
186+
label.className = 'conductor-pr-button-label';
130187
const presetName = currentSettings ? getDefaultPreset(currentSettings)?.name : null;
131188
label.textContent = presetName ? `Conductor: ${presetName}` : 'Open in Conductor';
132189
button.appendChild(label);
@@ -139,6 +196,44 @@ function createButton(): HTMLButtonElement {
139196
return button;
140197
}
141198

199+
/**
200+
* Dropdown listing every configured preset (visible when there are 2+).
201+
*
202+
* Choosing a preset from the dropdown immediately fires that preset and
203+
* resets the select to its first option, so the dropdown acts as a one-shot
204+
* launcher rather than a persistent selector.
205+
*/
206+
function createPresetSelect(): HTMLSelectElement {
207+
const select = document.createElement('select');
208+
select.className = 'conductor-preset-select';
209+
select.title = 'Pick a different preset';
210+
211+
const placeholder = document.createElement('option');
212+
placeholder.value = '';
213+
placeholder.textContent = '⌄';
214+
placeholder.disabled = true;
215+
placeholder.selected = true;
216+
select.appendChild(placeholder);
217+
218+
if (currentSettings) {
219+
for (const preset of currentSettings.presets) {
220+
const opt = document.createElement('option');
221+
opt.value = preset.id;
222+
opt.textContent = preset.name;
223+
select.appendChild(opt);
224+
}
225+
}
226+
227+
select.addEventListener('change', () => {
228+
const presetId = select.value;
229+
select.selectedIndex = 0;
230+
if (!presetId) return;
231+
void runPreset(presetId);
232+
});
233+
234+
return select;
235+
}
236+
142237
function getDefaultPreset(settings: Settings): Preset | undefined {
143238
return settings.presets.find((p) => p.id === settings.defaultPresetId) ?? settings.presets[0];
144239
}

0 commit comments

Comments
 (0)