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.
- package/dist/registry/scripts/learning/completion-state.js +10 -2
- package/dist/src/ai-hub/conversation-store.js +14 -11
- package/dist/src/ai-hub/desktop-main.js +7 -3
- package/dist/src/ai-hub/hosts.js +28 -0
- package/dist/src/ai-hub/manager-turns.js +16 -0
- package/dist/src/ai-hub/server.js +148 -6
- package/dist/src/cli/setup/ide-invocation-surfaces.js +1 -1
- package/dist/src/core/handoff-contracts.js +122 -21
- package/dist/src/core/proposal-set-contract.js +214 -0
- package/dist/src/core/run-recovery-state.js +9 -0
- package/dist/src/core/utils/local-registry-resolver.js +33 -6
- package/dist/src/local-mcp-server/learning-context-builder.js +128 -1
- package/dist/src/local-mcp-server/learning-usage-eligibility.js +59 -0
- package/dist/src/local-mcp-server/learning-usage-projection.js +5 -1
- package/dist/src/local-mcp-server/learning-usage-store.js +163 -38
- package/package.json +5 -2
- package/public/ai-hub/coach-intake-surface.js +415 -0
- package/public/ai-hub/index.html +21 -63
- package/public/ai-hub/pane-surfaces.js +315 -0
- package/public/ai-hub/review.css +46 -3
- package/public/ai-hub/script.js +474 -370
- package/public/ai-hub/styles.css +61 -40
package/public/ai-hub/script.js
CHANGED
|
@@ -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 =
|
|
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 #
|
|
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
|
-
*
|
|
5673
|
-
*
|
|
5674
|
-
*
|
|
5675
|
-
*
|
|
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
|
|
5678
|
-
|
|
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
|
|
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
|
-
//
|
|
7098
|
-
|
|
7099
|
-
|
|
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
|
|
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')
|
|
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
|
-
|
|
7596
|
-
|
|
7597
|
-
|
|
7598
|
-
|
|
7599
|
-
|
|
7600
|
-
|
|
7601
|
-
|
|
7602
|
-
|
|
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.
|
|
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.
|
|
8231
|
-
//
|
|
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.
|
|
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.
|
|
8619
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', () =>
|
|
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
|
-
/**
|
|
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
|
-
|
|
14846
|
-
|
|
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
|
-
|
|
14889
|
-
|
|
14890
|
-
|
|
14891
|
-
|
|
14892
|
-
|
|
14893
|
-
|
|
14894
|
-
|
|
14895
|
-
|
|
14896
|
-
|
|
14897
|
-
|
|
14898
|
-
|
|
14899
|
-
|
|
14900
|
-
|
|
14901
|
-
|
|
14902
|
-
|
|
14903
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
15979
|
+
const o = tfProposalOverrides[tfProposalKey(p)];
|
|
15939
15980
|
return (o && o.reason) || '';
|
|
15940
15981
|
}
|
|
15941
15982
|
function tfProposalChanged(p) {
|
|
15942
|
-
const o = tfProposalOverrides[p
|
|
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
|
|
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
|
|
15999
|
+
const o = tfProposalOverrides[tfProposalKey(p)] || {};
|
|
15958
16000
|
if (o.skipped) {
|
|
15959
|
-
tfProposalDecisions[p
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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(
|
|
16264
|
+
head.appendChild(title);
|
|
16210
16265
|
card.appendChild(head);
|
|
16211
16266
|
|
|
16212
|
-
|
|
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.
|
|
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
|
-
|
|
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[
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
}
|