fraim-hub 2.0.340 → 2.0.342

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.
@@ -2234,6 +2234,16 @@ function composerIsArmed() {
2234
2234
  function syncComposerPlaceholder(conv = activeConversation()) {
2235
2235
  const textarea = els['coach-text'];
2236
2236
  if (!textarea || textarea.disabled) return;
2237
+ // Issue #1911 R6: a pane surface that claims the composer states what to type
2238
+ // for its own mode, and outranks every case below it.
2239
+ const surfaceComposer = paneSurfaceComposerContract();
2240
+ if (surfaceComposer && typeof surfaceComposer.placeholder === 'function') {
2241
+ const surfacePlaceholder = surfaceComposer.placeholder(PaneSurfaces.activeContext());
2242
+ if (surfacePlaceholder) {
2243
+ textarea.placeholder = surfacePlaceholder;
2244
+ return;
2245
+ }
2246
+ }
2237
2247
  if (state.cpSelectedJob) {
2238
2248
  textarea.placeholder = textarea.dataset.placeholderArmed || 'Now type what you want done...';
2239
2249
  } else if (convAwaitingReview(conv)) {
@@ -3954,11 +3964,15 @@ function formatMicroLogLine(event, mode = 'events') {
3954
3964
 
3955
3965
  function renderActive() {
3956
3966
  const conv = activeConversation();
3967
+ // Issue #1911 R7: a focused conversation owns the pane. Evicting here (rather
3968
+ // than leaving a surface rendering behind the thread) is what makes the slot
3969
+ // safe to reuse - every surface gets the same eviction rule for free.
3970
+ if (conv && paneSurfaceOpen() && (typeof PaneSurfaces.isSubmitting !== 'function' || !PaneSurfaces.isSubmitting())) PaneSurfaces.close({ restoreFocus: false });
3957
3971
  if (typeof syncTaskPaneSurfaceMode === 'function') syncTaskPaneSurfaceMode();
3958
3972
  if (typeof tfApplyWorkspaceMode === 'function') tfApplyWorkspaceMode();
3959
3973
  if (!conv) {
3960
3974
  syncMicroArchive(null);
3961
- els['empty'].hidden = false;
3975
+ els['empty'].hidden = paneSurfaceOpen();
3962
3976
  els['active-conv'].hidden = true;
3963
3977
  renderHubAgentSetupPanel();
3964
3978
  const _c = document.getElementById('conversation');
@@ -5664,23 +5678,16 @@ function latestEmployeeSurfaceText(conv) {
5664
5678
  }
5665
5679
 
5666
5680
  /**
5667
- * Issue #1755: a sleep-on-learnings message carries its proposal set as a
5668
- * "## Pending Review" markdown block. That block is the Hub's input, not prose
5669
- * the manager should read: it renders below as cards they can act on. Printing
5670
- * both puts every lesson title on the screen twice.
5681
+ * Issue #1857: the strip step is gone along with the block it stripped.
5671
5682
  *
5672
- * Only the block is dropped, and only when the cards are actually rendering.
5673
- * Whatever the job wrote above it (its analysis summary) is what the manager
5674
- * came to read, so that stays. When no cards render, nothing is stripped, so a
5675
- * proposal set can never go missing.
5683
+ * It existed because the job printed its proposal set as markdown into the turn
5684
+ * and the Hub rendered cards from the same text, so both appeared. The set now
5685
+ * travels as structured evidence and never appears in the prose, so there is
5686
+ * nothing to remove. Kept as an identity function only so the single call site
5687
+ * reads unchanged; removing it there is a separate, noisier diff.
5676
5688
  */
5677
- function tfStripRenderedProposalBlock(text, conv) {
5678
- const raw = String(text || '');
5679
- if (!conv || conv.jobId !== 'sleep-on-learnings') return raw;
5680
- const marker = raw.search(/^##\s*Pending Review\s*$/im);
5681
- if (marker === -1) return raw;
5682
- if (typeof tfConvHasProposals !== 'function' || !tfConvHasProposals(conv)) return raw;
5683
- return raw.slice(0, marker).trimEnd();
5689
+ function tfStripRenderedProposalBlock(text) {
5690
+ return String(text || '');
5684
5691
  }
5685
5692
 
5686
5693
  function appendMessageDom(role, text, conv, message) {
@@ -5746,7 +5753,7 @@ function appendMessageDom(role, text, conv, message) {
5746
5753
  // surfaceText collapses whitespace which destroys markdown. Only use it
5747
5754
  // when it returned a transformed value (Started/Resumed prefix overrides).
5748
5755
  const useSurfaced = surfaced && surfaced !== raw.replace(/\s+/g, ' ').trim();
5749
- const content = tfStripRenderedProposalBlock(useSurfaced ? surfaced : raw, conv);
5756
+ const content = tfStripRenderedProposalBlock(useSurfaced ? surfaced : raw);
5750
5757
  bubble.innerHTML = formatEmployeeText(content || (conv.status === 'completed' ? 'Done, please review.' : 'Working on it…'));
5751
5758
  } else {
5752
5759
  bubble.textContent = role === 'manager' ? managerBubbleText(text) : (surfaceText(role, text, conv) || text);
@@ -6267,6 +6274,14 @@ function syncSendButton() {
6267
6274
  syncComposerStopButton(conv);
6268
6275
  return;
6269
6276
  }
6277
+ // Issue #1911 R10: while a pane surface owns the composer, Send stays enabled
6278
+ // so pressing it can report "Job details are required." rather than doing
6279
+ // nothing. The retired dialog's own submit button was always enabled too.
6280
+ if (paneSurfaceComposerContract()) {
6281
+ els['send'].disabled = false;
6282
+ syncComposerStopButton(conv);
6283
+ return;
6284
+ }
6270
6285
  // Issue #1651 R6: a valid staged attachment is enough to enable Send even
6271
6286
  // with an empty textarea, same as a pending coaching job.
6272
6287
  const hasAttachment = hasValidStagedAttachment();
@@ -7094,9 +7109,11 @@ function renderLearningProposalTurn(conv) {
7094
7109
  if (existing) existing.remove();
7095
7110
  if (!conv || conv.jobId !== 'sleep-on-learnings' || !tfConvHasProposals(conv)) return;
7096
7111
 
7097
- // The scope the run was asked for decides where an approved lesson is written.
7098
- const scope = tfProposalScopeForConversation(conv);
7099
- const proposals = tfParsePendingProposals(conv, scope);
7112
+ // Issue #1857: the scope arrives on the set rather than being re-derived from
7113
+ // the kickoff text, so a decision reaches the corpus its run was scoped to even
7114
+ // when the kickoff wording is ambiguous.
7115
+ const scope = conv.proposalSet.runScope || tfProposalScopeForConversation(conv);
7116
+ const proposals = tfPendingProposals(conv);
7100
7117
  if (!proposals.length) return;
7101
7118
  // Keep the relay pointed at this run even if the accordion never registered it.
7102
7119
  tfLearningRunByScope[tfLearningRunKey(scope)] = conv;
@@ -7110,7 +7127,7 @@ function renderLearningProposalTurn(conv) {
7110
7127
 
7111
7128
  const label = document.createElement('div');
7112
7129
  label.className = 'review-turn-label';
7113
- const undecided = proposals.filter((p) => !tfProposalDecisions[p.id]).length;
7130
+ const undecided = proposals.filter((p) => !tfProposalDecisions[tfProposalKey(p)]).length;
7114
7131
  label.textContent = undecided
7115
7132
  ? `${undecided} lesson${undecided === 1 ? '' : 's'} waiting on your decision`
7116
7133
  : 'Lessons reviewed';
@@ -7122,6 +7139,34 @@ function renderLearningProposalTurn(conv) {
7122
7139
  for (const p of proposals) list.appendChild(tfProposalCard(p, scope));
7123
7140
  bubble.appendChild(list);
7124
7141
 
7142
+ // Issue #1857 R7. A claim the manager already decided is not a card, because
7143
+ // re-asking a settled question is the cost this issue exists to remove. It is
7144
+ // also not silence: suppression the manager cannot see is a system quietly
7145
+ // deciding on their behalf. One line each, after the batch.
7146
+ const suppressed = tfSuppressedClaims(conv);
7147
+ if (suppressed.length) {
7148
+ const note = document.createElement('div');
7149
+ note.className = 'learn-suppressed';
7150
+ note.dataset.testid = 'learn-suppressed';
7151
+ const lead = document.createElement('div');
7152
+ lead.className = 'ls-lead';
7153
+ lead.textContent = `Not shown: ${suppressed.length} claim${suppressed.length === 1 ? '' : 's'} `
7154
+ + `matched ${suppressed.length === 1 ? 'a decision' : 'decisions'} you already made.`;
7155
+ note.appendChild(lead);
7156
+ for (const claim of suppressed) {
7157
+ const line = document.createElement('div');
7158
+ line.className = 'ls-row';
7159
+ // D2: a claim a rule or skill now owns was promoted, not refused. Saying
7160
+ // "you rejected this" about work the manager approved elsewhere is wrong.
7161
+ line.textContent = claim.kind === 'superseded'
7162
+ ? `"${claim.claimKey}" superseded ${claim.rejectedAt}: ${claim.reason}`
7163
+ : `"${claim.claimKey}" rejected ${claim.rejectedAt}: "${claim.reason}"`
7164
+ + (claim.newSourceCount ? ` - ${claim.newSourceCount} new source${claim.newSourceCount === 1 ? '' : 's'}` : '');
7165
+ note.appendChild(line);
7166
+ }
7167
+ bubble.appendChild(note);
7168
+ }
7169
+
7125
7170
  row.appendChild(bubble);
7126
7171
  // Below the thread, above the deliverables/next-job furniture.
7127
7172
  host.insertBefore(row, host.querySelector('#deliverables-bar, #next-job-section'));
@@ -7485,6 +7530,93 @@ function managementActionContext(area) {
7485
7530
  return { area: area || (typeof tf !== 'undefined' && tf.area) || 'projects', projectPath: state.projectPath || '' };
7486
7531
  }
7487
7532
 
7533
+ // A pane surface takes over the conversation pane, so the focused conversation
7534
+ // has to be released before it opens. Skipping this leaves state.activeId set,
7535
+ // and the next poll-driven renderActive() evicts the surface a second later.
7536
+ // This is the same defocus focusComposerForDelegate() already performs; only
7537
+ // the composer seeding differs.
7538
+ function releasePaneForSurface() {
7539
+ if (!state.activeId) return;
7540
+ state.activeId = null;
7541
+ persistActiveConversationSelection(null, tf.area === 'projects' ? { projectPath: state.projectPath || '' } : { scope: tf.area });
7542
+ tf.areaSelection[tfAreaSelectionKey(tf.area)] = 'delegate';
7543
+ if (tf.area === 'company' || tf.area === 'manager') {
7544
+ tf.areaSelection[tf.area] = 'delegate';
7545
+ if (tf.area === 'company') tfRenderCompany();
7546
+ else tfRenderManager();
7547
+ } else {
7548
+ renderRail();
7549
+ renderActive();
7550
+ }
7551
+ }
7552
+
7553
+ // Issue #1911: Coach's intake is a pane surface registered by
7554
+ // coach-intake-surface.js, which loads after this file. Guarded so a failed
7555
+ // asset load reports itself instead of throwing a ReferenceError on click.
7556
+ function openCoachSurface(actionContext) {
7557
+ if (typeof openCoachIntake !== 'function') {
7558
+ showStatus('The coaching panel is still loading. Reload FRAIM Hub if this persists.', true);
7559
+ return;
7560
+ }
7561
+ const context = actionContext || managementActionContext();
7562
+ // The pane surface host lives inside #area-projects. Switch there first so
7563
+ // the panel is visible regardless of which tab the manager clicked Coach from,
7564
+ // matching the Delegate behavior (focusComposerForDelegate does the same).
7565
+ if (tf.area !== 'projects') tfShowArea('projects');
7566
+ // Coach claims both the pane and the composer, so release both first. An
7567
+ // armed Delegate job would otherwise reappear in the chip the moment Coach
7568
+ // closes (the defect #1657's post-launch fix had to correct twice).
7569
+ hideComposerSuggestions();
7570
+ releasePaneForSurface();
7571
+ if (els['coach-text']) els['coach-text'].value = '';
7572
+ openCoachIntake(context);
7573
+ syncSendButton();
7574
+ }
7575
+
7576
+ // ---------------------------------------------------------------------------
7577
+ // Issue #1911 R16: main-pane surface host integration
7578
+ //
7579
+ // PaneSurfaces owns the #pane-surface-host slot and the optional composer
7580
+ // contract. Everything below is the app side of that contract: which pane
7581
+ // element wins, who writes the chip, and where Send goes. No surface-specific
7582
+ // knowledge lives here either - this works the same for Coach and for whatever
7583
+ // registers next.
7584
+ // ---------------------------------------------------------------------------
7585
+
7586
+ function paneSurfaceOpen() {
7587
+ return typeof PaneSurfaces !== 'undefined' && !!PaneSurfaces.activeId();
7588
+ }
7589
+
7590
+ // Binds the generic host to this application's DOM. Every Hub-specific id lives
7591
+ // here rather than in pane-surfaces.js, which is what lets that module stay a
7592
+ // reusable slot instead of a Coach panel with a registry bolted on.
7593
+ function configurePaneSurfaceHost() {
7594
+ if (typeof PaneSurfaces === 'undefined') return;
7595
+ PaneSurfaces.configure({
7596
+ hostSelector: '#pane-surface-host',
7597
+ composerTextSelector: '#coach-text',
7598
+ chipSelector: '#cp-armed-job-chip',
7599
+ chipLabelSelector: '#cp-armed-job-chip .cp-armed-job-label',
7600
+ chipClearSelector: '#cp-armed-job-clear',
7601
+ composerErrorSelector: '#pane-surface-composer-error',
7602
+ onChange: onPaneSurfaceChange,
7603
+ });
7604
+ }
7605
+
7606
+ function paneSurfaceComposerContract() {
7607
+ return typeof PaneSurfaces === 'undefined' ? null : PaneSurfaces.composerContract();
7608
+ }
7609
+
7610
+ // Called by PaneSurfaces on every open, close and successful submit. It must
7611
+ // never call renderActive(): renderActive() closes surfaces, and the two would
7612
+ // re-enter each other.
7613
+ function onPaneSurfaceChange() {
7614
+ if (els['empty']) els['empty'].hidden = paneSurfaceOpen() || !!activeConversation();
7615
+ if (paneSurfaceOpen() && els['active-conv']) els['active-conv'].hidden = true;
7616
+ renderArmedJobChip();
7617
+ syncSendButton();
7618
+ }
7619
+
7488
7620
  function bindManagementDialog(overlay, close, trigger) {
7489
7621
  overlay.addEventListener('keydown', (event) => {
7490
7622
  if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(); return; }
@@ -7518,7 +7650,7 @@ function appendManagementActions(rail, area) {
7518
7650
  button.textContent = label;
7519
7651
  if (!existing) button.addEventListener('click', () => {
7520
7652
  if (action === 'delegate') focusComposerForDelegate({ actionContext: managementActionContext(area) });
7521
- else if (action === 'coach') openTeachJobDialog(managementActionContext(area));
7653
+ else if (action === 'coach') openCoachSurface(managementActionContext(area));
7522
7654
  else openRosterDialog(area);
7523
7655
  });
7524
7656
  actions.appendChild(button);
@@ -7592,209 +7724,14 @@ function teachJobOwnerLabel(job) {
7592
7724
  return persona ? (persona.displayName || key) : key;
7593
7725
  }
7594
7726
 
7595
- function clearTeachFieldErrors() {
7596
- ['tjd-job-name-error', 'tjd-owner-error', 'tjd-details-error', 'tjd-submit-error'].forEach((id) => {
7597
- const el = document.getElementById(id);
7598
- if (el) el.textContent = '';
7599
- });
7600
- }
7601
-
7602
- function setTeachSelectedMimic(job) {
7603
- state.teachSelectedMimicJob = job || null;
7604
- if (job && document.getElementById('tjd-mode')?.value === 'customize') {
7605
- document.getElementById('tjd-submit-error').textContent = '';
7606
- }
7607
- document.querySelectorAll('#tjd-mimic-results .tjd-mimic-row').forEach((row) => {
7608
- row.classList.toggle('selected', !!job && row.dataset.jobId === job.id);
7609
- });
7610
- const selected = document.getElementById('tjd-selected-mimic');
7611
- const label = document.getElementById('tjd-selected-mimic-label');
7612
- if (!selected || !label) return;
7613
- if (!job) {
7614
- selected.hidden = true;
7615
- label.textContent = '';
7616
- return;
7617
- }
7618
- const owner = teachJobOwnerLabel(job);
7619
- const prefix = document.getElementById('tjd-mode').value === 'customize' ? 'Correct' : 'Mimic';
7620
- label.textContent = `${prefix}: ${job.title || job.id} (${job.id}) · ${owner}`;
7621
- selected.hidden = false;
7622
- }
7623
-
7624
- function buildTeachMimicRow(job) {
7625
- const row = document.createElement('button');
7626
- row.type = 'button';
7627
- row.className = 'cp-row tjd-mimic-row';
7628
- row.dataset.jobId = job.id;
7629
-
7630
- const icon = document.createElement('span');
7631
- icon.className = 'cp-row-icon';
7632
- icon.textContent = 'J';
7633
- row.appendChild(icon);
7634
-
7635
- const body = document.createElement('span');
7636
- body.className = 'cp-row-body';
7637
- const title = document.createElement('span');
7638
- title.className = 'cp-row-title';
7639
- title.textContent = job.title || job.id;
7640
- body.appendChild(title);
7641
- const sub = document.createElement('span');
7642
- sub.className = 'cp-row-sub';
7643
- sub.textContent = job.intent || job.id;
7644
- body.appendChild(sub);
7645
- row.appendChild(body);
7646
-
7647
- const tag = document.createElement('span');
7648
- tag.className = 'cp-row-tag';
7649
- tag.textContent = job.requiredPersonaKey || 'general';
7650
- row.appendChild(tag);
7651
-
7652
- row.addEventListener('click', () => setTeachSelectedMimic(job));
7653
- return row;
7654
- }
7655
-
7656
- function renderTeachMimicJobs() {
7657
- const list = document.getElementById('tjd-mimic-results');
7658
- const empty = document.getElementById('tjd-mimic-empty');
7659
- const search = document.getElementById('tjd-mimic-search');
7660
- if (!list || !empty) return;
7661
- const q = search ? search.value.trim().toLowerCase() : '';
7662
- const jobs = teachJobCatalog().filter((job) => {
7663
- if (!q) return true;
7664
- const owner = teachJobOwnerLabel(job);
7665
- return [
7666
- job.id,
7667
- job.title,
7668
- job.intent,
7669
- job.requiredPersonaKey,
7670
- owner,
7671
- ].filter(Boolean).some((value) => String(value).toLowerCase().includes(q));
7672
- });
7673
- list.innerHTML = '';
7674
- jobs.forEach((job) => list.appendChild(buildTeachMimicRow(job)));
7675
- empty.hidden = jobs.length !== 0;
7676
- if (state.teachSelectedMimicJob && !jobs.some((job) => job.id === state.teachSelectedMimicJob.id)) {
7677
- setTeachSelectedMimic(state.teachSelectedMimicJob);
7678
- } else {
7679
- setTeachSelectedMimic(state.teachSelectedMimicJob || null);
7680
- }
7681
- }
7682
-
7683
- function populateTeachOwners() {
7684
- const owner = document.getElementById('tjd-owner');
7685
- if (!owner) return;
7686
- owner.innerHTML = '';
7687
- const placeholder = document.createElement('option');
7688
- placeholder.value = '';
7689
- placeholder.textContent = 'Choose an employee...';
7690
- owner.appendChild(placeholder);
7691
- for (const option of teachOwnerOptions()) {
7692
- const el = document.createElement('option');
7693
- el.value = option.key;
7694
- el.textContent = `${option.label} - ${option.role}`;
7695
- owner.appendChild(el);
7696
- }
7697
- }
7698
-
7699
- function setTeachMode(mode) {
7700
- const correcting = mode === 'customize';
7701
- document.getElementById('tjd-mode').value = correcting ? 'customize' : 'new';
7702
- document.querySelectorAll('.tjd-mode').forEach((button) => {
7703
- button.setAttribute('aria-pressed', String(button.dataset.mode === mode));
7704
- });
7705
- document.getElementById('tjd-new-fields').hidden = correcting;
7706
- document.getElementById('tjd-owner').disabled = correcting;
7707
- const mimic = document.getElementById('tjd-mimic');
7708
- // Keep keyboard order aligned with the visible mode-specific field order.
7709
- document.getElementById('tjd-form').insertBefore(mimic, correcting
7710
- ? document.querySelector('.tjd-details-field')
7711
- : document.getElementById('tjd-correction-hint'));
7712
- mimic.open = correcting;
7713
- document.getElementById('tjd-mimic-toggle').hidden = correcting;
7714
- document.querySelector('label[for="tjd-mimic-search"]').textContent = correcting ? 'Which job needs correcting?' : 'Mimic an existing job (optional)';
7715
- document.querySelector('label[for="tjd-details"]').textContent = correcting ? 'What should change?' : 'Details';
7716
- document.getElementById('tjd-details').placeholder = correcting ? 'Tell Mandy what to change.' : 'What should this job do?';
7717
- document.getElementById('tjd-correction-hint').hidden = !correcting;
7718
- document.getElementById('tjd-submit').textContent = correcting ? 'Send correction to Mandy' : 'Delegate to Mandy';
7719
- document.getElementById('tjd-mimic-empty').textContent = correcting ? 'No matching jobs. Try another search.' : 'No matching jobs. Mandy will still check for duplicates.';
7720
- clearTeachFieldErrors();
7721
- setTeachSelectedMimic(null);
7722
- }
7723
-
7724
- function openTeachJobDialog(actionContext) {
7725
- const modal = document.getElementById('teach-job-modal');
7726
- const jobName = document.getElementById('tjd-job-name');
7727
- const owner = document.getElementById('tjd-owner');
7728
- const details = document.getElementById('tjd-details');
7729
- const search = document.getElementById('tjd-mimic-search');
7730
- if (!modal || !jobName || !owner || !details || !search) return;
7731
- clearTeachFieldErrors();
7732
- state.teachActionContext = actionContext || managementActionContext();
7733
- state.teachTrigger = document.activeElement;
7734
- setTeachMode('new');
7735
- const area = state.teachActionContext.area;
7736
- document.getElementById('tjd-scope-note').textContent = area === 'company' ? 'Company jobs' : area === 'manager' ? 'Your jobs' : "This project's jobs";
7737
- state.teachSelectedMimicJob = null;
7738
- jobName.value = '';
7739
- details.value = '';
7740
- search.value = '';
7741
- populateTeachOwners();
7742
- renderTeachMimicJobs();
7743
- modal.hidden = false;
7744
- jobName.focus();
7745
- }
7746
-
7747
- function closeTeachJobDialog() {
7748
- const modal = document.getElementById('teach-job-modal');
7749
- if (modal) modal.hidden = true;
7750
- state.teachSelectedMimicJob = null;
7751
- if (state.teachTrigger && state.teachTrigger.isConnected) state.teachTrigger.focus();
7752
- }
7753
-
7754
- function validateTeachJobDialog() {
7755
- clearTeachFieldErrors();
7756
- const jobName = document.getElementById('tjd-job-name');
7757
- const owner = document.getElementById('tjd-owner');
7758
- const details = document.getElementById('tjd-details');
7759
- if (!jobName || !owner || !details) return null;
7760
- const mode = document.getElementById('tjd-mode').value;
7761
- if (mode === 'customize' && !state.teachSelectedMimicJob) {
7762
- document.getElementById('tjd-submit-error').textContent = 'Choose the existing job to improve.';
7763
- document.getElementById('tjd-mimic-search').focus();
7764
- return null;
7765
- }
7766
- if (mode !== 'customize' && !jobName.value.trim()) {
7767
- const err = document.getElementById('tjd-job-name-error');
7768
- if (err) err.textContent = 'Job name is required.';
7769
- jobName.focus();
7770
- return null;
7771
- }
7772
- if (mode !== 'customize' && !owner.value) {
7773
- const err = document.getElementById('tjd-owner-error');
7774
- if (err) err.textContent = 'Choose the employee who should own this job.';
7775
- owner.focus();
7776
- return null;
7777
- }
7778
- if (!details.value.trim()) {
7779
- const err = document.getElementById('tjd-details-error');
7780
- if (err) err.textContent = 'Job details are required.';
7781
- details.focus();
7782
- return null;
7783
- }
7784
- const ownerKey = mode === 'customize' ? (state.teachSelectedMimicJob.requiredPersonaKey || GENERIC_WORKER_PERSONA.key) : owner.value;
7785
- const ownerOption = teachOwnerOptions().find((option) => option.key === ownerKey) || {
7786
- key: ownerKey,
7787
- label: ownerKey,
7788
- role: 'AI Employee',
7789
- };
7790
- return {
7791
- mode,
7792
- jobName: mode === 'customize' ? (state.teachSelectedMimicJob.title || state.teachSelectedMimicJob.id) : jobName.value.trim(),
7793
- owner: ownerOption,
7794
- details: details.value.trim(),
7795
- mimic: state.teachSelectedMimicJob || null,
7796
- };
7797
- }
7727
+ // Issue #1911: the #teach-job-modal intake (clearTeachFieldErrors,
7728
+ // setTeachSelectedMimic, buildTeachMimicRow, renderTeachMimicJobs,
7729
+ // populateTeachOwners, setTeachMode, openTeachJobDialog, closeTeachJobDialog,
7730
+ // validateTeachJobDialog, submitTeachJobDialog) is retired. Coach now renders
7731
+ // into the main pane through PaneSurfaces; see coach-intake-surface.js, which
7732
+ // reuses teachJobCatalog/teachJobOwnerLabel/teachOwnerOptions and calls the
7733
+ // buildTeachIntakeInstructions() below unchanged - the payload and the
7734
+ // evolve-employee/Mandy run are the same ones spec #976 established.
7798
7735
 
7799
7736
  function buildTeachIntakeInstructions(intake) {
7800
7737
  const mimic = intake.mimic
@@ -7825,26 +7762,6 @@ function buildTeachIntakeInstructions(intake) {
7825
7762
  ].join('\n');
7826
7763
  }
7827
7764
 
7828
- async function submitTeachJobDialog() {
7829
- const intake = validateTeachJobDialog();
7830
- if (!intake) return;
7831
- const submit = document.getElementById('tjd-submit');
7832
- if (submit) submit.disabled = true;
7833
- const employeeId = normalizeConfiguredAgentChoice((state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.employeeId) || 'claude');
7834
- const result = await startRun({
7835
- id: 'evolve-employee',
7836
- title: intake.mode === 'customize' ? 'Improve a job' : 'Teach a new job',
7837
- requiredPersonaKey: 'mandy',
7838
- }, buildTeachIntakeInstructions(intake), employeeId, undefined, state.teachActionContext?.area, undefined, state.teachActionContext);
7839
- if (submit) submit.disabled = false;
7840
- if (result && result.ok === false) {
7841
- const err = document.getElementById('tjd-submit-error');
7842
- if (err) err.textContent = result.error || 'Could not start the teaching run.';
7843
- return;
7844
- }
7845
- closeTeachJobDialog();
7846
- }
7847
-
7848
7765
  // Comments follow the artifact (R7.7): a PR opens GitHub; local artifacts open
7849
7766
  // from disk; reports view inline. Nothing new is invented - these surface what
7850
7767
  // the read-side already knows and otherwise leave a status hint.
@@ -8092,6 +8009,9 @@ function wirePopovers() {
8092
8009
  }
8093
8010
 
8094
8011
  if (e.key === 'Escape') {
8012
+ // Issue #1911 R15/R9a: an open pane surface is the innermost thing on
8013
+ // screen, so it cancels first - and its own onEscape can veto.
8014
+ if (typeof PaneSurfaces !== 'undefined' && PaneSurfaces.handleEscape()) return;
8095
8015
  const suggestions = document.getElementById('composer-suggestions');
8096
8016
  const assignModal = document.getElementById('dep-assignment-modal');
8097
8017
  const aomModal = document.getElementById('area-onboard-modal');
@@ -8103,7 +8023,7 @@ function wirePopovers() {
8103
8023
  assignModal.hidden = true;
8104
8024
  } else if (state.cpSelectedJob) {
8105
8025
  // Back out of an armed Delegate Job pick (see armSelectedJob) without
8106
- // sending. Teach has its own modal (#teach-job-modal), not covered here.
8026
+ // sending. Coach is a pane surface and is handled above this branch.
8107
8027
  clearArmedJob();
8108
8028
  } else {
8109
8029
  closePopovers();
@@ -8130,6 +8050,10 @@ function parseCpSearch(text) {
8130
8050
  // personaKey/prefixSearch are still honored (e.g. a custom employee's own
8131
8051
  // "+" button) by seeding state and the composer text directly.
8132
8052
  function focusComposerForDelegate(opts) {
8053
+ // Issue #1911 edge case: Delegate and Coach both want the pane and the
8054
+ // composer. The second click replaces the first surface rather than layering a
8055
+ // suggestion list over a panel.
8056
+ if (paneSurfaceOpen()) PaneSurfaces.close({ restoreFocus: false });
8133
8057
  state.cpActionContext = (opts && opts.actionContext) || managementActionContext(state.pendingNextJobArea || undefined);
8134
8058
  state.activeId = null;
8135
8059
  persistActiveConversationSelection(null, tf.area === 'projects' ? { projectPath: state.projectPath || '' } : { scope: tf.area });
@@ -8227,13 +8151,21 @@ function renderComposerSuggestions(searchText) {
8227
8151
 
8228
8152
  // Issue #1657 R1 (Treatment 3): the single composer's send action, resolved
8229
8153
  // by context - an idle catalog/ad-hoc delegate, or coaching an
8230
- // already-focused conversation. Teach has its own modal (#teach-job-modal)
8231
- // and never reaches this function. This is the only thing that changes
8154
+ // already-focused conversation. A pane surface that claims the composer
8155
+ // (issue #1911) intercepts before any of that. This is the only thing that changes
8232
8156
  // across every path; the box itself never does.
8233
8157
  async function sendFromComposer() {
8234
8158
  const submitDelegate = (typeof window !== 'undefined' && typeof window.submitComposerDelegate === 'function')
8235
8159
  ? window.submitComposerDelegate
8236
8160
  : submitComposerDelegate;
8161
+ // Issue #1911 R1: a pane surface that claims the composer takes Send first, so
8162
+ // the detailed instruction typed here is submitted by that surface instead of
8163
+ // being started as an ad-hoc delegate.
8164
+ if (typeof PaneSurfaces !== 'undefined'
8165
+ && await PaneSurfaces.submitFromComposer(els['coach-text'] ? els['coach-text'].value : '')) {
8166
+ syncSendButton();
8167
+ return;
8168
+ }
8237
8169
  if (!state.activeId) {
8238
8170
  // Issue #989: a next-job-recommendation chip can pre-select a job (and
8239
8171
  // pre-fill instructions) without going through the suggestion list at
@@ -8573,7 +8505,8 @@ function renderCpHighlight() {
8573
8505
  // (armSelectedJob) and clears the box for the manager to type real
8574
8506
  // instructions, instead of sending immediately. Ad-hoc remains immediate-fire:
8575
8507
  // there is no separate name/instructions split - the typed text already IS the
8576
- // instructions. Teach is out of this catalog entirely (see #teach-btn/#teach-job-modal).
8508
+ // instructions. Coach is out of this catalog entirely (see #teach-btn and the
8509
+ // coach-intake pane surface).
8577
8510
  function selectCpRow(index) {
8578
8511
  const row = state.cpRows[index];
8579
8512
  if (!row) return;
@@ -8591,6 +8524,7 @@ function selectCpRow(index) {
8591
8524
  // instructions) and waits for the manager to type real instructions, then
8592
8525
  // send (sendFromComposer's existing state.cpSelectedJob branch handles that).
8593
8526
  function armSelectedJob(job) {
8527
+ if (paneSurfaceOpen()) PaneSurfaces.close({ restoreFocus: false });
8594
8528
  state.cpBrowseAll = false;
8595
8529
  state.cpActionContext = state.cpActionContext || managementActionContext(state.pendingNextJobArea || undefined);
8596
8530
  state.cpSelectedJob = job;
@@ -8615,21 +8549,25 @@ function clearArmedJob() {
8615
8549
  }
8616
8550
 
8617
8551
  // Shows the "Delegating to:" confirmation for a regular armed Delegate Job
8618
- // catalog pick. Teach has its own modal (#teach-job-modal) and never sets
8619
- // state.cpSelectedJob, so this chip needs no Teach-specific branch.
8552
+ // catalog pick. Coach never sets state.cpSelectedJob; it writes this chip
8553
+ // through the pane-surface composer contract instead.
8620
8554
  function renderArmedJobChip() {
8621
8555
  const chip = document.getElementById('cp-armed-job-chip');
8622
- const textEl = els['coach-text'];
8623
8556
  if (!chip) return;
8557
+ // Issue #1911: an open pane surface owns the chip while it is up and says what
8558
+ // it is coaching or configuring in its own words.
8559
+ if (typeof PaneSurfaces !== 'undefined' && PaneSurfaces.renderComposerChrome()) return;
8560
+ const label = chip.querySelector('.cp-armed-job-label');
8561
+ const clear = document.getElementById('cp-armed-job-clear');
8562
+ if (clear) clear.setAttribute('aria-label', 'Change job');
8624
8563
  if (state.cpSelectedJob) {
8625
8564
  chip.hidden = false;
8626
- const label = chip.querySelector('.cp-armed-job-label');
8627
- if (label) label.textContent = state.cpSelectedJob.title || state.cpSelectedJob.id;
8628
- syncComposerPlaceholder();
8565
+ if (label) label.textContent = 'Delegating to: ' + (state.cpSelectedJob.title || state.cpSelectedJob.id);
8629
8566
  } else {
8630
8567
  chip.hidden = true;
8631
- syncComposerPlaceholder();
8568
+ if (label) label.textContent = '';
8632
8569
  }
8570
+ syncComposerPlaceholder();
8633
8571
  }
8634
8572
 
8635
8573
  async function submitComposerDelegate(job, instructionsRaw) {
@@ -9264,6 +9202,7 @@ async function continueRun(text, options) {
9264
9202
  ...(coachingJobId ? { coachingJobId } : {}),
9265
9203
  ...(deliveryIntent ? { deliveryIntent } : {}),
9266
9204
  ...(opts.selectedReviewAction ? { selectedReviewAction: opts.selectedReviewAction } : {}),
9205
+ ...(opts.proposalDecisions ? { proposalDecisions: opts.proposalDecisions } : {}),
9267
9206
  ...(attachmentIds ? { attachmentIds } : {}),
9268
9207
  }),
9269
9208
  });
@@ -9294,6 +9233,7 @@ async function continueRun(text, options) {
9294
9233
  instructions: effectiveText,
9295
9234
  ...(coachingJobId ? { coachingJobId } : {}),
9296
9235
  ...(opts.selectedReviewAction ? { selectedReviewAction: opts.selectedReviewAction } : {}),
9236
+ ...(opts.proposalDecisions ? { proposalDecisions: opts.proposalDecisions } : {}),
9297
9237
  }),
9298
9238
  });
9299
9239
  conv.runId = run.id; // bind the conversation to the freshly-resumed run
@@ -9315,6 +9255,7 @@ async function continueRun(text, options) {
9315
9255
  instructions: effectiveText,
9316
9256
  ...(coachingJobId ? { coachingJobId } : {}),
9317
9257
  ...(opts.selectedReviewAction ? { selectedReviewAction: opts.selectedReviewAction } : {}),
9258
+ ...(opts.proposalDecisions ? { proposalDecisions: opts.proposalDecisions } : {}),
9318
9259
  ...(attachmentIds ? { attachmentIds } : {}),
9319
9260
  }),
9320
9261
  });
@@ -9425,6 +9366,7 @@ function foldRunIntoConversation(conv, run) {
9425
9366
  conv.hostSessions = { ...(conv.hostSessions || {}), ...run.hostSessions };
9426
9367
  }
9427
9368
  const runHandoff = normalizeReviewHandoff(run.reviewHandoff);
9369
+ conv.proposalSet = run.proposalSet || null;
9428
9370
  if (runHandoff) {
9429
9371
  conv.reviewHandoff = runHandoff;
9430
9372
  } else if (run.reviewHandoff) {
@@ -10310,39 +10252,7 @@ function wireEvents() {
10310
10252
  });
10311
10253
  if (els['teach-btn']) {
10312
10254
  els['teach-btn'].addEventListener('click', () => {
10313
- openTeachJobDialog();
10314
- });
10315
- }
10316
- const teachModal = document.getElementById('teach-job-modal');
10317
- const teachClose = document.getElementById('tjd-close');
10318
- const teachCancel = document.getElementById('tjd-cancel');
10319
- const teachSubmit = document.getElementById('tjd-submit');
10320
- const teachSearch = document.getElementById('tjd-mimic-search');
10321
- const teachClear = document.getElementById('tjd-clear-mimic');
10322
- if (teachClose) teachClose.addEventListener('click', closeTeachJobDialog);
10323
- if (teachCancel) teachCancel.addEventListener('click', closeTeachJobDialog);
10324
- if (teachSubmit) teachSubmit.addEventListener('click', submitTeachJobDialog);
10325
- if (teachSearch) teachSearch.addEventListener('input', renderTeachMimicJobs);
10326
- if (teachClear) teachClear.addEventListener('click', () => setTeachSelectedMimic(null));
10327
- document.querySelectorAll('.tjd-mode').forEach((button) => {
10328
- button.addEventListener('click', () => {
10329
- if (document.getElementById('tjd-mode').value !== button.dataset.mode) setTeachMode(button.dataset.mode);
10330
- });
10331
- });
10332
- if (teachModal) {
10333
- bindManagementDialog(teachModal, closeTeachJobDialog);
10334
- teachModal.addEventListener('click', (event) => {
10335
- if (event.target === teachModal) closeTeachJobDialog();
10336
- });
10337
- teachModal.addEventListener('keydown', (event) => {
10338
- if (event.key === 'Escape') {
10339
- closeTeachJobDialog();
10340
- return;
10341
- }
10342
- if (event.key === 'Enter' && event.target?.matches('input:not(#tjd-mimic-search)')) {
10343
- event.preventDefault();
10344
- submitTeachJobDialog();
10345
- }
10255
+ openCoachSurface();
10346
10256
  });
10347
10257
  }
10348
10258
  // Issue #385: hire-required notice back button.
@@ -10442,7 +10352,11 @@ function wireEvents() {
10442
10352
  // which Teach also goes through now), the box holds instructions, not a
10443
10353
  // search query - re-triggering the catalog off that text would pop an
10444
10354
  // irrelevant suggestion list on top of the armed-job chip.
10445
- if (!state.activeId && !state.cpSelectedJob) {
10355
+ // Issue #1911: while a pane surface owns the composer, this text is the
10356
+ // instruction the surface will submit, not a job search - popping the
10357
+ // catalog list under an open intake panel would squeeze the panel and
10358
+ // offer a delegate the manager did not ask for.
10359
+ if (!state.activeId && !state.cpSelectedJob && !paneSurfaceComposerContract()) {
10446
10360
  renderComposerSuggestions(els['coach-text'].value);
10447
10361
  }
10448
10362
  });
@@ -10458,6 +10372,9 @@ function wireEvents() {
10458
10372
  if (e.key === 'ArrowDown' && !document.getElementById('composer-suggestions').hidden) { e.preventDefault(); moveCpHighlight(+1); return; }
10459
10373
  if (e.key === 'ArrowUp' && !document.getElementById('composer-suggestions').hidden) { e.preventDefault(); moveCpHighlight(-1); return; }
10460
10374
  if (e.key === 'Escape') {
10375
+ // Issue #1911 R15: Escape from inside the composer cancels the pane
10376
+ // surface that owns it, before any Delegate-side arming is considered.
10377
+ if (typeof PaneSurfaces !== 'undefined' && PaneSurfaces.handleEscape()) return;
10461
10378
  // Issue #1657 post-launch fix: an armed job (see armSelectedJob) must be
10462
10379
  // cleared via clearArmedJob (which also resets the chip/placeholder) -
10463
10380
  // hideComposerSuggestions also nulls state.cpSelectedJob but silently,
@@ -10536,7 +10453,15 @@ function wireEvents() {
10536
10453
  // returns this composer to its catalog.
10537
10454
  const armedJobClearBtn = document.getElementById('cp-armed-job-clear');
10538
10455
  if (armedJobClearBtn) {
10539
- armedJobClearBtn.addEventListener('click', () => clearArmedJob());
10456
+ armedJobClearBtn.addEventListener('click', () => {
10457
+ // Issue #1911 R5: while a pane surface owns the chip, the clear control
10458
+ // cancels that surface instead of un-arming a Delegate job.
10459
+ if (paneSurfaceOpen()) {
10460
+ PaneSurfaces.close();
10461
+ return;
10462
+ }
10463
+ clearArmedJob();
10464
+ });
10540
10465
  }
10541
10466
  // Issue #442: Direct panel send button.
10542
10467
  const abDirectInput = document.getElementById('ab-direct-input');
@@ -10722,6 +10647,7 @@ function wireEvents() {
10722
10647
  // This is a belt-and-suspenders fallback in case the inline script was skipped.
10723
10648
 
10724
10649
  gatherElements();
10650
+ configurePaneSurfaceHost();
10725
10651
  state.conversations = {};
10726
10652
  state.activeId = null;
10727
10653
  wirePopovers();
@@ -14840,11 +14766,32 @@ function tfLearningRunKey(scope) {
14840
14766
  return scope === 'org' ? 'org' : scope === 'reverse' ? 'reverse' : 'manager';
14841
14767
  }
14842
14768
 
14843
- /** True when a conversation's employee turns carry a sleep-on-learnings proposal set. */
14769
+ /**
14770
+ * A proposal's identity, and the only thing a decision is ever keyed on.
14771
+ *
14772
+ * Issue #1857 R18: these used to be `scope + '-live-' + index`, positional per
14773
+ * render. A Skip therefore relayed a title, and `move-artifact-between-levels`
14774
+ * says plainly that titles never select updates, so the one handle the Hub gave
14775
+ * the job was the one the corpus forbids using. `proposalId` comes off the run
14776
+ * journal and is what a recorded decision binds to.
14777
+ */
14778
+ function tfProposalKey(p) {
14779
+ return p && p.proposalId;
14780
+ }
14781
+
14782
+ /**
14783
+ * True when this run is carrying a structured proposal set.
14784
+ *
14785
+ * Issue #1857: this used to scan employee turns for a `## Pending Review` heading
14786
+ * and `#### [P-LEVEL]` lines. No job or skill was ever instructed to emit that
14787
+ * format, so the review surface #1755 shipped rendered nothing for a real run, and
14788
+ * the two regexes that read the marker disagreed about whether a trailing date was
14789
+ * allowed. The job now emits `evidence.proposalSet` through seekMentoring and the
14790
+ * Hub stores it on the conversation, so there is one source and nothing to parse.
14791
+ */
14844
14792
  function tfConvHasProposals(conv) {
14845
- if (!conv || !Array.isArray(conv.messages)) return false;
14846
- const text = conv.messages.filter((m) => m && m.role === 'employee').map((m) => m.text || '').join('\n');
14847
- return /pending review/i.test(text) && /####\s*\[?P-(CRITICAL|HIGH|MED|LOW)\]?/i.test(text);
14793
+ return !!(conv && conv.proposalSet && Array.isArray(conv.proposalSet.proposals)
14794
+ && conv.proposalSet.proposals.length > 0);
14848
14795
  }
14849
14796
 
14850
14797
  /**
@@ -14885,47 +14832,22 @@ function tfScopeMeta(scope) {
14885
14832
  return { cls: 'scope-proj', label: 'Project', note: 'Saves to project learnings · this project only' };
14886
14833
  }
14887
14834
 
14888
- // Parse pending proposals out of a sleep-on-learnings run's surfaced text. The
14889
- // job writes a "## Pending Review" section (see sleep-on-learnings.md
14890
- // write-proposals phase); we read the employee turns for proposal headings so
14891
- // what renders here is the job's own output, not a Hub invention.
14892
- function tfParsePendingProposals(conv, scope) {
14893
- if (!conv) return [];
14894
- const text = (conv.messages || [])
14895
- .filter((m) => m.role === 'employee')
14896
- .map((m) => m.text || '')
14897
- .join('\n');
14898
- if (!text || !/pending review/i.test(text)) return [];
14899
- const out = [];
14900
- const re = /####\s*\[?P-(CRITICAL|HIGH|MED|LOW)\]?\s*(.+)/gi;
14901
- let m;
14902
- let i = 0;
14903
- while ((m = re.exec(text)) !== null) {
14904
- const title = m[2].trim();
14905
- // Issue #1002 R1/R13: the job states a level per proposal and, when it has a
14906
- // reason, a suggestion to move it. Read both out of the job's own output
14907
- // rather than assuming: the block after this heading carries them.
14908
- // Bound the block at the NEXT proposal heading. A fixed-length slice would
14909
- // read the following proposal's lines and attribute them to this one.
14910
- const rest = text.slice(m.index + m[0].length);
14911
- const nextHeading = rest.search(/####\s*\[?P-(?:CRITICAL|HIGH|MED|LOW)\]?/i);
14912
- const block = nextHeading === -1 ? rest : rest.slice(0, nextHeading);
14913
- const scopeLine = block.match(/\*\*Scope(?: if approved)?\*\*:\s*(.+)/i);
14914
- const suggestedLevel = scopeLine ? tfParseLevelWord(scopeLine[1]) : 'manager';
14915
- const suggestLine = block.match(/\*\*Suggested(?: level)?\*\*:\s*([a-z ]+?)[.:]\s*(.+)/i);
14916
- out.push({
14917
- id: scope + '-live-' + (i++),
14918
- scope,
14919
- title,
14920
- body: '',
14921
- example: '',
14922
- live: true,
14923
- suggestedLevel,
14924
- suggestion: suggestLine ? tfParseLevelWord(suggestLine[1]) : null,
14925
- suggestionReason: suggestLine ? suggestLine[2].trim() : null,
14926
- });
14927
- }
14928
- return out;
14835
+ /**
14836
+ * The proposals this run is waiting on, straight off the structured set.
14837
+ *
14838
+ * Issue #1857 replaced a markdown parse with `evidence.proposalSet`. The scope the
14839
+ * run was asked for still decides which corpus an approval lands in, and it now
14840
+ * arrives on the set itself rather than being re-derived from the kickoff text.
14841
+ */
14842
+ function tfPendingProposals(conv) {
14843
+ if (!tfConvHasProposals(conv)) return [];
14844
+ return conv.proposalSet.proposals;
14845
+ }
14846
+
14847
+ /** Claims this run suppressed because the manager already decided them (R7). */
14848
+ function tfSuppressedClaims(conv) {
14849
+ const suppressed = conv && conv.proposalSet && conv.proposalSet.suppressed;
14850
+ return Array.isArray(suppressed) ? suppressed : [];
14929
14851
  }
14930
14852
 
14931
14853
  // Issue #1002 R13: the job states levels in the manager's words ("keep only for
@@ -14947,12 +14869,23 @@ async function tfRelayProposalDecision(scope, proposal, decision) {
14947
14869
  const conv = tfFindLearningRun(scope);
14948
14870
  const verb = decision === 'approve' ? 'Approve as-is' : 'Reject (skip)';
14949
14871
  const message = `${verb} the proposal: "${proposal.title}".`;
14950
- tfProposalDecisions[proposal.id] = decision;
14872
+ tfProposalDecisions[tfProposalKey(proposal)] = decision;
14951
14873
  if (conv && conv.runId) {
14952
14874
  // Relay into the live run via the existing manager-turn path.
14953
14875
  const prev = state.activeId;
14954
14876
  state.activeId = conv.id;
14955
- try { await continueRun(message); } finally { state.activeId = prev; }
14877
+ try {
14878
+ // Issue #1857 R18: the decision names the proposal it decided. The prose is
14879
+ // what the manager reads back; `proposalDecisions` is what the job records
14880
+ // against, so it never has to re-derive a revision from a quoted title.
14881
+ await continueRun(message, {
14882
+ proposalDecisions: [{
14883
+ proposalId: tfProposalKey(proposal), revision: proposal.revision,
14884
+ action: decision === 'approve' ? 'approve' : 'reject',
14885
+ level: tfProposalLevel(proposal), reason: tfProposalReason(proposal) || '',
14886
+ }],
14887
+ });
14888
+ } finally { state.activeId = prev; }
14956
14889
  } else if (typeof showStatus === 'function') {
14957
14890
  showStatus(decision === 'approve'
14958
14891
  ? `Blessed “${proposal.title}”. Run Share learnings to let sleep-on-learnings write it.`
@@ -15017,6 +14950,103 @@ async function tfFetchPreservedLearnings(scope, level) {
15017
14950
  }
15018
14951
  return tfPreservedCache()[tfLearnKey(scope, level)];
15019
14952
  }
14953
+ /**
14954
+ * Issue #1857 R19: what the manager said no to.
14955
+ *
14956
+ * Rejection is one of the three states a claim can be in, and it was the only one
14957
+ * with no way to see it: approved claims are in the family files the library
14958
+ * renders, pending ones are in the run's conversation, and a rejection lived only
14959
+ * in a markdown file the manager would have to open by hand. Without this a
14960
+ * manager cannot tell a claim that keeps returning from one they already settled,
14961
+ * which is the judgment R8's reopening rule asks them to make.
14962
+ *
14963
+ * `undefined` = not loaded yet, so a first render can say "Loading" rather than
14964
+ * claiming there are no rejections.
14965
+ */
14966
+ function tfRejectionsCache() { return state._rejections; }
14967
+ async function tfFetchRejections() {
14968
+ const params = new URLSearchParams();
14969
+ if (state.projectPath) params.set('projectPath', state.projectPath);
14970
+ try {
14971
+ const data = await requestJson('/api/ai-hub/rejections?' + params.toString());
14972
+ state._rejections = Array.isArray(data.entries) ? data.entries : [];
14973
+ } catch {
14974
+ state._rejections = [];
14975
+ }
14976
+ return state._rejections;
14977
+ }
14978
+
14979
+ /**
14980
+ * The collapsed rejected section, in the same shape as the Archived section above
14981
+ * it: a count in the summary and the detail only when opened. Closed by default,
14982
+ * because a refused claim is settled and the library is about what the employee
14983
+ * knows, not what it was told no about.
14984
+ */
14985
+ function tfRejectedSection(rejections) {
14986
+ const box = document.createElement('details');
14987
+ box.className = 'learn-dormant-section learn-rejected-section';
14988
+ box.dataset.testid = 'rejected-section';
14989
+ const summary = document.createElement('summary');
14990
+ summary.className = 'learn-dormant-header';
14991
+ summary.textContent = `Rejected (${rejections.length})`;
14992
+ box.appendChild(summary);
14993
+
14994
+ for (const r of rejections) {
14995
+ const card = document.createElement('div');
14996
+ card.className = 'learn-prop lr-card';
14997
+ card.dataset.testid = 'rejected-card';
14998
+
14999
+ const head = document.createElement('div');
15000
+ head.className = 'lp-head';
15001
+ const title = document.createElement('span');
15002
+ title.className = 'lp-title';
15003
+ title.textContent = r.title;
15004
+ head.appendChild(title);
15005
+ // A claim a rule or skill now owns was promoted, not refused. Labelling both
15006
+ // "Rejected" would tell the manager they said no to work they approved.
15007
+ const kind = document.createElement('span');
15008
+ kind.className = 'lp-scope ' + (r.kind === 'superseded' ? 'sc-project' : 'sc-manager');
15009
+ kind.dataset.testid = 'lr-kind';
15010
+ kind.textContent = r.kind === 'superseded' ? 'Superseded' : 'Rejected';
15011
+ head.appendChild(kind);
15012
+ box.appendChild(card);
15013
+ card.appendChild(head);
15014
+
15015
+ const why = document.createElement('div');
15016
+ why.className = 'lp-body';
15017
+ why.textContent = r.reason;
15018
+ card.appendChild(why);
15019
+
15020
+ const meta = document.createElement('div');
15021
+ meta.className = 'lp-stats';
15022
+ if (r.rejectedAt) {
15023
+ const when = document.createElement('span');
15024
+ when.className = 'lp-score';
15025
+ when.dataset.testid = 'lr-when';
15026
+ when.textContent = String(r.rejectedAt).slice(0, 10);
15027
+ meta.appendChild(when);
15028
+ }
15029
+ if (typeof r.recurrencesAtRejection === 'number') {
15030
+ const count = document.createElement('span');
15031
+ count.className = 'lp-score';
15032
+ count.textContent = `${r.recurrencesAtRejection} occurrence${r.recurrencesAtRejection === 1 ? '' : 's'} then`;
15033
+ meta.appendChild(count);
15034
+ }
15035
+ if (meta.children.length) card.appendChild(meta);
15036
+
15037
+ // The manager's own condition for looking at this again. R8 applies it as
15038
+ // written, so showing it here is how they check what they actually asked for.
15039
+ if (r.revisitCondition) {
15040
+ const revisit = document.createElement('div');
15041
+ revisit.className = 'lp-example';
15042
+ revisit.dataset.testid = 'lr-revisit';
15043
+ revisit.textContent = 'Revisit when: ' + r.revisitCondition;
15044
+ card.appendChild(revisit);
15045
+ }
15046
+ }
15047
+ return box;
15048
+ }
15049
+
15020
15050
  // Re-render ONLY the surface that owns this (scope, level) after an async fetch or
15021
15051
  // a write — never the whole app. The project level lives in the project workspace;
15022
15052
  // machine/org levels live in their top-tab areas. (A broad re-render here caused
@@ -15711,6 +15741,15 @@ function tfRenderLearningsList(host, scope, level, emptyText) {
15711
15741
  tfFetchPreservedLearnings(scope, level).then(() => tfReRenderScope(scope, level)).catch(() => {});
15712
15742
  }
15713
15743
 
15744
+ // Issue #1857 R19. Rejections are personal to the manager, so only their own
15745
+ // surface asks for them; the company and reverse-mentoring lists are other
15746
+ // people's corpora and a refusal there is not this manager's to review.
15747
+ const showsRejections = scope === 'manager';
15748
+ if (showsRejections && tfRejectionsCache() === undefined) {
15749
+ tfFetchRejections().then(() => tfReRenderScope(scope, level)).catch(() => {});
15750
+ }
15751
+ const rejections = showsRejections && Array.isArray(tfRejectionsCache()) ? tfRejectionsCache() : [];
15752
+
15714
15753
  const addCategories = scope === 'reverse' ? ['coaching']
15715
15754
  : scope === 'org' ? ['avoid', 'preference', 'repeat', 'coaching']
15716
15755
  : ['avoid', 'preference', 'repeat'];
@@ -15815,6 +15854,7 @@ function tfRenderLearningsList(host, scope, level, emptyText) {
15815
15854
 
15816
15855
  if (list.children.length) {
15817
15856
  host.appendChild(list);
15857
+ if (rejections.length && !shareMode) host.appendChild(tfRejectedSection(rejections));
15818
15858
  if (!shareMode) { host.appendChild(addBtn); wireAdd(); }
15819
15859
  if (shareMode) tfUpdateShareBar(host, scope, level);
15820
15860
  return;
@@ -15837,6 +15877,7 @@ function tfRenderLearningsList(host, scope, level, emptyText) {
15837
15877
  empty.appendChild(emoji); empty.appendChild(title); empty.appendChild(sub);
15838
15878
  host.appendChild(empty);
15839
15879
  if (shareMode) { tfUpdateShareBar(host, scope, level); return; }
15880
+ if (rejections.length) host.appendChild(tfRejectedSection(rejections));
15840
15881
  host.appendChild(addBtn);
15841
15882
  wireAdd();
15842
15883
  }
@@ -15872,7 +15913,7 @@ function tfLearningBatchBar(proposals, scope) {
15872
15913
  accept.dataset.testid = 'learn-batch-accept';
15873
15914
  // Do not say "Save all 5" when one of the five is skipped. Count what will
15874
15915
  // actually be written, and name the skips separately.
15875
- const skipped = proposals.filter((p) => (tfProposalOverrides[p.id] || {}).skipped);
15916
+ const skipped = proposals.filter((p) => (tfProposalOverrides[tfProposalKey(p)] || {}).skipped);
15876
15917
  const saving = proposals.length - skipped.length;
15877
15918
  if (changed.length === 0) {
15878
15919
  accept.textContent = `✓ Keep all ${proposals.length}`;
@@ -15898,7 +15939,7 @@ function tfLearningBatchBar(proposals, scope) {
15898
15939
  if (accept.getAttribute('aria-disabled') === 'true') {
15899
15940
  const first = missingReason[0];
15900
15941
  const field = first
15901
- ? document.getElementById('lp-why-' + String(first.id).replace(/[^a-zA-Z0-9_-]/g, ''))
15942
+ ? document.getElementById('lp-why-' + String(tfProposalKey(first)).replace(/[^a-zA-Z0-9_-]/g, ''))
15902
15943
  : null;
15903
15944
  if (field) {
15904
15945
  field.scrollIntoView({ block: 'center' });
@@ -15918,7 +15959,7 @@ function tfLearningBatchBar(proposals, scope) {
15918
15959
  skipAll.textContent = 'Skip all';
15919
15960
  skipAll.addEventListener('click', async () => {
15920
15961
  skipAll.disabled = true;
15921
- for (const p of proposals) tfProposalDecisions[p.id] = 'reject';
15962
+ for (const p of proposals) tfProposalDecisions[tfProposalKey(p)] = 'reject';
15922
15963
  await tfRelayBatch(scope, `Skip all ${proposals.length} proposals.`);
15923
15964
  });
15924
15965
  bar.appendChild(skipAll);
@@ -15931,20 +15972,20 @@ function tfLearningBatchBar(proposals, scope) {
15931
15972
  // wiped by the next render before the manager could save it.
15932
15973
  const tfProposalOverrides = {};
15933
15974
  function tfProposalLevel(p) {
15934
- const o = tfProposalOverrides[p.id];
15975
+ const o = tfProposalOverrides[tfProposalKey(p)];
15935
15976
  return (o && o.level) || p.suggestedLevel || 'manager';
15936
15977
  }
15937
15978
  function tfProposalReason(p) {
15938
- const o = tfProposalOverrides[p.id];
15979
+ const o = tfProposalOverrides[tfProposalKey(p)];
15939
15980
  return (o && o.reason) || '';
15940
15981
  }
15941
15982
  function tfProposalChanged(p) {
15942
- const o = tfProposalOverrides[p.id];
15983
+ const o = tfProposalOverrides[tfProposalKey(p)];
15943
15984
  if (!o) return false;
15944
15985
  return !!o.skipped || (!!o.level && o.level !== (p.suggestedLevel || 'manager'));
15945
15986
  }
15946
15987
  function tfSetProposalOverride(p, patch) {
15947
- tfProposalOverrides[p.id] = Object.assign({}, tfProposalOverrides[p.id] || {}, patch);
15988
+ tfProposalOverrides[tfProposalKey(p)] = Object.assign({}, tfProposalOverrides[tfProposalKey(p)] || {}, patch);
15948
15989
  }
15949
15990
 
15950
15991
  // Issue #1002 R13: accepting the batch relays ONE turn describing every decision,
@@ -15952,18 +15993,22 @@ function tfSetProposalOverride(p, patch) {
15952
15993
  // the job persists as an unprocessed signal for the next run to read.
15953
15994
  async function tfAcceptLearningBatch(proposals, scope) {
15954
15995
  const lines = [];
15996
+ const decisions = [];
15955
15997
  for (const p of proposals) {
15956
15998
  const level = tfProposalLevel(p);
15957
- const o = tfProposalOverrides[p.id] || {};
15999
+ const o = tfProposalOverrides[tfProposalKey(p)] || {};
15958
16000
  if (o.skipped) {
15959
- tfProposalDecisions[p.id] = 'reject';
16001
+ tfProposalDecisions[tfProposalKey(p)] = 'reject';
15960
16002
  lines.push(`- Skip "${p.title}". Reason: ${tfProposalReason(p) || '(none given)'}`);
16003
+ decisions.push({ proposalId: tfProposalKey(p), revision: p.revision, action: 'reject', level, reason: tfProposalReason(p) || '' });
15961
16004
  } else if (tfProposalChanged(p)) {
15962
- tfProposalDecisions[p.id] = 'promote:' + level;
16005
+ tfProposalDecisions[tfProposalKey(p)] = 'promote:' + level;
15963
16006
  lines.push(`- Save "${p.title}" at ${tfLevelWord(level)}. Reason: ${tfProposalReason(p) || '(none given)'}`);
16007
+ decisions.push({ proposalId: tfProposalKey(p), revision: p.revision, action: 'approve', level, reason: tfProposalReason(p) || '' });
15964
16008
  } else {
15965
- tfProposalDecisions[p.id] = 'approve';
16009
+ tfProposalDecisions[tfProposalKey(p)] = 'approve';
15966
16010
  lines.push(`- Keep "${p.title}" at ${tfLevelWord(level)}.`);
16011
+ decisions.push({ proposalId: tfProposalKey(p), revision: p.revision, action: 'approve', level, reason: tfProposalReason(p) || '' });
15967
16012
  }
15968
16013
  }
15969
16014
  const msg = [
@@ -15971,7 +16016,10 @@ async function tfAcceptLearningBatch(proposals, scope) {
15971
16016
  ...lines,
15972
16017
  'Apply them all, and save each stated reason as a signal so future runs get this right without asking.',
15973
16018
  ].join('\n');
15974
- await tfRelayBatch(scope, msg);
16019
+ // Issue #1857 R18. The prose above is what the manager reads back; `decisions`
16020
+ // is what the job records against. Previously only the prose existed, naming
16021
+ // each proposal by title, and the job had to guess which revision that was.
16022
+ await tfRelayBatch(scope, msg, decisions);
15975
16023
  }
15976
16024
 
15977
16025
  function tfLevelWord(level) {
@@ -15982,12 +16030,17 @@ function tfLevelWord(level) {
15982
16030
 
15983
16031
  // Relay one manager turn into the running learning job, through the same
15984
16032
  // continueRun path every other in-conversation control uses. No second write path.
15985
- async function tfRelayBatch(scope, message) {
16033
+ async function tfRelayBatch(scope, message, decisions) {
15986
16034
  const conv = tfFindLearningRun(scope);
15987
16035
  if (conv && conv.runId) {
15988
16036
  const prev = state.activeId;
15989
16037
  state.activeId = conv.id;
15990
- try { await continueRun(message); } finally { state.activeId = prev; }
16038
+ try {
16039
+ // Issue #1857 R18. Carried the same way an approve_delivery selection already
16040
+ // travels: the job records against `proposalId` and `revision` rather than
16041
+ // matching the titles quoted in the sentence the manager reads.
16042
+ await continueRun(message, decisions && decisions.length ? { proposalDecisions: decisions } : undefined);
16043
+ } finally { state.activeId = prev; }
15991
16044
  } else if (typeof showStatus === 'function') {
15992
16045
  showStatus('Run Sleep on learnings first, then your decisions go straight into that run.', false);
15993
16046
  }
@@ -16192,27 +16245,32 @@ async function tfLaunchShareWithOthers(scope, level, selectedTitles, job, projec
16192
16245
  }
16193
16246
 
16194
16247
  function tfProposalCard(p, scope) {
16195
- const meta = tfScopeMeta(scope);
16196
16248
  const card = document.createElement('div');
16197
16249
  card.className = 'learn-prop';
16198
- const decided = tfProposalDecisions[p.id];
16250
+ const decided = tfProposalDecisions[p.proposalId];
16199
16251
  if (decided) card.classList.add('resolved');
16200
16252
 
16253
+ // Issue #1857: no scope chip. It showed the run's scope while the level buttons
16254
+ // below showed the proposal's, so a proposal suggested at Project inside a
16255
+ // Manager-scoped run was chipped MANAGER and footed "this project only". The
16256
+ // buttons already carry the level; two controls for one fact is how they came
16257
+ // to disagree. Invisible before this change because a parsed proposal could not
16258
+ // express a level that differed from its run.
16201
16259
  const head = document.createElement('div');
16202
16260
  head.className = 'lp-head';
16203
- const sc = document.createElement('span');
16204
- sc.className = 'lp-scope ' + meta.cls;
16205
- sc.textContent = meta.label;
16206
16261
  const title = document.createElement('span');
16207
16262
  title.className = 'lp-title';
16208
16263
  title.textContent = p.title;
16209
- head.appendChild(sc); head.appendChild(title);
16264
+ head.appendChild(title);
16210
16265
  card.appendChild(head);
16211
16266
 
16212
- if (p.body) {
16267
+ // The takeaway and the example. Both were authored by the job and both were
16268
+ // thrown away by the parse, which hardcoded them empty, so until now a manager
16269
+ // approved or rejected a lesson on its title alone.
16270
+ if (p.takeaway) {
16213
16271
  const body = document.createElement('div');
16214
16272
  body.className = 'lp-body';
16215
- body.textContent = p.body;
16273
+ body.textContent = p.takeaway;
16216
16274
  card.appendChild(body);
16217
16275
  }
16218
16276
  if (p.example) {
@@ -16222,6 +16280,43 @@ function tfProposalCard(p, scope) {
16222
16280
  card.appendChild(ex);
16223
16281
  }
16224
16282
 
16283
+ // A reopening says so, in the manager's own words, with both counts. Without
16284
+ // this a claim they already rejected would return looking like a new one.
16285
+ if (p.reopening) {
16286
+ const reopen = document.createElement('div');
16287
+ reopen.className = 'lp-reopen';
16288
+ reopen.dataset.testid = 'lp-reopen';
16289
+ reopen.textContent = `You rejected this on ${p.reopening.rejectedAt}: "${p.reopening.reason}". `
16290
+ + `It was at ${p.reopening.countAtRejection} occurrence`
16291
+ + `${p.reopening.countAtRejection === 1 ? '' : 's'} then and is at ${p.recurrences} now.`;
16292
+ card.appendChild(reopen);
16293
+ }
16294
+
16295
+ // Evidence, using the library's own chips. The trajectory is what makes
16296
+ // "leave it for now" a position rather than a deferred chore.
16297
+ const stats = document.createElement('div');
16298
+ stats.className = 'lp-stats';
16299
+ if (p.domain) {
16300
+ const domain = document.createElement('span');
16301
+ domain.className = 'lp-domain';
16302
+ domain.textContent = p.domain;
16303
+ stats.appendChild(domain);
16304
+ }
16305
+ const occurrences = document.createElement('span');
16306
+ occurrences.className = 'lp-score';
16307
+ occurrences.dataset.testid = 'lp-occurrences';
16308
+ occurrences.textContent = typeof p.recurrencesAtLastReview === 'number'
16309
+ ? `${p.recurrences} occurrences, up from ${p.recurrencesAtLastReview}`
16310
+ : `${p.recurrences} occurrence${p.recurrences === 1 ? '' : 's'}`;
16311
+ stats.appendChild(occurrences);
16312
+ const kind = document.createElement('span');
16313
+ kind.className = 'lp-strength';
16314
+ kind.textContent = p.changeType === 'update' ? 'Updates an existing lesson'
16315
+ : p.changeType === 'cold' ? 'Revives a dormant lesson'
16316
+ : 'New lesson';
16317
+ stats.appendChild(kind);
16318
+ card.appendChild(stats);
16319
+
16225
16320
  // Issue #1755 R6: a proposal card reads as two zones: what you can still change
16226
16321
  // before deciding, and the decision itself. They used to sit on one undivided
16227
16322
  // row, so a level button and a Skip button looked like the same kind of control.
@@ -16258,14 +16353,18 @@ function tfProposalCard(p, scope) {
16258
16353
 
16259
16354
  // The recommendation, when the job made one, with its reason. R6: absent a
16260
16355
  // reason there is no nudge, so the row simply states where it will be saved.
16261
- if (p.suggestion && p.suggestionReason) {
16356
+ // #1857: the level is `suggestedLevel` on the wire and is always present, so the
16357
+ // reason alone decides whether there is a nudge to show. The old parse carried a
16358
+ // separate `suggestion` field that was set only when the job named a level in
16359
+ // prose, which made the nudge depend on how the sentence happened to be written.
16360
+ if (p.suggestionReason) {
16262
16361
  const why = document.createElement('div');
16263
16362
  why.className = 'lp-reason';
16264
16363
  const b = document.createElement('b');
16265
16364
  // Name the suggested level with the SAME words the picker below uses. Reading
16266
16365
  // "Suggested: Project" above a button that says "This project" makes the
16267
16366
  // manager check whether those are the same thing.
16268
- b.textContent = 'Suggested: ' + (TF_LEVEL_PICK_LABEL[p.suggestion] || tfScopeMeta(p.suggestion).label || p.suggestion) + '.';
16367
+ b.textContent = 'Suggested: ' + (TF_LEVEL_PICK_LABEL[suggested] || tfScopeMeta(suggested).label || suggested) + '.';
16269
16368
  why.appendChild(b);
16270
16369
  why.appendChild(document.createTextNode(' ' + p.suggestionReason));
16271
16370
  card.appendChild(why);
@@ -16282,7 +16381,7 @@ function tfProposalCard(p, scope) {
16282
16381
  // A skipped lesson is saved at no level, so no level is selected on that row.
16283
16382
  // Showing "Skipped" next to a highlighted level says two contradictory things
16284
16383
  // about the same row. The buttons stay live: picking a level clears the skip.
16285
- const isSkipped = !!(tfProposalOverrides[p.id] || {}).skipped;
16384
+ const isSkipped = !!(tfProposalOverrides[p.proposalId] || {}).skipped;
16286
16385
  if (isSkipped) pick.classList.add('lp-moot');
16287
16386
  for (const [level, label] of LEVELS) {
16288
16387
  const b = document.createElement('button');
@@ -16312,13 +16411,13 @@ function tfProposalCard(p, scope) {
16312
16411
  decide.dataset.testid = 'lp-decide';
16313
16412
 
16314
16413
  const skip = document.createElement('button');
16315
- skip.className = 'lp-skip' + ((tfProposalOverrides[p.id] || {}).skipped || decided === 'reject' ? ' on' : '');
16414
+ skip.className = 'lp-skip' + ((tfProposalOverrides[p.proposalId] || {}).skipped || decided === 'reject' ? ' on' : '');
16316
16415
  skip.type = 'button';
16317
16416
  skip.dataset.testid = 'lp-skip';
16318
- skip.textContent = (tfProposalOverrides[p.id] || {}).skipped || decided === 'reject' ? '✕ Skipped' : 'Skip';
16417
+ skip.textContent = (tfProposalOverrides[p.proposalId] || {}).skipped || decided === 'reject' ? '✕ Skipped' : 'Skip';
16319
16418
  skip.disabled = !!decided;
16320
16419
  skip.addEventListener('click', () => {
16321
- const wasSkipped = !!(tfProposalOverrides[p.id] || {}).skipped;
16420
+ const wasSkipped = !!(tfProposalOverrides[p.proposalId] || {}).skipped;
16322
16421
  tfSetProposalOverride(p, { skipped: !wasSkipped });
16323
16422
  reRender();
16324
16423
  });
@@ -16330,10 +16429,10 @@ function tfProposalCard(p, scope) {
16330
16429
  if (tfProposalChanged(p) && !decided) {
16331
16430
  const why = document.createElement('div');
16332
16431
  why.className = 'lp-why';
16333
- const id = 'lp-why-' + String(p.id).replace(/[^a-zA-Z0-9_-]/g, '');
16432
+ const id = 'lp-why-' + String(p.proposalId).replace(/[^a-zA-Z0-9_-]/g, '');
16334
16433
  const label = document.createElement('label');
16335
16434
  label.setAttribute('for', id);
16336
- label.textContent = (tfProposalOverrides[p.id] || {}).skipped
16435
+ label.textContent = (tfProposalOverrides[p.proposalId] || {}).skipped
16337
16436
  ? 'Why skip it?'
16338
16437
  : 'Why this level instead?';
16339
16438
  const ta = document.createElement('textarea');
@@ -16368,7 +16467,7 @@ function tfProposalCard(p, scope) {
16368
16467
  // R8: state what the decision will actually write, in the zone where it is made.
16369
16468
  const note = document.createElement('span');
16370
16469
  note.className = 'lp-scope-note';
16371
- note.textContent = (tfProposalOverrides[p.id] || {}).skipped
16470
+ note.textContent = (tfProposalOverrides[p.proposalId] || {}).skipped
16372
16471
  ? 'Nothing is written. The skip is recorded as a signal.'
16373
16472
  : (TF_LEVEL_TITLE[currentLevel] || tfScopeMeta(scope).note);
16374
16473
  decide.appendChild(note);
@@ -16382,7 +16481,7 @@ async function tfPromoteProposal(originScope, proposal, targetScope) {
16382
16481
  const label = targetScope === 'org' ? 'Company' : targetScope === 'manager' ? 'Manager' : 'Project';
16383
16482
  const conv = tfFindLearningRun(originScope);
16384
16483
  const message = `Promote the proposal "${proposal.title}" to ${label.toLowerCase()} learnings (scope: ${targetScope}).`;
16385
- tfProposalDecisions[proposal.id] = 'promote:' + targetScope;
16484
+ tfProposalDecisions[tfProposalKey(proposal)] = 'promote:' + targetScope;
16386
16485
  if (conv && conv.runId) {
16387
16486
  const prev = state.activeId;
16388
16487
  state.activeId = conv.id;
@@ -19016,4 +19115,9 @@ if (typeof window !== 'undefined') {
19016
19115
  // tests can assert the rendered controls without depending on click coordinates.
19017
19116
  window.openInlineCreateEmployee = openInlineCreateEmployee;
19018
19117
  window.submitComposerDelegate = submitComposerDelegate;
19118
+ // Issue #1911: the pane-surface seams, so the suite can drive the host and
19119
+ // the Coach entry point without clicking through three different rails.
19120
+ window.openCoachSurface = openCoachSurface;
19121
+ window.paneSurfaceOpen = paneSurfaceOpen;
19122
+ window.buildTeachIntakeInstructions = buildTeachIntakeInstructions;
19019
19123
  }