fraim-hub 2.0.340 → 2.0.341

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
  });
@@ -10310,39 +10251,7 @@ function wireEvents() {
10310
10251
  });
10311
10252
  if (els['teach-btn']) {
10312
10253
  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
- }
10254
+ openCoachSurface();
10346
10255
  });
10347
10256
  }
10348
10257
  // Issue #385: hire-required notice back button.
@@ -10442,7 +10351,11 @@ function wireEvents() {
10442
10351
  // which Teach also goes through now), the box holds instructions, not a
10443
10352
  // search query - re-triggering the catalog off that text would pop an
10444
10353
  // irrelevant suggestion list on top of the armed-job chip.
10445
- if (!state.activeId && !state.cpSelectedJob) {
10354
+ // Issue #1911: while a pane surface owns the composer, this text is the
10355
+ // instruction the surface will submit, not a job search - popping the
10356
+ // catalog list under an open intake panel would squeeze the panel and
10357
+ // offer a delegate the manager did not ask for.
10358
+ if (!state.activeId && !state.cpSelectedJob && !paneSurfaceComposerContract()) {
10446
10359
  renderComposerSuggestions(els['coach-text'].value);
10447
10360
  }
10448
10361
  });
@@ -10458,6 +10371,9 @@ function wireEvents() {
10458
10371
  if (e.key === 'ArrowDown' && !document.getElementById('composer-suggestions').hidden) { e.preventDefault(); moveCpHighlight(+1); return; }
10459
10372
  if (e.key === 'ArrowUp' && !document.getElementById('composer-suggestions').hidden) { e.preventDefault(); moveCpHighlight(-1); return; }
10460
10373
  if (e.key === 'Escape') {
10374
+ // Issue #1911 R15: Escape from inside the composer cancels the pane
10375
+ // surface that owns it, before any Delegate-side arming is considered.
10376
+ if (typeof PaneSurfaces !== 'undefined' && PaneSurfaces.handleEscape()) return;
10461
10377
  // Issue #1657 post-launch fix: an armed job (see armSelectedJob) must be
10462
10378
  // cleared via clearArmedJob (which also resets the chip/placeholder) -
10463
10379
  // hideComposerSuggestions also nulls state.cpSelectedJob but silently,
@@ -10536,7 +10452,15 @@ function wireEvents() {
10536
10452
  // returns this composer to its catalog.
10537
10453
  const armedJobClearBtn = document.getElementById('cp-armed-job-clear');
10538
10454
  if (armedJobClearBtn) {
10539
- armedJobClearBtn.addEventListener('click', () => clearArmedJob());
10455
+ armedJobClearBtn.addEventListener('click', () => {
10456
+ // Issue #1911 R5: while a pane surface owns the chip, the clear control
10457
+ // cancels that surface instead of un-arming a Delegate job.
10458
+ if (paneSurfaceOpen()) {
10459
+ PaneSurfaces.close();
10460
+ return;
10461
+ }
10462
+ clearArmedJob();
10463
+ });
10540
10464
  }
10541
10465
  // Issue #442: Direct panel send button.
10542
10466
  const abDirectInput = document.getElementById('ab-direct-input');
@@ -10722,6 +10646,7 @@ function wireEvents() {
10722
10646
  // This is a belt-and-suspenders fallback in case the inline script was skipped.
10723
10647
 
10724
10648
  gatherElements();
10649
+ configurePaneSurfaceHost();
10725
10650
  state.conversations = {};
10726
10651
  state.activeId = null;
10727
10652
  wirePopovers();
@@ -14840,11 +14765,32 @@ function tfLearningRunKey(scope) {
14840
14765
  return scope === 'org' ? 'org' : scope === 'reverse' ? 'reverse' : 'manager';
14841
14766
  }
14842
14767
 
14843
- /** True when a conversation's employee turns carry a sleep-on-learnings proposal set. */
14768
+ /**
14769
+ * A proposal's identity, and the only thing a decision is ever keyed on.
14770
+ *
14771
+ * Issue #1857 R18: these used to be `scope + '-live-' + index`, positional per
14772
+ * render. A Skip therefore relayed a title, and `move-artifact-between-levels`
14773
+ * says plainly that titles never select updates, so the one handle the Hub gave
14774
+ * the job was the one the corpus forbids using. `proposalId` comes off the run
14775
+ * journal and is what a recorded decision binds to.
14776
+ */
14777
+ function tfProposalKey(p) {
14778
+ return p && p.proposalId;
14779
+ }
14780
+
14781
+ /**
14782
+ * True when this run is carrying a structured proposal set.
14783
+ *
14784
+ * Issue #1857: this used to scan employee turns for a `## Pending Review` heading
14785
+ * and `#### [P-LEVEL]` lines. No job or skill was ever instructed to emit that
14786
+ * format, so the review surface #1755 shipped rendered nothing for a real run, and
14787
+ * the two regexes that read the marker disagreed about whether a trailing date was
14788
+ * allowed. The job now emits `evidence.proposalSet` through seekMentoring and the
14789
+ * Hub stores it on the conversation, so there is one source and nothing to parse.
14790
+ */
14844
14791
  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);
14792
+ return !!(conv && conv.proposalSet && Array.isArray(conv.proposalSet.proposals)
14793
+ && conv.proposalSet.proposals.length > 0);
14848
14794
  }
14849
14795
 
14850
14796
  /**
@@ -14885,47 +14831,22 @@ function tfScopeMeta(scope) {
14885
14831
  return { cls: 'scope-proj', label: 'Project', note: 'Saves to project learnings · this project only' };
14886
14832
  }
14887
14833
 
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;
14834
+ /**
14835
+ * The proposals this run is waiting on, straight off the structured set.
14836
+ *
14837
+ * Issue #1857 replaced a markdown parse with `evidence.proposalSet`. The scope the
14838
+ * run was asked for still decides which corpus an approval lands in, and it now
14839
+ * arrives on the set itself rather than being re-derived from the kickoff text.
14840
+ */
14841
+ function tfPendingProposals(conv) {
14842
+ if (!tfConvHasProposals(conv)) return [];
14843
+ return conv.proposalSet.proposals;
14844
+ }
14845
+
14846
+ /** Claims this run suppressed because the manager already decided them (R7). */
14847
+ function tfSuppressedClaims(conv) {
14848
+ const suppressed = conv && conv.proposalSet && conv.proposalSet.suppressed;
14849
+ return Array.isArray(suppressed) ? suppressed : [];
14929
14850
  }
14930
14851
 
14931
14852
  // Issue #1002 R13: the job states levels in the manager's words ("keep only for
@@ -14947,12 +14868,23 @@ async function tfRelayProposalDecision(scope, proposal, decision) {
14947
14868
  const conv = tfFindLearningRun(scope);
14948
14869
  const verb = decision === 'approve' ? 'Approve as-is' : 'Reject (skip)';
14949
14870
  const message = `${verb} the proposal: "${proposal.title}".`;
14950
- tfProposalDecisions[proposal.id] = decision;
14871
+ tfProposalDecisions[tfProposalKey(proposal)] = decision;
14951
14872
  if (conv && conv.runId) {
14952
14873
  // Relay into the live run via the existing manager-turn path.
14953
14874
  const prev = state.activeId;
14954
14875
  state.activeId = conv.id;
14955
- try { await continueRun(message); } finally { state.activeId = prev; }
14876
+ try {
14877
+ // Issue #1857 R18: the decision names the proposal it decided. The prose is
14878
+ // what the manager reads back; `proposalDecisions` is what the job records
14879
+ // against, so it never has to re-derive a revision from a quoted title.
14880
+ await continueRun(message, {
14881
+ proposalDecisions: [{
14882
+ proposalId: tfProposalKey(proposal), revision: proposal.revision,
14883
+ action: decision === 'approve' ? 'approve' : 'reject',
14884
+ level: tfProposalLevel(proposal), reason: tfProposalReason(proposal) || '',
14885
+ }],
14886
+ });
14887
+ } finally { state.activeId = prev; }
14956
14888
  } else if (typeof showStatus === 'function') {
14957
14889
  showStatus(decision === 'approve'
14958
14890
  ? `Blessed “${proposal.title}”. Run Share learnings to let sleep-on-learnings write it.`
@@ -15017,6 +14949,103 @@ async function tfFetchPreservedLearnings(scope, level) {
15017
14949
  }
15018
14950
  return tfPreservedCache()[tfLearnKey(scope, level)];
15019
14951
  }
14952
+ /**
14953
+ * Issue #1857 R19: what the manager said no to.
14954
+ *
14955
+ * Rejection is one of the three states a claim can be in, and it was the only one
14956
+ * with no way to see it: approved claims are in the family files the library
14957
+ * renders, pending ones are in the run's conversation, and a rejection lived only
14958
+ * in a markdown file the manager would have to open by hand. Without this a
14959
+ * manager cannot tell a claim that keeps returning from one they already settled,
14960
+ * which is the judgment R8's reopening rule asks them to make.
14961
+ *
14962
+ * `undefined` = not loaded yet, so a first render can say "Loading" rather than
14963
+ * claiming there are no rejections.
14964
+ */
14965
+ function tfRejectionsCache() { return state._rejections; }
14966
+ async function tfFetchRejections() {
14967
+ const params = new URLSearchParams();
14968
+ if (state.projectPath) params.set('projectPath', state.projectPath);
14969
+ try {
14970
+ const data = await requestJson('/api/ai-hub/rejections?' + params.toString());
14971
+ state._rejections = Array.isArray(data.entries) ? data.entries : [];
14972
+ } catch {
14973
+ state._rejections = [];
14974
+ }
14975
+ return state._rejections;
14976
+ }
14977
+
14978
+ /**
14979
+ * The collapsed rejected section, in the same shape as the Archived section above
14980
+ * it: a count in the summary and the detail only when opened. Closed by default,
14981
+ * because a refused claim is settled and the library is about what the employee
14982
+ * knows, not what it was told no about.
14983
+ */
14984
+ function tfRejectedSection(rejections) {
14985
+ const box = document.createElement('details');
14986
+ box.className = 'learn-dormant-section learn-rejected-section';
14987
+ box.dataset.testid = 'rejected-section';
14988
+ const summary = document.createElement('summary');
14989
+ summary.className = 'learn-dormant-header';
14990
+ summary.textContent = `Rejected (${rejections.length})`;
14991
+ box.appendChild(summary);
14992
+
14993
+ for (const r of rejections) {
14994
+ const card = document.createElement('div');
14995
+ card.className = 'learn-prop lr-card';
14996
+ card.dataset.testid = 'rejected-card';
14997
+
14998
+ const head = document.createElement('div');
14999
+ head.className = 'lp-head';
15000
+ const title = document.createElement('span');
15001
+ title.className = 'lp-title';
15002
+ title.textContent = r.title;
15003
+ head.appendChild(title);
15004
+ // A claim a rule or skill now owns was promoted, not refused. Labelling both
15005
+ // "Rejected" would tell the manager they said no to work they approved.
15006
+ const kind = document.createElement('span');
15007
+ kind.className = 'lp-scope ' + (r.kind === 'superseded' ? 'sc-project' : 'sc-manager');
15008
+ kind.dataset.testid = 'lr-kind';
15009
+ kind.textContent = r.kind === 'superseded' ? 'Superseded' : 'Rejected';
15010
+ head.appendChild(kind);
15011
+ box.appendChild(card);
15012
+ card.appendChild(head);
15013
+
15014
+ const why = document.createElement('div');
15015
+ why.className = 'lp-body';
15016
+ why.textContent = r.reason;
15017
+ card.appendChild(why);
15018
+
15019
+ const meta = document.createElement('div');
15020
+ meta.className = 'lp-stats';
15021
+ if (r.rejectedAt) {
15022
+ const when = document.createElement('span');
15023
+ when.className = 'lp-score';
15024
+ when.dataset.testid = 'lr-when';
15025
+ when.textContent = String(r.rejectedAt).slice(0, 10);
15026
+ meta.appendChild(when);
15027
+ }
15028
+ if (typeof r.recurrencesAtRejection === 'number') {
15029
+ const count = document.createElement('span');
15030
+ count.className = 'lp-score';
15031
+ count.textContent = `${r.recurrencesAtRejection} occurrence${r.recurrencesAtRejection === 1 ? '' : 's'} then`;
15032
+ meta.appendChild(count);
15033
+ }
15034
+ if (meta.children.length) card.appendChild(meta);
15035
+
15036
+ // The manager's own condition for looking at this again. R8 applies it as
15037
+ // written, so showing it here is how they check what they actually asked for.
15038
+ if (r.revisitCondition) {
15039
+ const revisit = document.createElement('div');
15040
+ revisit.className = 'lp-example';
15041
+ revisit.dataset.testid = 'lr-revisit';
15042
+ revisit.textContent = 'Revisit when: ' + r.revisitCondition;
15043
+ card.appendChild(revisit);
15044
+ }
15045
+ }
15046
+ return box;
15047
+ }
15048
+
15020
15049
  // Re-render ONLY the surface that owns this (scope, level) after an async fetch or
15021
15050
  // a write — never the whole app. The project level lives in the project workspace;
15022
15051
  // machine/org levels live in their top-tab areas. (A broad re-render here caused
@@ -15711,6 +15740,15 @@ function tfRenderLearningsList(host, scope, level, emptyText) {
15711
15740
  tfFetchPreservedLearnings(scope, level).then(() => tfReRenderScope(scope, level)).catch(() => {});
15712
15741
  }
15713
15742
 
15743
+ // Issue #1857 R19. Rejections are personal to the manager, so only their own
15744
+ // surface asks for them; the company and reverse-mentoring lists are other
15745
+ // people's corpora and a refusal there is not this manager's to review.
15746
+ const showsRejections = scope === 'manager';
15747
+ if (showsRejections && tfRejectionsCache() === undefined) {
15748
+ tfFetchRejections().then(() => tfReRenderScope(scope, level)).catch(() => {});
15749
+ }
15750
+ const rejections = showsRejections && Array.isArray(tfRejectionsCache()) ? tfRejectionsCache() : [];
15751
+
15714
15752
  const addCategories = scope === 'reverse' ? ['coaching']
15715
15753
  : scope === 'org' ? ['avoid', 'preference', 'repeat', 'coaching']
15716
15754
  : ['avoid', 'preference', 'repeat'];
@@ -15815,6 +15853,7 @@ function tfRenderLearningsList(host, scope, level, emptyText) {
15815
15853
 
15816
15854
  if (list.children.length) {
15817
15855
  host.appendChild(list);
15856
+ if (rejections.length && !shareMode) host.appendChild(tfRejectedSection(rejections));
15818
15857
  if (!shareMode) { host.appendChild(addBtn); wireAdd(); }
15819
15858
  if (shareMode) tfUpdateShareBar(host, scope, level);
15820
15859
  return;
@@ -15837,6 +15876,7 @@ function tfRenderLearningsList(host, scope, level, emptyText) {
15837
15876
  empty.appendChild(emoji); empty.appendChild(title); empty.appendChild(sub);
15838
15877
  host.appendChild(empty);
15839
15878
  if (shareMode) { tfUpdateShareBar(host, scope, level); return; }
15879
+ if (rejections.length) host.appendChild(tfRejectedSection(rejections));
15840
15880
  host.appendChild(addBtn);
15841
15881
  wireAdd();
15842
15882
  }
@@ -15872,7 +15912,7 @@ function tfLearningBatchBar(proposals, scope) {
15872
15912
  accept.dataset.testid = 'learn-batch-accept';
15873
15913
  // Do not say "Save all 5" when one of the five is skipped. Count what will
15874
15914
  // actually be written, and name the skips separately.
15875
- const skipped = proposals.filter((p) => (tfProposalOverrides[p.id] || {}).skipped);
15915
+ const skipped = proposals.filter((p) => (tfProposalOverrides[tfProposalKey(p)] || {}).skipped);
15876
15916
  const saving = proposals.length - skipped.length;
15877
15917
  if (changed.length === 0) {
15878
15918
  accept.textContent = `✓ Keep all ${proposals.length}`;
@@ -15898,7 +15938,7 @@ function tfLearningBatchBar(proposals, scope) {
15898
15938
  if (accept.getAttribute('aria-disabled') === 'true') {
15899
15939
  const first = missingReason[0];
15900
15940
  const field = first
15901
- ? document.getElementById('lp-why-' + String(first.id).replace(/[^a-zA-Z0-9_-]/g, ''))
15941
+ ? document.getElementById('lp-why-' + String(tfProposalKey(first)).replace(/[^a-zA-Z0-9_-]/g, ''))
15902
15942
  : null;
15903
15943
  if (field) {
15904
15944
  field.scrollIntoView({ block: 'center' });
@@ -15918,7 +15958,7 @@ function tfLearningBatchBar(proposals, scope) {
15918
15958
  skipAll.textContent = 'Skip all';
15919
15959
  skipAll.addEventListener('click', async () => {
15920
15960
  skipAll.disabled = true;
15921
- for (const p of proposals) tfProposalDecisions[p.id] = 'reject';
15961
+ for (const p of proposals) tfProposalDecisions[tfProposalKey(p)] = 'reject';
15922
15962
  await tfRelayBatch(scope, `Skip all ${proposals.length} proposals.`);
15923
15963
  });
15924
15964
  bar.appendChild(skipAll);
@@ -15931,20 +15971,20 @@ function tfLearningBatchBar(proposals, scope) {
15931
15971
  // wiped by the next render before the manager could save it.
15932
15972
  const tfProposalOverrides = {};
15933
15973
  function tfProposalLevel(p) {
15934
- const o = tfProposalOverrides[p.id];
15974
+ const o = tfProposalOverrides[tfProposalKey(p)];
15935
15975
  return (o && o.level) || p.suggestedLevel || 'manager';
15936
15976
  }
15937
15977
  function tfProposalReason(p) {
15938
- const o = tfProposalOverrides[p.id];
15978
+ const o = tfProposalOverrides[tfProposalKey(p)];
15939
15979
  return (o && o.reason) || '';
15940
15980
  }
15941
15981
  function tfProposalChanged(p) {
15942
- const o = tfProposalOverrides[p.id];
15982
+ const o = tfProposalOverrides[tfProposalKey(p)];
15943
15983
  if (!o) return false;
15944
15984
  return !!o.skipped || (!!o.level && o.level !== (p.suggestedLevel || 'manager'));
15945
15985
  }
15946
15986
  function tfSetProposalOverride(p, patch) {
15947
- tfProposalOverrides[p.id] = Object.assign({}, tfProposalOverrides[p.id] || {}, patch);
15987
+ tfProposalOverrides[tfProposalKey(p)] = Object.assign({}, tfProposalOverrides[tfProposalKey(p)] || {}, patch);
15948
15988
  }
15949
15989
 
15950
15990
  // Issue #1002 R13: accepting the batch relays ONE turn describing every decision,
@@ -15952,18 +15992,22 @@ function tfSetProposalOverride(p, patch) {
15952
15992
  // the job persists as an unprocessed signal for the next run to read.
15953
15993
  async function tfAcceptLearningBatch(proposals, scope) {
15954
15994
  const lines = [];
15995
+ const decisions = [];
15955
15996
  for (const p of proposals) {
15956
15997
  const level = tfProposalLevel(p);
15957
- const o = tfProposalOverrides[p.id] || {};
15998
+ const o = tfProposalOverrides[tfProposalKey(p)] || {};
15958
15999
  if (o.skipped) {
15959
- tfProposalDecisions[p.id] = 'reject';
16000
+ tfProposalDecisions[tfProposalKey(p)] = 'reject';
15960
16001
  lines.push(`- Skip "${p.title}". Reason: ${tfProposalReason(p) || '(none given)'}`);
16002
+ decisions.push({ proposalId: tfProposalKey(p), revision: p.revision, action: 'reject', level, reason: tfProposalReason(p) || '' });
15961
16003
  } else if (tfProposalChanged(p)) {
15962
- tfProposalDecisions[p.id] = 'promote:' + level;
16004
+ tfProposalDecisions[tfProposalKey(p)] = 'promote:' + level;
15963
16005
  lines.push(`- Save "${p.title}" at ${tfLevelWord(level)}. Reason: ${tfProposalReason(p) || '(none given)'}`);
16006
+ decisions.push({ proposalId: tfProposalKey(p), revision: p.revision, action: 'approve', level, reason: tfProposalReason(p) || '' });
15964
16007
  } else {
15965
- tfProposalDecisions[p.id] = 'approve';
16008
+ tfProposalDecisions[tfProposalKey(p)] = 'approve';
15966
16009
  lines.push(`- Keep "${p.title}" at ${tfLevelWord(level)}.`);
16010
+ decisions.push({ proposalId: tfProposalKey(p), revision: p.revision, action: 'approve', level, reason: tfProposalReason(p) || '' });
15967
16011
  }
15968
16012
  }
15969
16013
  const msg = [
@@ -15971,7 +16015,10 @@ async function tfAcceptLearningBatch(proposals, scope) {
15971
16015
  ...lines,
15972
16016
  'Apply them all, and save each stated reason as a signal so future runs get this right without asking.',
15973
16017
  ].join('\n');
15974
- await tfRelayBatch(scope, msg);
16018
+ // Issue #1857 R18. The prose above is what the manager reads back; `decisions`
16019
+ // is what the job records against. Previously only the prose existed, naming
16020
+ // each proposal by title, and the job had to guess which revision that was.
16021
+ await tfRelayBatch(scope, msg, decisions);
15975
16022
  }
15976
16023
 
15977
16024
  function tfLevelWord(level) {
@@ -15982,12 +16029,17 @@ function tfLevelWord(level) {
15982
16029
 
15983
16030
  // Relay one manager turn into the running learning job, through the same
15984
16031
  // continueRun path every other in-conversation control uses. No second write path.
15985
- async function tfRelayBatch(scope, message) {
16032
+ async function tfRelayBatch(scope, message, decisions) {
15986
16033
  const conv = tfFindLearningRun(scope);
15987
16034
  if (conv && conv.runId) {
15988
16035
  const prev = state.activeId;
15989
16036
  state.activeId = conv.id;
15990
- try { await continueRun(message); } finally { state.activeId = prev; }
16037
+ try {
16038
+ // Issue #1857 R18. Carried the same way an approve_delivery selection already
16039
+ // travels: the job records against `proposalId` and `revision` rather than
16040
+ // matching the titles quoted in the sentence the manager reads.
16041
+ await continueRun(message, decisions && decisions.length ? { proposalDecisions: decisions } : undefined);
16042
+ } finally { state.activeId = prev; }
15991
16043
  } else if (typeof showStatus === 'function') {
15992
16044
  showStatus('Run Sleep on learnings first, then your decisions go straight into that run.', false);
15993
16045
  }
@@ -16192,27 +16244,32 @@ async function tfLaunchShareWithOthers(scope, level, selectedTitles, job, projec
16192
16244
  }
16193
16245
 
16194
16246
  function tfProposalCard(p, scope) {
16195
- const meta = tfScopeMeta(scope);
16196
16247
  const card = document.createElement('div');
16197
16248
  card.className = 'learn-prop';
16198
- const decided = tfProposalDecisions[p.id];
16249
+ const decided = tfProposalDecisions[p.proposalId];
16199
16250
  if (decided) card.classList.add('resolved');
16200
16251
 
16252
+ // Issue #1857: no scope chip. It showed the run's scope while the level buttons
16253
+ // below showed the proposal's, so a proposal suggested at Project inside a
16254
+ // Manager-scoped run was chipped MANAGER and footed "this project only". The
16255
+ // buttons already carry the level; two controls for one fact is how they came
16256
+ // to disagree. Invisible before this change because a parsed proposal could not
16257
+ // express a level that differed from its run.
16201
16258
  const head = document.createElement('div');
16202
16259
  head.className = 'lp-head';
16203
- const sc = document.createElement('span');
16204
- sc.className = 'lp-scope ' + meta.cls;
16205
- sc.textContent = meta.label;
16206
16260
  const title = document.createElement('span');
16207
16261
  title.className = 'lp-title';
16208
16262
  title.textContent = p.title;
16209
- head.appendChild(sc); head.appendChild(title);
16263
+ head.appendChild(title);
16210
16264
  card.appendChild(head);
16211
16265
 
16212
- if (p.body) {
16266
+ // The takeaway and the example. Both were authored by the job and both were
16267
+ // thrown away by the parse, which hardcoded them empty, so until now a manager
16268
+ // approved or rejected a lesson on its title alone.
16269
+ if (p.takeaway) {
16213
16270
  const body = document.createElement('div');
16214
16271
  body.className = 'lp-body';
16215
- body.textContent = p.body;
16272
+ body.textContent = p.takeaway;
16216
16273
  card.appendChild(body);
16217
16274
  }
16218
16275
  if (p.example) {
@@ -16222,6 +16279,43 @@ function tfProposalCard(p, scope) {
16222
16279
  card.appendChild(ex);
16223
16280
  }
16224
16281
 
16282
+ // A reopening says so, in the manager's own words, with both counts. Without
16283
+ // this a claim they already rejected would return looking like a new one.
16284
+ if (p.reopening) {
16285
+ const reopen = document.createElement('div');
16286
+ reopen.className = 'lp-reopen';
16287
+ reopen.dataset.testid = 'lp-reopen';
16288
+ reopen.textContent = `You rejected this on ${p.reopening.rejectedAt}: "${p.reopening.reason}". `
16289
+ + `It was at ${p.reopening.countAtRejection} occurrence`
16290
+ + `${p.reopening.countAtRejection === 1 ? '' : 's'} then and is at ${p.recurrences} now.`;
16291
+ card.appendChild(reopen);
16292
+ }
16293
+
16294
+ // Evidence, using the library's own chips. The trajectory is what makes
16295
+ // "leave it for now" a position rather than a deferred chore.
16296
+ const stats = document.createElement('div');
16297
+ stats.className = 'lp-stats';
16298
+ if (p.domain) {
16299
+ const domain = document.createElement('span');
16300
+ domain.className = 'lp-domain';
16301
+ domain.textContent = p.domain;
16302
+ stats.appendChild(domain);
16303
+ }
16304
+ const occurrences = document.createElement('span');
16305
+ occurrences.className = 'lp-score';
16306
+ occurrences.dataset.testid = 'lp-occurrences';
16307
+ occurrences.textContent = typeof p.recurrencesAtLastReview === 'number'
16308
+ ? `${p.recurrences} occurrences, up from ${p.recurrencesAtLastReview}`
16309
+ : `${p.recurrences} occurrence${p.recurrences === 1 ? '' : 's'}`;
16310
+ stats.appendChild(occurrences);
16311
+ const kind = document.createElement('span');
16312
+ kind.className = 'lp-strength';
16313
+ kind.textContent = p.changeType === 'update' ? 'Updates an existing lesson'
16314
+ : p.changeType === 'cold' ? 'Revives a dormant lesson'
16315
+ : 'New lesson';
16316
+ stats.appendChild(kind);
16317
+ card.appendChild(stats);
16318
+
16225
16319
  // Issue #1755 R6: a proposal card reads as two zones: what you can still change
16226
16320
  // before deciding, and the decision itself. They used to sit on one undivided
16227
16321
  // row, so a level button and a Skip button looked like the same kind of control.
@@ -16258,14 +16352,18 @@ function tfProposalCard(p, scope) {
16258
16352
 
16259
16353
  // The recommendation, when the job made one, with its reason. R6: absent a
16260
16354
  // reason there is no nudge, so the row simply states where it will be saved.
16261
- if (p.suggestion && p.suggestionReason) {
16355
+ // #1857: the level is `suggestedLevel` on the wire and is always present, so the
16356
+ // reason alone decides whether there is a nudge to show. The old parse carried a
16357
+ // separate `suggestion` field that was set only when the job named a level in
16358
+ // prose, which made the nudge depend on how the sentence happened to be written.
16359
+ if (p.suggestionReason) {
16262
16360
  const why = document.createElement('div');
16263
16361
  why.className = 'lp-reason';
16264
16362
  const b = document.createElement('b');
16265
16363
  // Name the suggested level with the SAME words the picker below uses. Reading
16266
16364
  // "Suggested: Project" above a button that says "This project" makes the
16267
16365
  // manager check whether those are the same thing.
16268
- b.textContent = 'Suggested: ' + (TF_LEVEL_PICK_LABEL[p.suggestion] || tfScopeMeta(p.suggestion).label || p.suggestion) + '.';
16366
+ b.textContent = 'Suggested: ' + (TF_LEVEL_PICK_LABEL[suggested] || tfScopeMeta(suggested).label || suggested) + '.';
16269
16367
  why.appendChild(b);
16270
16368
  why.appendChild(document.createTextNode(' ' + p.suggestionReason));
16271
16369
  card.appendChild(why);
@@ -16282,7 +16380,7 @@ function tfProposalCard(p, scope) {
16282
16380
  // A skipped lesson is saved at no level, so no level is selected on that row.
16283
16381
  // Showing "Skipped" next to a highlighted level says two contradictory things
16284
16382
  // about the same row. The buttons stay live: picking a level clears the skip.
16285
- const isSkipped = !!(tfProposalOverrides[p.id] || {}).skipped;
16383
+ const isSkipped = !!(tfProposalOverrides[p.proposalId] || {}).skipped;
16286
16384
  if (isSkipped) pick.classList.add('lp-moot');
16287
16385
  for (const [level, label] of LEVELS) {
16288
16386
  const b = document.createElement('button');
@@ -16312,13 +16410,13 @@ function tfProposalCard(p, scope) {
16312
16410
  decide.dataset.testid = 'lp-decide';
16313
16411
 
16314
16412
  const skip = document.createElement('button');
16315
- skip.className = 'lp-skip' + ((tfProposalOverrides[p.id] || {}).skipped || decided === 'reject' ? ' on' : '');
16413
+ skip.className = 'lp-skip' + ((tfProposalOverrides[p.proposalId] || {}).skipped || decided === 'reject' ? ' on' : '');
16316
16414
  skip.type = 'button';
16317
16415
  skip.dataset.testid = 'lp-skip';
16318
- skip.textContent = (tfProposalOverrides[p.id] || {}).skipped || decided === 'reject' ? '✕ Skipped' : 'Skip';
16416
+ skip.textContent = (tfProposalOverrides[p.proposalId] || {}).skipped || decided === 'reject' ? '✕ Skipped' : 'Skip';
16319
16417
  skip.disabled = !!decided;
16320
16418
  skip.addEventListener('click', () => {
16321
- const wasSkipped = !!(tfProposalOverrides[p.id] || {}).skipped;
16419
+ const wasSkipped = !!(tfProposalOverrides[p.proposalId] || {}).skipped;
16322
16420
  tfSetProposalOverride(p, { skipped: !wasSkipped });
16323
16421
  reRender();
16324
16422
  });
@@ -16330,10 +16428,10 @@ function tfProposalCard(p, scope) {
16330
16428
  if (tfProposalChanged(p) && !decided) {
16331
16429
  const why = document.createElement('div');
16332
16430
  why.className = 'lp-why';
16333
- const id = 'lp-why-' + String(p.id).replace(/[^a-zA-Z0-9_-]/g, '');
16431
+ const id = 'lp-why-' + String(p.proposalId).replace(/[^a-zA-Z0-9_-]/g, '');
16334
16432
  const label = document.createElement('label');
16335
16433
  label.setAttribute('for', id);
16336
- label.textContent = (tfProposalOverrides[p.id] || {}).skipped
16434
+ label.textContent = (tfProposalOverrides[p.proposalId] || {}).skipped
16337
16435
  ? 'Why skip it?'
16338
16436
  : 'Why this level instead?';
16339
16437
  const ta = document.createElement('textarea');
@@ -16368,7 +16466,7 @@ function tfProposalCard(p, scope) {
16368
16466
  // R8: state what the decision will actually write, in the zone where it is made.
16369
16467
  const note = document.createElement('span');
16370
16468
  note.className = 'lp-scope-note';
16371
- note.textContent = (tfProposalOverrides[p.id] || {}).skipped
16469
+ note.textContent = (tfProposalOverrides[p.proposalId] || {}).skipped
16372
16470
  ? 'Nothing is written. The skip is recorded as a signal.'
16373
16471
  : (TF_LEVEL_TITLE[currentLevel] || tfScopeMeta(scope).note);
16374
16472
  decide.appendChild(note);
@@ -16382,7 +16480,7 @@ async function tfPromoteProposal(originScope, proposal, targetScope) {
16382
16480
  const label = targetScope === 'org' ? 'Company' : targetScope === 'manager' ? 'Manager' : 'Project';
16383
16481
  const conv = tfFindLearningRun(originScope);
16384
16482
  const message = `Promote the proposal "${proposal.title}" to ${label.toLowerCase()} learnings (scope: ${targetScope}).`;
16385
- tfProposalDecisions[proposal.id] = 'promote:' + targetScope;
16483
+ tfProposalDecisions[tfProposalKey(proposal)] = 'promote:' + targetScope;
16386
16484
  if (conv && conv.runId) {
16387
16485
  const prev = state.activeId;
16388
16486
  state.activeId = conv.id;
@@ -19016,4 +19114,9 @@ if (typeof window !== 'undefined') {
19016
19114
  // tests can assert the rendered controls without depending on click coordinates.
19017
19115
  window.openInlineCreateEmployee = openInlineCreateEmployee;
19018
19116
  window.submitComposerDelegate = submitComposerDelegate;
19117
+ // Issue #1911: the pane-surface seams, so the suite can drive the host and
19118
+ // the Coach entry point without clicking through three different rails.
19119
+ window.openCoachSurface = openCoachSurface;
19120
+ window.paneSurfaceOpen = paneSurfaceOpen;
19121
+ window.buildTeachIntakeInstructions = buildTeachIntakeInstructions;
19019
19122
  }