fraim-hub 2.0.333 → 2.0.337
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/src/ai-hub/attachment-store.js +35 -2
- package/dist/src/ai-hub/desktop-main.js +5 -0
- package/dist/src/ai-hub/native-voice-input.js +141 -0
- package/dist/src/ai-hub/server.js +29 -2
- package/package.json +2 -2
- package/public/ai-hub/index.html +51 -65
- package/public/ai-hub/review.css +3 -2
- package/public/ai-hub/script.js +501 -682
- package/public/ai-hub/styles.css +93 -330
package/public/ai-hub/script.js
CHANGED
|
@@ -140,12 +140,11 @@ function gatherElements() {
|
|
|
140
140
|
// Issue #385: team roster
|
|
141
141
|
'team-roster',
|
|
142
142
|
'empty', 'active-conv', 'active-title', 'active-identity', 'run-state-pill', 'run-stop-btn',
|
|
143
|
-
'conversation-focus-btn', 'conversation-focus-overlay', 'conversation-focus-close', 'conversation-focus-title', 'conversation-focus-subtitle', 'conversation-focus-panels',
|
|
144
143
|
'messages',
|
|
145
144
|
'coach-text', 'send', 'micro-manage', 'micro-log', 'resume-command', 'resume-command-code', 'resume-command-copy',
|
|
146
|
-
'
|
|
145
|
+
'coach-note',
|
|
147
146
|
// Issue #1651: composer attachments.
|
|
148
|
-
'coach', 'composer-dropzone', 'staged-attachments', 'attach-btn', 'attach-file-input',
|
|
147
|
+
'coach', 'composer-dropzone', 'staged-attachments', 'attach-btn', 'attach-file-input', 'voice-input-btn', 'voice-input-status',
|
|
149
148
|
'thread-panel', 'thread-panel-kicker', 'micro-manage-toggle', 'micro-return', 'micro-activity-state',
|
|
150
149
|
'quick-coach-btns',
|
|
151
150
|
'positive-reinforcement-btn', 'positive-reinforcement-dropdown',
|
|
@@ -171,8 +170,6 @@ function gatherElements() {
|
|
|
171
170
|
'word-context-bar', 'word-ctx-text', 'word-ctx-refresh',
|
|
172
171
|
// Issue #512 S6: review experience (R7) + learnings (R9) + reverse mentoring (R10).
|
|
173
172
|
'review-actions', 'review-approve', 'review-request-changes', 'review-handoff',
|
|
174
|
-
// Issue #770: Deliverables panel (single-run artifact home).
|
|
175
|
-
'deliverables-panel', 'deliverables-summary', 'deliverables-body',
|
|
176
173
|
// Issue #540: persona grid, manager team pool, hire strip, hire checkout modal.
|
|
177
174
|
'persona-grid', 'manager-team-pool',
|
|
178
175
|
'hire-strip', 'hs-avatar', 'hs-title', 'hs-sub', 'hs-add-btn', 'hs-get-btn', 'hs-skip-btn',
|
|
@@ -335,6 +332,7 @@ function mergePersonaProjection(incoming, previous) {
|
|
|
335
332
|
function applyBootstrap(bootstrap, docUrl) {
|
|
336
333
|
bootstrap = mergePersonaProjection(bootstrap, state.bootstrap);
|
|
337
334
|
state.bootstrap = bootstrap;
|
|
335
|
+
syncVoiceInput();
|
|
338
336
|
state.projectPath = bootstrap.project.path;
|
|
339
337
|
// Issue #1044: seed client-side tombstone set from server preferences so
|
|
340
338
|
// tfEnsureCurrentProject does not re-inject a path the user just deleted.
|
|
@@ -366,8 +364,7 @@ function applyBootstrap(bootstrap, docUrl) {
|
|
|
366
364
|
}
|
|
367
365
|
// Render header project name + status line.
|
|
368
366
|
els['project-name'].textContent = friendlyProjectName(bootstrap.project.path);
|
|
369
|
-
|
|
370
|
-
els['status-line'].classList.toggle('error', !bootstrap.project.exists || !bootstrap.project.hasFraim);
|
|
367
|
+
if (!bootstrap.project.exists || !bootstrap.project.hasFraim) showStatus(bootstrap.project.message, true);
|
|
371
368
|
// Update the compact project label shown in task-pane surface rail.
|
|
372
369
|
const tpLabel = document.getElementById('tp-project-label');
|
|
373
370
|
if (tpLabel) {
|
|
@@ -2168,6 +2165,64 @@ function syncQuickCoachButtons(conv) {
|
|
|
2168
2165
|
}
|
|
2169
2166
|
}
|
|
2170
2167
|
|
|
2168
|
+
let voiceInputBusy = false;
|
|
2169
|
+
|
|
2170
|
+
function syncVoiceInput() {
|
|
2171
|
+
const button = els['voice-input-btn'];
|
|
2172
|
+
if (!button) return;
|
|
2173
|
+
const capability = state.bootstrap && state.bootstrap.voiceInput;
|
|
2174
|
+
const unsupported = !capability || capability.mode === 'unsupported';
|
|
2175
|
+
button.disabled = voiceInputBusy || unsupported || !!els['coach-text'].disabled;
|
|
2176
|
+
button.setAttribute('aria-busy', String(voiceInputBusy));
|
|
2177
|
+
const label = unsupported
|
|
2178
|
+
? (capability && capability.guidance) || 'Voice input is unavailable. Reload the Hub to check again.'
|
|
2179
|
+
: capability.mode === 'manual' ? capability.guidance : 'Start voice input using system dictation';
|
|
2180
|
+
button.title = label;
|
|
2181
|
+
button.setAttribute('aria-label', label);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
function showVoiceInputStatus(message, failed = false) {
|
|
2185
|
+
const status = els['voice-input-status'];
|
|
2186
|
+
status.textContent = message;
|
|
2187
|
+
status.hidden = false;
|
|
2188
|
+
status.classList.toggle('voice-input-status--error', failed);
|
|
2189
|
+
// Narrow layouts scroll the whole pane. Keep both recovery text and actions
|
|
2190
|
+
// visible when a new notice increases the composer's height, without refocusing.
|
|
2191
|
+
status.closest('.coach-input').scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
async function startVoiceInput() {
|
|
2195
|
+
const capability = state.bootstrap && state.bootstrap.voiceInput;
|
|
2196
|
+
const textarea = els['coach-text'];
|
|
2197
|
+
if (voiceInputBusy || !capability || capability.mode === 'unsupported' || textarea.disabled) return;
|
|
2198
|
+
const { selectionStart, selectionEnd, selectionDirection } = textarea;
|
|
2199
|
+
textarea.focus({ preventScroll: true });
|
|
2200
|
+
textarea.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
|
|
2201
|
+
if (capability.mode === 'manual') {
|
|
2202
|
+
showVoiceInputStatus(capability.guidance);
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
voiceInputBusy = true;
|
|
2206
|
+
syncVoiceInput();
|
|
2207
|
+
showVoiceInputStatus('Requesting system dictation...');
|
|
2208
|
+
try {
|
|
2209
|
+
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
2210
|
+
// Do not launch an OS input action if the manager moved to another control.
|
|
2211
|
+
if (document.activeElement !== textarea) {
|
|
2212
|
+
showVoiceInputStatus('Voice input canceled because the composer lost focus. Try again.');
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
const result = await requestJson('/api/ai-hub/voice-input/start', { method: 'POST' }, 7000);
|
|
2216
|
+
showVoiceInputStatus(result.message);
|
|
2217
|
+
} catch (error) {
|
|
2218
|
+
const message = error.message || 'Could not request system dictation.';
|
|
2219
|
+
showVoiceInputStatus(message.includes(capability.guidance) ? message : `${message} ${capability.guidance}`, true);
|
|
2220
|
+
} finally {
|
|
2221
|
+
voiceInputBusy = false;
|
|
2222
|
+
syncVoiceInput();
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2171
2226
|
function composerIsArmed() {
|
|
2172
2227
|
return !!(
|
|
2173
2228
|
(els['coach-text'] && els['coach-text'].value.trim().length > 0) ||
|
|
@@ -2181,6 +2236,8 @@ function syncComposerPlaceholder(conv = activeConversation()) {
|
|
|
2181
2236
|
if (!textarea || textarea.disabled) return;
|
|
2182
2237
|
if (state.cpSelectedJob) {
|
|
2183
2238
|
textarea.placeholder = textarea.dataset.placeholderArmed || 'Now type what you want done...';
|
|
2239
|
+
} else if (convAwaitingReview(conv)) {
|
|
2240
|
+
textarea.placeholder = textarea.dataset.placeholderReview;
|
|
2184
2241
|
} else if (conv) {
|
|
2185
2242
|
textarea.placeholder = textarea.dataset.placeholderActive || 'Coach this run, or type new instructions...';
|
|
2186
2243
|
} else {
|
|
@@ -2323,7 +2380,7 @@ function manageViewFor(conv) {
|
|
|
2323
2380
|
}
|
|
2324
2381
|
function captureManagePosition() {
|
|
2325
2382
|
const view = manageViews.get(displayedManageKey);
|
|
2326
|
-
if (!view
|
|
2383
|
+
if (!view) return;
|
|
2327
2384
|
const node = view.micro ? els['micro-log'] : els['messages'];
|
|
2328
2385
|
if (!node || !node.clientHeight) return;
|
|
2329
2386
|
view[view.micro ? 'activity' : 'messages'] = {
|
|
@@ -2351,7 +2408,6 @@ function setManageMicroMode(enabled) {
|
|
|
2351
2408
|
if (!conv || conv.compareMode === 'ab') return;
|
|
2352
2409
|
captureManagePosition();
|
|
2353
2410
|
manageViewFor(conv).micro = enabled;
|
|
2354
|
-
els['thread-panel'].open = true;
|
|
2355
2411
|
applyManageView(conv);
|
|
2356
2412
|
syncMicroArchive(conv);
|
|
2357
2413
|
restoreManagePosition(conv);
|
|
@@ -2372,15 +2428,6 @@ function syncManageActivityState(conv, events) {
|
|
|
2372
2428
|
els['micro-return'].hidden = !(error || conversationUiState(conv) === 'waiting' || convAwaitingReview(conv));
|
|
2373
2429
|
}
|
|
2374
2430
|
|
|
2375
|
-
function syncConversationPanels(conv, switchedConv) {
|
|
2376
|
-
if (!conv) return;
|
|
2377
|
-
const panelState = panelStateFor(conv.id);
|
|
2378
|
-
if (switchedConv) {
|
|
2379
|
-
if (els['thread-panel']) {
|
|
2380
|
-
els['thread-panel'].open = panelState.thread ?? true;
|
|
2381
|
-
}
|
|
2382
|
-
}
|
|
2383
|
-
}
|
|
2384
2431
|
|
|
2385
2432
|
function upsertConversation(conv, options) {
|
|
2386
2433
|
// Issue #708: route to the conversation's scope bucket (manager/company → sentinel
|
|
@@ -2402,6 +2449,24 @@ function newConversationId() {
|
|
|
2402
2449
|
// #533 R6: remove a run from the Hub. This is a Hub-local projection delete — it
|
|
2403
2450
|
// drops the conversation from state + localStorage and clears any pointer to it.
|
|
2404
2451
|
// It never touches artifacts the employee wrote to disk (the confirm copy says so).
|
|
2452
|
+
function clearDeletedConversationReferences(id) {
|
|
2453
|
+
if (!id) return;
|
|
2454
|
+
if (activeConversationId === id || state.activeId === id || deletedConversationIds.has(activeConversationId)) {
|
|
2455
|
+
activeConversationId = null;
|
|
2456
|
+
state.activeId = null;
|
|
2457
|
+
}
|
|
2458
|
+
if (state.areaActiveId) {
|
|
2459
|
+
for (const area of Object.keys(state.areaActiveId)) {
|
|
2460
|
+
if (state.areaActiveId[area] === id) state.areaActiveId[area] = null;
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
if (typeof tf === 'object' && tf && tf.areaSelection) {
|
|
2464
|
+
for (const area of Object.keys(tf.areaSelection)) {
|
|
2465
|
+
if (tf.areaSelection[area] === id) tf.areaSelection[area] = null;
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2405
2470
|
async function deleteConversation(id) {
|
|
2406
2471
|
if (!id) return false;
|
|
2407
2472
|
const target = findConversation(id);
|
|
@@ -2410,9 +2475,8 @@ async function deleteConversation(id) {
|
|
|
2410
2475
|
// Tombstone before the async delete so any in-flight header/body hydrate that
|
|
2411
2476
|
// resolves during the server round trip cannot reselect or reinsert this run.
|
|
2412
2477
|
deletedConversationIds.add(id);
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
state.activeId = null;
|
|
2478
|
+
clearDeletedConversationReferences(id);
|
|
2479
|
+
if (!state.activeId) {
|
|
2416
2480
|
if (state.pollHandle) { window.clearInterval(state.pollHandle); state.pollHandle = null; }
|
|
2417
2481
|
}
|
|
2418
2482
|
try {
|
|
@@ -2431,12 +2495,10 @@ async function deleteConversation(id) {
|
|
|
2431
2495
|
if (next.length !== list.length) { state.conversations[key] = next; removed = true; }
|
|
2432
2496
|
}
|
|
2433
2497
|
const clearDeletedActive = () => {
|
|
2434
|
-
|
|
2435
|
-
activeConversationId = null;
|
|
2436
|
-
state.activeId = null;
|
|
2498
|
+
clearDeletedConversationReferences(id);
|
|
2437
2499
|
// Deleting the active run: stop its poll so no zombie interval keeps ticking
|
|
2438
2500
|
// against a conversation that no longer exists.
|
|
2439
|
-
if (state.pollHandle) { window.clearInterval(state.pollHandle); state.pollHandle = null; }
|
|
2501
|
+
if (!state.activeId && state.pollHandle) { window.clearInterval(state.pollHandle); state.pollHandle = null; }
|
|
2440
2502
|
};
|
|
2441
2503
|
clearDeletedActive();
|
|
2442
2504
|
if (!removed) return false;
|
|
@@ -2685,8 +2747,7 @@ function showCustomEmployeeDeleteConfirm(group, detailsEl) {
|
|
|
2685
2747
|
// ---------------------------------------------------------------------------
|
|
2686
2748
|
|
|
2687
2749
|
// Build a run-item button for any area rail.
|
|
2688
|
-
//
|
|
2689
|
-
// and tfRenderAreaPersonaGroup(). Extracted from renderRail's inner buildRunButton.
|
|
2750
|
+
// Used by the shared employee builder for Projects, Manager, and Company.
|
|
2690
2751
|
function buildAreaRunButton(conv, options) {
|
|
2691
2752
|
options = options || {};
|
|
2692
2753
|
const btn = document.createElement('button');
|
|
@@ -2750,7 +2811,7 @@ function buildAreaRunButton(conv, options) {
|
|
|
2750
2811
|
}
|
|
2751
2812
|
|
|
2752
2813
|
// Build a <div class="conv-employee-list"> with run buttons and optional delegation children.
|
|
2753
|
-
// childrenByManager: Map<id, conv[]> —
|
|
2814
|
+
// childrenByManager: Map<id, conv[]> — scoped by the caller before nesting.
|
|
2754
2815
|
function buildAreaGroupList(conversations, opts, childrenByManager) {
|
|
2755
2816
|
childrenByManager = childrenByManager || new Map();
|
|
2756
2817
|
opts = opts || {};
|
|
@@ -2842,19 +2903,21 @@ function appendEmployeeGroupWithActions(container, details) {
|
|
|
2842
2903
|
|
|
2843
2904
|
function renderRail() {
|
|
2844
2905
|
renderTeamRoster();
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2906
|
+
renderScopeEmployees(els['conv-list'], 'projects');
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
// All scopes share grouping, employee actions, nested workstreams and poll state.
|
|
2910
|
+
function renderScopeEmployees(host, area) {
|
|
2911
|
+
if (!host) return;
|
|
2912
|
+
const isProject = area === 'projects';
|
|
2913
|
+
const activeRailProjectKey = isProject ? (tf.activeProjectId || state.projectPath || 'current') : area;
|
|
2848
2914
|
const employeeGroupOpenState = new Map();
|
|
2849
|
-
|
|
2915
|
+
host.querySelectorAll('details.conv-employee-group[data-employee-key]').forEach((details) => {
|
|
2850
2916
|
const key = details.dataset ? details.dataset.employeeKey : '';
|
|
2851
2917
|
const projectKey = details.dataset ? (details.dataset.projectKey || activeRailProjectKey) : activeRailProjectKey;
|
|
2852
|
-
//
|
|
2853
|
-
//
|
|
2854
|
-
|
|
2855
|
-
// groups always follow the default (collapsed); with-run groups default open (#726 UX).
|
|
2856
|
-
const hasRuns = !!details.querySelector('.conv-item');
|
|
2857
|
-
if (key && hasRuns) employeeGroupOpenState.set(projectKey + ':' + key, details.open);
|
|
2918
|
+
// History groups default closed whether hydrated or empty. Preserve every
|
|
2919
|
+
// existing group, including an explicitly collapsed empty In Progress group.
|
|
2920
|
+
if (key) employeeGroupOpenState.set(projectKey + ':' + key, details.open);
|
|
2858
2921
|
});
|
|
2859
2922
|
const employeeGroupStateKey = (key) => activeRailProjectKey + ':' + key;
|
|
2860
2923
|
// Preserve the user's open/closed toggle across re-renders; otherwise fall back to
|
|
@@ -2865,13 +2928,15 @@ function renderRail() {
|
|
|
2865
2928
|
? employeeGroupOpenState.get(employeeGroupStateKey(key))
|
|
2866
2929
|
: defaultOpen
|
|
2867
2930
|
);
|
|
2868
|
-
|
|
2931
|
+
host.innerHTML = '';
|
|
2869
2932
|
// R4: filter by selected persona when one is active.
|
|
2870
2933
|
// #693 R1: the "Project Updates" section was retired; project-lifecycle runs
|
|
2871
2934
|
// (project-onboarding + sleep-on-learnings) now surface in the Runs list under
|
|
2872
2935
|
// their employee like any other run, so their history stays reachable.
|
|
2873
|
-
const
|
|
2874
|
-
|
|
2936
|
+
const scopedConversations = isProject
|
|
2937
|
+
? projectConversations().filter((conv) => convScope(conv) === 'project')
|
|
2938
|
+
: tfAreaScopedConversations(area);
|
|
2939
|
+
const managedChildren = scopedConversations.filter(isManagedDelegationChild);
|
|
2875
2940
|
const childrenByManager = new Map();
|
|
2876
2941
|
const childIdentity = (child) => child && (child.id || child.runId);
|
|
2877
2942
|
const addChildForManager = (managerId, child) => {
|
|
@@ -2894,7 +2959,7 @@ function renderRail() {
|
|
|
2894
2959
|
if (task.jobId && child.jobId && task.jobId !== child.jobId) return false;
|
|
2895
2960
|
return true;
|
|
2896
2961
|
};
|
|
2897
|
-
for (const manager of
|
|
2962
|
+
for (const manager of scopedConversations) {
|
|
2898
2963
|
const ledger = normalizeDelegationLedger(manager.delegation);
|
|
2899
2964
|
if (!ledger) continue;
|
|
2900
2965
|
for (const task of ledger.tasks || []) {
|
|
@@ -2905,12 +2970,12 @@ function renderRail() {
|
|
|
2905
2970
|
}
|
|
2906
2971
|
}
|
|
2907
2972
|
}
|
|
2908
|
-
const list =
|
|
2909
|
-
(!state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey) &&
|
|
2973
|
+
const list = scopedConversations.filter((conv) =>
|
|
2974
|
+
(!isProject || !state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey) &&
|
|
2910
2975
|
!isManagedDelegationChild(conv) &&
|
|
2911
2976
|
// #702/#708/#1408: manager/company-scoped runs surface on those tabs, not in a
|
|
2912
2977
|
// project, decided by scope alone — no job id is ever excluded from Projects.
|
|
2913
|
-
convScope(conv) === 'project'
|
|
2978
|
+
convScope(conv) === (isProject ? 'project' : area)
|
|
2914
2979
|
);
|
|
2915
2980
|
|
|
2916
2981
|
// Issue #851 Option A: In Progress section (top) + per-employee accordion tree.
|
|
@@ -2972,13 +3037,15 @@ function renderRail() {
|
|
|
2972
3037
|
...managerTeamKeys,
|
|
2973
3038
|
]);
|
|
2974
3039
|
// Apply persona filter.
|
|
2975
|
-
const
|
|
2976
|
-
|
|
2977
|
-
|
|
3040
|
+
const areaPersonas = isProject ? [] : tfAreaRailPersonas(area);
|
|
3041
|
+
const areaPersonaOrder = new Map(areaPersonas.map((persona, index) => [persona.key, index]));
|
|
3042
|
+
const filteredEmployeeKeys = isProject
|
|
3043
|
+
? (state.selectedPersonaKey ? new Set([state.selectedPersonaKey].filter((k) => projectEmployeeKeys.has(k))) : projectEmployeeKeys)
|
|
3044
|
+
: new Set(areaPersonaOrder.keys());
|
|
2978
3045
|
|
|
2979
3046
|
for (const key of filteredEmployeeKeys) {
|
|
2980
3047
|
if (doneGroups.has(key)) continue;
|
|
2981
|
-
const p =
|
|
3048
|
+
const p = getConversationPersona({ personaKey: key });
|
|
2982
3049
|
doneGroups.set(key, {
|
|
2983
3050
|
key,
|
|
2984
3051
|
label: p ? p.displayName : key,
|
|
@@ -2990,7 +3057,7 @@ function renderRail() {
|
|
|
2990
3057
|
|
|
2991
3058
|
// Compute latest kickoff timestamp per employee for employee tree sorting.
|
|
2992
3059
|
const employeeLatest = new Map();
|
|
2993
|
-
for (const conv of
|
|
3060
|
+
for (const conv of scopedConversations) {
|
|
2994
3061
|
if (!conv) continue;
|
|
2995
3062
|
const key = conv.personaKey || conversationAgentName(conv);
|
|
2996
3063
|
if (!key) continue;
|
|
@@ -3001,6 +3068,7 @@ function renderRail() {
|
|
|
3001
3068
|
// Sort employee groups: employees with completed runs first (latest kickoff),
|
|
3002
3069
|
// then no-run employees last (stable by key).
|
|
3003
3070
|
const sortedDoneGroups = [...doneGroups.values()].sort((a, b) => {
|
|
3071
|
+
if (!isProject) return (areaPersonaOrder.get(a.key) ?? Infinity) - (areaPersonaOrder.get(b.key) ?? Infinity);
|
|
3004
3072
|
const aLatest = employeeLatest.get(a.key) || 0;
|
|
3005
3073
|
const bLatest = employeeLatest.get(b.key) || 0;
|
|
3006
3074
|
const aHas = aLatest > 0 ? 1 : 0;
|
|
@@ -3025,7 +3093,7 @@ function renderRail() {
|
|
|
3025
3093
|
emptyWrap.appendChild(em);
|
|
3026
3094
|
inProgressSec.appendChild(emptyWrap);
|
|
3027
3095
|
}
|
|
3028
|
-
|
|
3096
|
+
host.appendChild(inProgressSec);
|
|
3029
3097
|
|
|
3030
3098
|
// ----- SECTION 2: Per-employee accordion tree -----
|
|
3031
3099
|
// Each employee gets their own accordion showing their COMPLETED runs underneath,
|
|
@@ -3073,11 +3141,11 @@ function renderRail() {
|
|
|
3073
3141
|
// personaKey is passed separately so renderCpRows can look up the jobIds correctly.
|
|
3074
3142
|
if (group.key.startsWith('custom:')) {
|
|
3075
3143
|
const displaySlug = group.label ? group.label.toLowerCase().replace(/\s+/g, '-') : group.key.replace('custom:', '');
|
|
3076
|
-
focusComposerForDelegate({ prefixSearch: '/' + displaySlug, personaKey: group.key });
|
|
3144
|
+
focusComposerForDelegate({ prefixSearch: '/' + displaySlug, personaKey: group.key, actionContext: managementActionContext(area) });
|
|
3077
3145
|
return;
|
|
3078
3146
|
}
|
|
3079
3147
|
const personaKey = group.sample && group.sample.personaKey ? group.key : null;
|
|
3080
|
-
focusComposerForDelegate(personaKey ? { employeeId: state.bootstrap && state.bootstrap.preferences ? state.bootstrap.preferences.employeeId : 'claude', prefixSearch: '/' + personaKey } : {});
|
|
3148
|
+
focusComposerForDelegate(personaKey ? { employeeId: state.bootstrap && state.bootstrap.preferences ? state.bootstrap.preferences.employeeId : 'claude', prefixSearch: '/' + personaKey, actionContext: managementActionContext(area) } : { actionContext: managementActionContext(area) });
|
|
3081
3149
|
});
|
|
3082
3150
|
}
|
|
3083
3151
|
let manageBtn = null;
|
|
@@ -3135,7 +3203,7 @@ function renderRail() {
|
|
|
3135
3203
|
emptyWrap.appendChild(em);
|
|
3136
3204
|
details.appendChild(emptyWrap);
|
|
3137
3205
|
}
|
|
3138
|
-
appendEmployeeGroupWithActions(
|
|
3206
|
+
appendEmployeeGroupWithActions(host, details);
|
|
3139
3207
|
}
|
|
3140
3208
|
|
|
3141
3209
|
// No FRAIM employee group: completed runs with no owning persona.
|
|
@@ -3162,29 +3230,29 @@ function renderRail() {
|
|
|
3162
3230
|
wcSummary.appendChild(wcCount);
|
|
3163
3231
|
wcDetails.appendChild(wcSummary);
|
|
3164
3232
|
wcDetails.appendChild(buildAreaGroupList(noFraimEmployeeConvs, {}, childrenByManager));
|
|
3165
|
-
|
|
3233
|
+
host.appendChild(wcDetails);
|
|
3166
3234
|
}
|
|
3167
3235
|
|
|
3168
3236
|
// #769: hire-pending polling indicator
|
|
3169
|
-
if (typeof tf !== 'undefined' && tf && tf.hirePendingState === 'polling') {
|
|
3237
|
+
if (isProject && typeof tf !== 'undefined' && tf && tf.hirePendingState === 'polling') {
|
|
3170
3238
|
const pendingRow = document.createElement('div');
|
|
3171
3239
|
pendingRow.className = 'hire-pending-row';
|
|
3172
3240
|
pendingRow.id = 'hire-pending-row';
|
|
3173
3241
|
pendingRow.innerHTML = '<div class="hire-pending-spinner"></div><span class="hire-pending-text">Checking your new hire…</span>';
|
|
3174
|
-
|
|
3175
|
-
} else if (typeof tf !== 'undefined' && tf && tf.hirePendingState === 'timeout') {
|
|
3242
|
+
host.appendChild(pendingRow);
|
|
3243
|
+
} else if (isProject && typeof tf !== 'undefined' && tf && tf.hirePendingState === 'timeout') {
|
|
3176
3244
|
const timeoutRow = document.createElement('div');
|
|
3177
3245
|
timeoutRow.className = 'hire-timeout-row';
|
|
3178
3246
|
timeoutRow.id = 'hire-timeout-row';
|
|
3179
3247
|
timeoutRow.innerHTML = '<span class="hire-timeout-icon">⚠</span><span class="hire-timeout-text">Taking longer than expected. </span><a href="javascript:window.location.reload()">Reload to check</a>';
|
|
3180
|
-
|
|
3248
|
+
host.appendChild(timeoutRow);
|
|
3181
3249
|
}
|
|
3182
3250
|
|
|
3183
3251
|
// #851 Option A: tree always has content (at minimum In Progress + employee tree).
|
|
3184
|
-
const runsSection =
|
|
3252
|
+
const runsSection = host.closest('.rail-section--runs');
|
|
3185
3253
|
if (runsSection) runsSection.hidden = false;
|
|
3186
3254
|
// #824: re-attach an open run-delete confirm that this rebuild would otherwise drop.
|
|
3187
|
-
tfReopenPendingRunDelete(
|
|
3255
|
+
tfReopenPendingRunDelete(host);
|
|
3188
3256
|
}
|
|
3189
3257
|
|
|
3190
3258
|
function statusLabel(s) {
|
|
@@ -3723,9 +3791,9 @@ function showHireStrip(job, conversationId, instructions, employeeId) {
|
|
|
3723
3791
|
state._hireStripPending = { job, conversationId, instructions, employeeId };
|
|
3724
3792
|
els['hire-strip'].hidden = false;
|
|
3725
3793
|
// Switch workspace to conv mode so the hire strip (inside .page) is visible
|
|
3726
|
-
// even when
|
|
3794
|
+
// even when the manager was reading Project Info.
|
|
3727
3795
|
const wc = document.querySelector('#proj-workspace .workspace-conv');
|
|
3728
|
-
if (wc)
|
|
3796
|
+
if (wc) tfToggleAreaView('projects', 'delegate');
|
|
3729
3797
|
}
|
|
3730
3798
|
|
|
3731
3799
|
function hideHireStrip() {
|
|
@@ -3856,37 +3924,13 @@ function syncMicroArchive(conv) {
|
|
|
3856
3924
|
els['micro-log'].onscroll = captureManagePosition;
|
|
3857
3925
|
microArchiveTimer = setInterval(() => { if (microArchiveIsVisible(conv)) void loadMicroArchive(conv); }, 2000);
|
|
3858
3926
|
}
|
|
3859
|
-
const conversationFocusState = {
|
|
3860
|
-
open: false,
|
|
3861
|
-
previousFocus: null,
|
|
3862
|
-
threadParent: null,
|
|
3863
|
-
threadNext: null,
|
|
3864
|
-
// Issue #1657 Treatment 3: the composer is permanently docked; "expand
|
|
3865
|
-
// conversation" moves it into the overlay alongside the thread so the
|
|
3866
|
-
// manager can still type while expanded.
|
|
3867
|
-
composerParent: null,
|
|
3868
|
-
composerNext: null,
|
|
3869
|
-
messageScrollTop: 0,
|
|
3870
|
-
};
|
|
3871
3927
|
// #521 UI rework: the project workspace shows EITHER a job conversation (full
|
|
3872
3928
|
// height) OR the Brief & Learnings page — never both stacked, so the thread,
|
|
3873
3929
|
// coach, and micro-manage all have room. Toggle the mode class the CSS keys off,
|
|
3874
3930
|
// and reflect the state in the sidebar's Brief nav entry.
|
|
3875
3931
|
function tfApplyWorkspaceMode() {
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
const hasConv = typeof activeConversation === 'function' && !!activeConversation();
|
|
3879
|
-
// Issue #1657 Treatment 3: "no active conversation" no longer implies "show
|
|
3880
|
-
// the Brief page" by itself - the persistent composer's idle state (#empty)
|
|
3881
|
-
// must stay visible then too (R1: present at all times). state.showProjectInfo
|
|
3882
|
-
// is the explicit signal only the "Project Info" nav entry sets; any real
|
|
3883
|
-
// active conversation still wins over it, so the many state.activeId call
|
|
3884
|
-
// sites elsewhere don't each need to clear this flag themselves.
|
|
3885
|
-
const showBrief = !!state.showProjectInfo && !hasConv;
|
|
3886
|
-
wc.classList.toggle('mode-conv', !showBrief);
|
|
3887
|
-
wc.classList.toggle('mode-brief', showBrief);
|
|
3888
|
-
const briefNav = document.getElementById('tree-brief-nav');
|
|
3889
|
-
if (briefNav) briefNav.classList.toggle('active', showBrief);
|
|
3932
|
+
if (typeof tf === 'undefined') return;
|
|
3933
|
+
tfApplyAreaView('projects', tfResolveAreaView('projects', activeConversation()));
|
|
3890
3934
|
}
|
|
3891
3935
|
|
|
3892
3936
|
function formatMicroAuditKind(event) {
|
|
@@ -3908,110 +3952,12 @@ function formatMicroLogLine(event, mode = 'events') {
|
|
|
3908
3952
|
return `[${channel}] ${event && event.text != null ? String(event.text) : ''}\n`;
|
|
3909
3953
|
}
|
|
3910
3954
|
|
|
3911
|
-
function syncConversationFocusTitle(conv) {
|
|
3912
|
-
if (!els['conversation-focus-title']) return;
|
|
3913
|
-
els['conversation-focus-title'].textContent = conv ? conversationTitle(conv) : 'Focused conversation';
|
|
3914
|
-
if (els['conversation-focus-subtitle']) {
|
|
3915
|
-
els['conversation-focus-subtitle'].textContent = 'Conversation thread and coaching';
|
|
3916
|
-
}
|
|
3917
|
-
}
|
|
3918
|
-
|
|
3919
|
-
function restoreFocusedConversationNodes() {
|
|
3920
|
-
const thread = els['thread-panel'].closest('.manage-panel-shell') || els['thread-panel'];
|
|
3921
|
-
const composer = document.getElementById('persistent-composer');
|
|
3922
|
-
if (thread && conversationFocusState.threadParent) {
|
|
3923
|
-
const next = conversationFocusState.threadNext;
|
|
3924
|
-
if (next && next.parentNode === conversationFocusState.threadParent) {
|
|
3925
|
-
conversationFocusState.threadParent.insertBefore(thread, next);
|
|
3926
|
-
} else {
|
|
3927
|
-
conversationFocusState.threadParent.appendChild(thread);
|
|
3928
|
-
}
|
|
3929
|
-
}
|
|
3930
|
-
if (composer && conversationFocusState.composerParent) {
|
|
3931
|
-
const next = conversationFocusState.composerNext;
|
|
3932
|
-
if (next && next.parentNode === conversationFocusState.composerParent) {
|
|
3933
|
-
conversationFocusState.composerParent.insertBefore(composer, next);
|
|
3934
|
-
} else {
|
|
3935
|
-
conversationFocusState.composerParent.appendChild(composer);
|
|
3936
|
-
}
|
|
3937
|
-
}
|
|
3938
|
-
}
|
|
3939
|
-
|
|
3940
|
-
function closeConversationFocus() {
|
|
3941
|
-
if (!conversationFocusState.open) return;
|
|
3942
|
-
captureManagePosition();
|
|
3943
|
-
if (els['messages']) conversationFocusState.messageScrollTop = els['messages'].scrollTop;
|
|
3944
|
-
restoreFocusedConversationNodes();
|
|
3945
|
-
conversationFocusState.open = false;
|
|
3946
|
-
document.body.classList.remove('conversation-focus-open');
|
|
3947
|
-
if (els['conversation-focus-overlay']) {
|
|
3948
|
-
els['conversation-focus-overlay'].hidden = true;
|
|
3949
|
-
els['conversation-focus-overlay'].classList.remove('open');
|
|
3950
|
-
}
|
|
3951
|
-
requestAnimationFrame(() => {
|
|
3952
|
-
if (els['messages']) els['messages'].scrollTop = conversationFocusState.messageScrollTop || 0;
|
|
3953
|
-
if (typeof syncThreadMessageViewport === 'function') syncThreadMessageViewport();
|
|
3954
|
-
const conv = activeConversation();
|
|
3955
|
-
if (conv) restoreManagePosition(conv);
|
|
3956
|
-
const target = conversationFocusState.previousFocus && document.contains(conversationFocusState.previousFocus)
|
|
3957
|
-
? conversationFocusState.previousFocus
|
|
3958
|
-
: els['conversation-focus-btn'];
|
|
3959
|
-
if (target && typeof target.focus === 'function') target.focus();
|
|
3960
|
-
});
|
|
3961
|
-
}
|
|
3962
|
-
|
|
3963
|
-
function openConversationFocus() {
|
|
3964
|
-
const conv = activeConversation();
|
|
3965
|
-
captureManagePosition();
|
|
3966
|
-
const overlay = els['conversation-focus-overlay'];
|
|
3967
|
-
const panels = els['conversation-focus-panels'];
|
|
3968
|
-
const thread = els['thread-panel'].closest('.manage-panel-shell') || els['thread-panel'];
|
|
3969
|
-
const composer = document.getElementById('persistent-composer');
|
|
3970
|
-
if (!conv || !overlay || !panels || !thread) return;
|
|
3971
|
-
if (conversationFocusState.open) return;
|
|
3972
|
-
|
|
3973
|
-
conversationFocusState.open = true;
|
|
3974
|
-
conversationFocusState.previousFocus = document.activeElement;
|
|
3975
|
-
conversationFocusState.threadParent = thread.parentNode;
|
|
3976
|
-
conversationFocusState.threadNext = thread.nextSibling;
|
|
3977
|
-
if (composer) {
|
|
3978
|
-
conversationFocusState.composerParent = composer.parentNode;
|
|
3979
|
-
conversationFocusState.composerNext = composer.nextSibling;
|
|
3980
|
-
}
|
|
3981
|
-
conversationFocusState.messageScrollTop = els['messages'] ? els['messages'].scrollTop : 0;
|
|
3982
|
-
|
|
3983
|
-
syncConversationFocusTitle(conv);
|
|
3984
|
-
panels.appendChild(thread);
|
|
3985
|
-
if (composer) panels.appendChild(composer);
|
|
3986
|
-
overlay.hidden = false;
|
|
3987
|
-
overlay.classList.add('open');
|
|
3988
|
-
document.body.classList.add('conversation-focus-open');
|
|
3989
|
-
|
|
3990
|
-
requestAnimationFrame(() => {
|
|
3991
|
-
if (els['messages']) els['messages'].scrollTop = conversationFocusState.messageScrollTop || 0;
|
|
3992
|
-
if (typeof syncThreadMessageViewport === 'function') syncThreadMessageViewport();
|
|
3993
|
-
restoreManagePosition(conv);
|
|
3994
|
-
if (els['conversation-focus-close']) els['conversation-focus-close'].focus();
|
|
3995
|
-
});
|
|
3996
|
-
}
|
|
3997
|
-
|
|
3998
|
-
function handleConversationFocusEscape(e) {
|
|
3999
|
-
if (e.key !== 'Escape' || !conversationFocusState.open || e.defaultPrevented) return;
|
|
4000
|
-
const openCoachMenu = COACH_DROPDOWN_PAIRS
|
|
4001
|
-
.some(([id]) => { const menu = els[id] || document.getElementById(id); return menu && !menu.hidden; });
|
|
4002
|
-
const reviewMenu = document.getElementById('review-approve-menu');
|
|
4003
|
-
if (openCoachMenu || (reviewMenu && !reviewMenu.hidden)) return;
|
|
4004
|
-
e.preventDefault();
|
|
4005
|
-
closeConversationFocus();
|
|
4006
|
-
}
|
|
4007
|
-
|
|
4008
3955
|
function renderActive() {
|
|
4009
3956
|
const conv = activeConversation();
|
|
4010
3957
|
if (typeof syncTaskPaneSurfaceMode === 'function') syncTaskPaneSurfaceMode();
|
|
4011
3958
|
if (typeof tfApplyWorkspaceMode === 'function') tfApplyWorkspaceMode();
|
|
4012
3959
|
if (!conv) {
|
|
4013
3960
|
syncMicroArchive(null);
|
|
4014
|
-
closeConversationFocus();
|
|
4015
3961
|
els['empty'].hidden = false;
|
|
4016
3962
|
els['active-conv'].hidden = true;
|
|
4017
3963
|
renderHubAgentSetupPanel();
|
|
@@ -4024,7 +3970,6 @@ function renderActive() {
|
|
|
4024
3970
|
syncComposerPlaceholder(null);
|
|
4025
3971
|
syncComposerStopButton(null);
|
|
4026
3972
|
// #770: hide the Deliverables panel when there is no active conversation.
|
|
4027
|
-
if (els['deliverables-panel']) { els['deliverables-panel'].hidden = true; els['deliverables-body'].innerHTML = ''; }
|
|
4028
3973
|
syncThreadUiState(null);
|
|
4029
3974
|
renderedConvId = null;
|
|
4030
3975
|
renderedMessageCount = 0;
|
|
@@ -4050,7 +3995,6 @@ function renderActive() {
|
|
|
4050
3995
|
syncConvictionDropdownLabel();
|
|
4051
3996
|
syncCoachNote(conv);
|
|
4052
3997
|
syncComposerPlaceholder(conv);
|
|
4053
|
-
if (conversationFocusState.open) syncConversationFocusTitle(conv);
|
|
4054
3998
|
|
|
4055
3999
|
// If we switched conversations (or this is the first render), wipe and
|
|
4056
4000
|
// start fresh. Otherwise we're going to do an incremental update below.
|
|
@@ -4094,7 +4038,6 @@ function renderActive() {
|
|
|
4094
4038
|
}
|
|
4095
4039
|
const statusChanged = renderedStatus !== conv.status;
|
|
4096
4040
|
syncThreadUiState(conv);
|
|
4097
|
-
syncConversationPanels(conv, switchedConv);
|
|
4098
4041
|
syncQuickCoachButtons(conv);
|
|
4099
4042
|
// Issue #1723: renderActive() runs on every poll cycle for the active
|
|
4100
4043
|
// conversation but previously never re-synced the Stop button here, so a
|
|
@@ -4245,13 +4188,14 @@ function renderActive() {
|
|
|
4245
4188
|
renderReviewExperience(conv);
|
|
4246
4189
|
// Issue #848 — "What's next?" chips below a completed conversation (post-retro).
|
|
4247
4190
|
renderNextJobRecommendations(conv);
|
|
4191
|
+
renderDeliverablesBar(conv);
|
|
4248
4192
|
syncManagedDelegationAccess(conv);
|
|
4249
4193
|
syncThreadMessageViewport();
|
|
4250
4194
|
// Issue #1065: the thread is rebuilt from state on a poll cadence, which would wipe the find
|
|
4251
4195
|
// marks a manager is stepping through. Re-emit them from find state instead of injecting once.
|
|
4252
4196
|
if (typeof convFindRefresh === 'function') convFindRefresh();
|
|
4253
4197
|
const shouldScrollForUpdate = switchedConv || statusChanged || appendedMessages > 0 || messagesMutated;
|
|
4254
|
-
const reviewFollow = (switchedConv ||
|
|
4198
|
+
const reviewFollow = (switchedConv || threadWasNearBottom) && (!switchedConv || manageViewFor(conv).messages.follow);
|
|
4255
4199
|
scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, hasNewPendingDeliveryMessage, reviewFollow, clearedPendingDeliveryMessages);
|
|
4256
4200
|
requestAnimationFrame(() => {
|
|
4257
4201
|
syncThreadMessageViewport();
|
|
@@ -4273,7 +4217,7 @@ function renderActive() {
|
|
|
4273
4217
|
renderTracker(conv);
|
|
4274
4218
|
renderTotals(conv);
|
|
4275
4219
|
renderManagerOversightBoard(conv);
|
|
4276
|
-
|
|
4220
|
+
|
|
4277
4221
|
renderDelegationLedger(conv);
|
|
4278
4222
|
syncConversationRefreshPolling();
|
|
4279
4223
|
syncTemplatePickerVisibility();
|
|
@@ -4422,6 +4366,25 @@ function renderRunStatePill(conv) {
|
|
|
4422
4366
|
// conversationStateLabel returns 'Stopped' for that state; toUpperCase() => 'STOPPED'.
|
|
4423
4367
|
pill.textContent = conversationStateLabel(conv).toUpperCase();
|
|
4424
4368
|
pill.className = `run-state-pill ${conversationUiState(conv)}`;
|
|
4369
|
+
const pending = convAwaitingReview(conv);
|
|
4370
|
+
pill.tabIndex = pending ? 0 : -1;
|
|
4371
|
+
if (pending) {
|
|
4372
|
+
pill.setAttribute('role', 'button');
|
|
4373
|
+
pill.setAttribute('aria-label', 'Waiting on you. Go to the pending decision.');
|
|
4374
|
+
} else {
|
|
4375
|
+
pill.removeAttribute('role');
|
|
4376
|
+
pill.removeAttribute('aria-label');
|
|
4377
|
+
}
|
|
4378
|
+
const jump = () => {
|
|
4379
|
+
const current = activeConversation();
|
|
4380
|
+
if (!convAwaitingReview(current)) return;
|
|
4381
|
+
setManageMicroMode(false);
|
|
4382
|
+
els['thread-panel'].scrollIntoView({ block: 'nearest' });
|
|
4383
|
+
scrollThreadForReview(current);
|
|
4384
|
+
els['review-approve'].focus({ preventScroll: true });
|
|
4385
|
+
};
|
|
4386
|
+
pill.onclick = pending ? jump : null;
|
|
4387
|
+
pill.onkeydown = pending ? (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); jump(); } } : null;
|
|
4425
4388
|
syncComposerStopButton(conv);
|
|
4426
4389
|
}
|
|
4427
4390
|
|
|
@@ -5811,7 +5774,7 @@ function ensureThreadMessageViewportObserver() {
|
|
|
5811
5774
|
function syncThreadMessageViewport() {
|
|
5812
5775
|
const messages = els['messages'];
|
|
5813
5776
|
if (!messages) return;
|
|
5814
|
-
// Flex layout now owns the thread height: #thread-panel
|
|
5777
|
+
// Flex layout now owns the thread height: #thread-panel grows to fill the
|
|
5815
5778
|
// available real estate and #messages (flex child, overflow-y:auto) scrolls
|
|
5816
5779
|
// internally. Clear any legacy inline sizing so CSS stays authoritative.
|
|
5817
5780
|
messages.style.height = '';
|
|
@@ -5899,8 +5862,8 @@ function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, forceBottom,
|
|
|
5899
5862
|
if (!latest || latest.id !== conv.id) return;
|
|
5900
5863
|
const host = els['messages'];
|
|
5901
5864
|
if (!host || host.hidden) return;
|
|
5865
|
+
ensurePendingAnchorScrollListener(host);
|
|
5902
5866
|
if (latest.status === 'running') {
|
|
5903
|
-
ensurePendingAnchorScrollListener(host);
|
|
5904
5867
|
if (!forceBottom && pendingAnchorUserScrolled(host)) {
|
|
5905
5868
|
return;
|
|
5906
5869
|
}
|
|
@@ -5970,7 +5933,7 @@ function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, forceBottom,
|
|
|
5970
5933
|
}
|
|
5971
5934
|
}
|
|
5972
5935
|
return;
|
|
5973
|
-
} else if (shouldScrollForUpdate && reviewFollow) {
|
|
5936
|
+
} else if (shouldScrollForUpdate && reviewFollow && !pendingAnchorUserScrolled(host)) {
|
|
5974
5937
|
// Issue #1665: jump to the review target only on a real transition (switch or
|
|
5975
5938
|
// status change) or when the manager was already following the latest message.
|
|
5976
5939
|
// A live update landing while they read history must not yank the view.
|
|
@@ -5997,13 +5960,16 @@ function scrollThreadForReview(conv) {
|
|
|
5997
5960
|
const targetRect = target.getBoundingClientRect();
|
|
5998
5961
|
const currentTop = host.scrollTop;
|
|
5999
5962
|
const targetTopInsideHost = currentTop + (targetRect.top - hostRect.top);
|
|
6000
|
-
const
|
|
5963
|
+
const bar = host.querySelector('#deliverables-bar:not([hidden])');
|
|
5964
|
+
const reservedHeight = bar ? bar.getBoundingClientRect().height : 0;
|
|
5965
|
+
const availableLead = Math.max(0, host.clientHeight - reservedHeight - targetRect.height - 24);
|
|
5966
|
+
const reviewOffset = Math.min(Math.max(24, host.clientHeight * 0.16), availableLead);
|
|
6001
5967
|
const desiredTop = Math.max(0, targetTopInsideHost - reviewOffset);
|
|
6002
5968
|
// #936: use instant scroll so the animation completes synchronously. A smooth
|
|
6003
5969
|
// scroll takes ~300-500ms; if the user sets scrollTop before the animation
|
|
6004
5970
|
// finishes the CSS scroll-behavior causes the browser to re-animate, making
|
|
6005
5971
|
// the user's intent hard to honor. Instant + one-time gate = clean hand-off.
|
|
6006
|
-
host.
|
|
5972
|
+
host.scrollTo({ top: desiredTop, behavior: 'instant' });
|
|
6007
5973
|
}
|
|
6008
5974
|
|
|
6009
5975
|
// ---------------------------------------------------------------------------
|
|
@@ -6268,6 +6234,8 @@ function resizeComposerTextarea() {
|
|
|
6268
6234
|
}
|
|
6269
6235
|
|
|
6270
6236
|
function syncSendButton() {
|
|
6237
|
+
syncVoiceInput();
|
|
6238
|
+
syncReviewHint();
|
|
6271
6239
|
resizeComposerTextarea();
|
|
6272
6240
|
const conv = activeConversation();
|
|
6273
6241
|
const hasText = els['coach-text'].value.trim().length > 0;
|
|
@@ -6379,6 +6347,7 @@ function syncManagedDelegationAccess(conv = activeConversation()) {
|
|
|
6379
6347
|
}
|
|
6380
6348
|
}
|
|
6381
6349
|
if (managed) closeAllCoachDropdowns();
|
|
6350
|
+
syncVoiceInput();
|
|
6382
6351
|
syncComposerPlaceholder(conv);
|
|
6383
6352
|
syncComposerStopButton(conv);
|
|
6384
6353
|
}
|
|
@@ -6981,37 +6950,95 @@ function deriveDeliverableFormat(conv) {
|
|
|
6981
6950
|
};
|
|
6982
6951
|
}
|
|
6983
6952
|
|
|
6953
|
+
// Review history belongs to the conversation record. It never grants approval:
|
|
6954
|
+
// the live run state remains the only source of action eligibility.
|
|
6955
|
+
function syncReviewTurns(conv) {
|
|
6956
|
+
if (!conv) return [];
|
|
6957
|
+
const turns = Array.isArray(conv.reviewTurns) ? conv.reviewTurns : [];
|
|
6958
|
+
if (!conversationHasBody(conv)) return turns;
|
|
6959
|
+
const awaiting = convAwaitingReview(conv);
|
|
6960
|
+
const signature = JSON.stringify(reviewHandoffForConversation(conv));
|
|
6961
|
+
let pending = turns.find((turn) => !turn.resolution);
|
|
6962
|
+
let changed = false;
|
|
6963
|
+
if (pending && (!awaiting || pending.signature !== signature)) {
|
|
6964
|
+
pending.resolution = conv.pauseReason === 'done' ? 'You marked this complete.'
|
|
6965
|
+
: conv.reviewApproved ? 'You approved this.'
|
|
6966
|
+
: awaiting ? 'A newer review replaced this decision.' : 'The run continued without a decision.';
|
|
6967
|
+
pending.resolvedAt = Date.now();
|
|
6968
|
+
pending = null;
|
|
6969
|
+
changed = true;
|
|
6970
|
+
}
|
|
6971
|
+
if (awaiting && !pending) {
|
|
6972
|
+
const last = (conv.messages || []).at(-1);
|
|
6973
|
+
turns.push({ id: String(Date.now()) + '-' + turns.length, signature,
|
|
6974
|
+
messageCount: (conv.messages || []).length, at: last ? messageTimestamp(last) : Date.now() });
|
|
6975
|
+
changed = true;
|
|
6976
|
+
}
|
|
6977
|
+
if (changed) {
|
|
6978
|
+
conv.reviewTurns = turns;
|
|
6979
|
+
persistConversation(conv);
|
|
6980
|
+
}
|
|
6981
|
+
return turns;
|
|
6982
|
+
}
|
|
6983
|
+
|
|
6984
|
+
function resolveReviewTurn(conv, resolution) {
|
|
6985
|
+
const turn = (conv.reviewTurns || syncReviewTurns(conv)).find((entry) => !entry.resolution);
|
|
6986
|
+
if (!turn) return;
|
|
6987
|
+
turn.resolution = resolution;
|
|
6988
|
+
turn.resolvedAt = Date.now();
|
|
6989
|
+
}
|
|
6990
|
+
|
|
6991
|
+
function syncReviewHint(conv = activeConversation()) {
|
|
6992
|
+
const hint = els['review-handoff'];
|
|
6993
|
+
if (!hint || !convAwaitingReview(conv)) return;
|
|
6994
|
+
hint.textContent = els['coach-text'].value.trim()
|
|
6995
|
+
? 'Request changes will send what you typed. Send replies without deciding.'
|
|
6996
|
+
: 'Review the update and deliverables, then approve or type changes below. Send replies without deciding.';
|
|
6997
|
+
}
|
|
6998
|
+
|
|
6984
6999
|
function renderReviewExperience(conv) {
|
|
6985
7000
|
const actions = els['review-actions'];
|
|
6986
|
-
|
|
6987
|
-
|
|
7001
|
+
const host = els['messages'];
|
|
7002
|
+
if (!actions || !host) return;
|
|
7003
|
+
const turns = syncReviewTurns(conv);
|
|
6988
7004
|
const awaiting = convAwaitingReview(conv);
|
|
7005
|
+
const rows = Array.from(host.children).filter((node) => node.classList.contains('message') && !node.classList.contains('review-turn'));
|
|
7006
|
+
for (const turn of turns) {
|
|
7007
|
+
let row = Array.from(host.querySelectorAll('.review-turn')).find((node) => node.dataset.reviewTurn === turn.id);
|
|
7008
|
+
if (!row) {
|
|
7009
|
+
row = document.createElement('div');
|
|
7010
|
+
row.className = 'message employee review-turn';
|
|
7011
|
+
row.dataset.reviewTurn = turn.id;
|
|
7012
|
+
const bubble = document.createElement('div');
|
|
7013
|
+
bubble.className = 'bubble';
|
|
7014
|
+
const label = document.createElement('div');
|
|
7015
|
+
label.className = 'review-turn-label';
|
|
7016
|
+
label.textContent = 'Ready for your review';
|
|
7017
|
+
bubble.appendChild(label);
|
|
7018
|
+
row.appendChild(bubble);
|
|
7019
|
+
host.insertBefore(row, rows[turn.messageCount] || host.querySelector('#deliverables-bar, #next-job-section'));
|
|
7020
|
+
}
|
|
7021
|
+
const bubble = row.querySelector('.bubble');
|
|
7022
|
+
if (!turn.resolution && awaiting) {
|
|
7023
|
+
if (actions.parentNode !== bubble) bubble.appendChild(actions);
|
|
7024
|
+
actions.hidden = false;
|
|
7025
|
+
renderDynamicReviewActions(conv, reviewHandoffForConversation(conv));
|
|
7026
|
+
syncReviewHint(conv);
|
|
7027
|
+
} else {
|
|
7028
|
+
let settled = bubble.querySelector('.review-resolution');
|
|
7029
|
+
if (!settled) {
|
|
7030
|
+
settled = document.createElement('div');
|
|
7031
|
+
settled.className = 'review-resolution';
|
|
7032
|
+
bubble.appendChild(settled);
|
|
7033
|
+
}
|
|
7034
|
+
const at = new Date(turn.resolvedAt);
|
|
7035
|
+
settled.textContent = turn.resolution + (Number.isFinite(at.getTime()) ? ' ' + at.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '');
|
|
7036
|
+
}
|
|
7037
|
+
}
|
|
6989
7038
|
if (!awaiting) {
|
|
6990
7039
|
clearDynamicReviewActionButtons(actions);
|
|
6991
7040
|
actions.hidden = true;
|
|
6992
|
-
|
|
6993
|
-
}
|
|
6994
|
-
|
|
6995
|
-
const fmt = deriveDeliverableFormat(conv);
|
|
6996
|
-
|
|
6997
|
-
// --- Format-derived artifact strip (R7.2) ---
|
|
6998
|
-
// #770 A1: real deliverables (artifact_set / pull_request / deck / report /
|
|
6999
|
-
// markdown) render in the Deliverables panel (renderDeliverablesPanel), not
|
|
7000
|
-
// pinned in the thread.
|
|
7001
|
-
|
|
7002
|
-
// --- Unified action bar: review actions appear beside the always-on chips ---
|
|
7003
|
-
actions.hidden = false;
|
|
7004
|
-
renderDynamicReviewActions(conv, reviewHandoffForConversation(conv));
|
|
7005
|
-
const handoff = els['review-handoff'];
|
|
7006
|
-
if (handoff) {
|
|
7007
|
-
const hasArtifact = !!(fmt.actions && fmt.actions.length);
|
|
7008
|
-
handoff.textContent = fmt.key === 'pull_request'
|
|
7009
|
-
? 'Comment on the PR to leave inline notes, or approve / type changes below.'
|
|
7010
|
-
: isManagerTemplateJob(conv && conv.jobId)
|
|
7011
|
-
? 'Open the updated files, then approve or type changes below.'
|
|
7012
|
-
: hasArtifact
|
|
7013
|
-
? 'Open the deliverable above, then approve or type changes below.'
|
|
7014
|
-
: 'Review the update above, then approve or type changes below.';
|
|
7041
|
+
document.getElementById('review-action-storage').appendChild(actions);
|
|
7015
7042
|
}
|
|
7016
7043
|
}
|
|
7017
7044
|
|
|
@@ -7232,34 +7259,46 @@ function approvalCommandFromLabel(action) {
|
|
|
7232
7259
|
return label ? `Approved and ${label}.` : APPROVAL_MESSAGE;
|
|
7233
7260
|
}
|
|
7234
7261
|
|
|
7235
|
-
//
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
|
|
7240
|
-
|
|
7241
|
-
|
|
7242
|
-
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
const
|
|
7262
|
+
// Run outputs stay reachable independently of the current review decision.
|
|
7263
|
+
function renderDeliverablesBar(conv) {
|
|
7264
|
+
const messages = els['messages'];
|
|
7265
|
+
if (!messages) return;
|
|
7266
|
+
let panel = messages.querySelector('#deliverables-bar');
|
|
7267
|
+
if (!panel) {
|
|
7268
|
+
panel = document.createElement('div');
|
|
7269
|
+
panel.id = 'deliverables-bar';
|
|
7270
|
+
panel.setAttribute('aria-label', 'Deliverables');
|
|
7271
|
+
}
|
|
7272
|
+
const body = panel;
|
|
7273
|
+
let fmt = conv ? deriveDeliverableFormat(conv) : null;
|
|
7274
|
+
// A later run snapshot may clear its handoff; produced outputs remain available.
|
|
7275
|
+
// Historical data supplies artifact links only, never decision eligibility.
|
|
7276
|
+
if (conv && (!reviewHandoffForConversation(conv) || reviewHandoffForConversation(conv).reviewRequired === false) && (!fmt || !fmt.actions.length)) {
|
|
7277
|
+
const previous = (conv.reviewTurns || []).at(-1);
|
|
7278
|
+
if (previous) {
|
|
7279
|
+
try { fmt = deriveFormatFromReviewHandoff(normalizeReviewHandoff(JSON.parse(previous.signature))); }
|
|
7280
|
+
catch { /* Ignore malformed history from older/imported records. */ }
|
|
7281
|
+
}
|
|
7282
|
+
}
|
|
7246
7283
|
const isDeliverableFmt = !!fmt && (fmt.key === 'artifact_set' || fmt.key === 'pull_request'
|
|
7247
7284
|
|| fmt.key === 'deck' || fmt.key === 'report'
|
|
7248
7285
|
|| (fmt.key === 'markdown' && fmt.actions && fmt.actions.length));
|
|
7249
7286
|
const actions = isDeliverableFmt ? fmt.actions.filter((a) => a.kind !== 'feedback') : [];
|
|
7250
|
-
// #770 / #512 R7.2: a handoff that EXPLICITLY declines review (reviewRequired:false,
|
|
7251
|
-
// e.g. org-onboarding wrote in-place context files reviewed via the Company/Manager
|
|
7252
|
-
// Brief tab) is not a deliverable to surface here. This preserves the prior "no
|
|
7253
|
-
// artifact strip for a no-review handoff" behavior now that the strip is decoupled
|
|
7254
|
-
// from the (hidden) review card.
|
|
7255
7287
|
const currentHandoff = conv ? reviewHandoffForConversation(conv) : null;
|
|
7256
|
-
const declinedReview = !!currentHandoff && currentHandoff.reviewRequired === false;
|
|
7288
|
+
const declinedReview = !!currentHandoff && currentHandoff.reviewRequired === false && !(conv.reviewTurns || []).length;
|
|
7257
7289
|
if (!conv || declinedReview || actions.length === 0) {
|
|
7258
7290
|
panel.hidden = true;
|
|
7291
|
+
delete panel.dataset.signature;
|
|
7259
7292
|
body.innerHTML = '';
|
|
7260
7293
|
return;
|
|
7261
7294
|
}
|
|
7262
7295
|
panel.hidden = false;
|
|
7296
|
+
const signature = JSON.stringify(actions);
|
|
7297
|
+
if (panel.dataset.signature === signature) {
|
|
7298
|
+
if (messages.lastElementChild !== panel) messages.appendChild(panel);
|
|
7299
|
+
return;
|
|
7300
|
+
}
|
|
7301
|
+
panel.dataset.signature = signature;
|
|
7263
7302
|
body.innerHTML = '';
|
|
7264
7303
|
const strip = document.createElement('div');
|
|
7265
7304
|
strip.className = 'rc-artifact-strip';
|
|
@@ -7292,13 +7331,7 @@ function renderDeliverablesPanel(conv) {
|
|
|
7292
7331
|
strip.appendChild(btn);
|
|
7293
7332
|
}
|
|
7294
7333
|
body.appendChild(strip);
|
|
7295
|
-
|
|
7296
|
-
if (summary) {
|
|
7297
|
-
summary.textContent = actions.length === 1 ? '1 file from this run' : `${actions.length} files from this run`;
|
|
7298
|
-
}
|
|
7299
|
-
const panelState = panelStateFor(conv.id);
|
|
7300
|
-
const shouldOpen = panelState.deliverables !== undefined ? panelState.deliverables : convAwaitingReview(conv);
|
|
7301
|
-
if (panel.open !== shouldOpen) panel.open = shouldOpen;
|
|
7334
|
+
messages.appendChild(panel);
|
|
7302
7335
|
}
|
|
7303
7336
|
|
|
7304
7337
|
function jobCatalogEntry(jobId) {
|
|
@@ -7829,6 +7862,7 @@ async function approveReview(options) {
|
|
|
7829
7862
|
const selectedReviewAction = opts.reviewAction && typeof opts.reviewAction === 'object'
|
|
7830
7863
|
? opts.reviewAction
|
|
7831
7864
|
: { id: 'approve', label: 'Approve', kind: 'approve', style: 'primary' };
|
|
7865
|
+
resolveReviewTurn(conv, 'You approved this.');
|
|
7832
7866
|
conv.reviewApproved = true;
|
|
7833
7867
|
conv.selectedReviewAction = selectedReviewAction;
|
|
7834
7868
|
conv.pendingDeliveryActionId = selectedReviewAction.deliveryActionId || null;
|
|
@@ -7845,6 +7879,7 @@ async function approveReview(options) {
|
|
|
7845
7879
|
await continueRun(approvalMessage, { preserveReviewApproved: true, selectedReviewAction });
|
|
7846
7880
|
return;
|
|
7847
7881
|
}
|
|
7882
|
+
resolveReviewTurn(conv, 'You marked this complete.');
|
|
7848
7883
|
conv.status = 'completed';
|
|
7849
7884
|
conv.reviewApproved = false;
|
|
7850
7885
|
conv.pendingDeliveryActionId = null;
|
|
@@ -7864,6 +7899,8 @@ async function approveReview(options) {
|
|
|
7864
7899
|
async function requestChangesReview() {
|
|
7865
7900
|
const conv = activeConversation();
|
|
7866
7901
|
if (!conv) return;
|
|
7902
|
+
if (!convAwaitingReview(conv)) return;
|
|
7903
|
+
resolveReviewTurn(conv, 'You asked for changes.');
|
|
7867
7904
|
const typedText = (els['coach-text'] && els['coach-text'].value.trim()) || '';
|
|
7868
7905
|
const text = typedText || DEFAULT_REQUEST_CHANGES_MESSAGE;
|
|
7869
7906
|
// Re-open the review gate: a change request means the run is no longer "done".
|
|
@@ -7999,8 +8036,8 @@ function parseCpSearch(text) {
|
|
|
7999
8036
|
function focusComposerForDelegate(opts) {
|
|
8000
8037
|
state.cpActionContext = (opts && opts.actionContext) || managementActionContext(state.pendingNextJobArea || undefined);
|
|
8001
8038
|
state.activeId = null;
|
|
8002
|
-
persistActiveConversationSelection(null, { projectPath: state.projectPath || '' });
|
|
8003
|
-
|
|
8039
|
+
persistActiveConversationSelection(null, tf.area === 'projects' ? { projectPath: state.projectPath || '' } : { scope: tf.area });
|
|
8040
|
+
tf.areaSelection[tfAreaSelectionKey(tf.area)] = 'delegate';
|
|
8004
8041
|
if (tf.area === 'company' || tf.area === 'manager') {
|
|
8005
8042
|
tf.areaSelection[tf.area] = 'delegate';
|
|
8006
8043
|
if (tf.area === 'company') tfRenderCompany();
|
|
@@ -8553,7 +8590,7 @@ function showRerunToast(msg) {
|
|
|
8553
8590
|
const t = document.createElement('div');
|
|
8554
8591
|
t.className = 'cp-rerun-toast';
|
|
8555
8592
|
// Issue #1627: matches this app's own convention for transient status text (#be-status,
|
|
8556
|
-
//
|
|
8593
|
+
// status notifications, etc.) so a screen reader announces the message instead of it being silently
|
|
8557
8594
|
// visual-only - relevant beyond the original re-run case now that this toast also carries the
|
|
8558
8595
|
// real reason a restart-to-latest attempt failed.
|
|
8559
8596
|
t.setAttribute('role', 'status');
|
|
@@ -8960,7 +8997,7 @@ async function startRun(job, instructions, employeeId, preassignedConvId, invoke
|
|
|
8960
8997
|
};
|
|
8961
8998
|
upsertConversation(conv);
|
|
8962
8999
|
state.activeId = conv.id;
|
|
8963
|
-
|
|
9000
|
+
tf.areaSelection[tfAreaSelectionKey(context.area)] = conv.id;
|
|
8964
9001
|
persistActiveConversationSelection(conv.id);
|
|
8965
9002
|
// Issue #539: make Cmd/Ctrl+Shift+R available as soon as the run is queued,
|
|
8966
9003
|
// not only after the server responds with the run id.
|
|
@@ -9087,6 +9124,7 @@ function conversationDispatchAgentId(conv) {
|
|
|
9087
9124
|
async function continueRun(text, options) {
|
|
9088
9125
|
const conv = activeConversation();
|
|
9089
9126
|
if (!conv || !conv.runId) return;
|
|
9127
|
+
if (convAwaitingReview(conv)) resolveReviewTurn(conv, 'You replied instead. The run continued without a decision.');
|
|
9090
9128
|
const opts = options || {};
|
|
9091
9129
|
// Consume any pending coaching job selected via quick-coach or template picker.
|
|
9092
9130
|
// The server adds the correct invocation prefix ($fraim / /fraim) based on
|
|
@@ -9205,6 +9243,7 @@ async function continueRun(text, options) {
|
|
|
9205
9243
|
}
|
|
9206
9244
|
|
|
9207
9245
|
function foldRunIntoConversation(conv, run) {
|
|
9246
|
+
syncReviewTurns(conv);
|
|
9208
9247
|
const createdAt = stableConversationCreatedAt(conv, run);
|
|
9209
9248
|
if (createdAt !== undefined) conv.createdAt = createdAt;
|
|
9210
9249
|
// Issue #710: server run identity is canonical. The browser may have
|
|
@@ -9471,23 +9510,13 @@ function switchToConversation(id) {
|
|
|
9471
9510
|
tfShowArea(targetArea);
|
|
9472
9511
|
}
|
|
9473
9512
|
state.activeId = id;
|
|
9513
|
+
tf.areaSelection[tfAreaSelectionKey(targetArea)] = id;
|
|
9474
9514
|
persistActiveConversationSelection(id);
|
|
9475
9515
|
// Issue #1065: a find session belongs to one thread. srchOpenResult re-opens it with the search
|
|
9476
9516
|
// query immediately after this call, which is what lands the manager on the passage (R45).
|
|
9477
9517
|
if (typeof convFindClose === 'function') convFindClose();
|
|
9478
|
-
|
|
9479
|
-
|
|
9480
|
-
if (targetArea === 'company' || targetArea === 'manager') {
|
|
9481
|
-
tfEnsurePageInArea(targetArea);
|
|
9482
|
-
const host = document.getElementById(targetArea + '-conv-host');
|
|
9483
|
-
const info = document.getElementById(targetArea + '-info-view');
|
|
9484
|
-
if (host) host.hidden = false;
|
|
9485
|
-
if (info) info.hidden = true;
|
|
9486
|
-
// #1397: an area selection represents every explicit destination, not only
|
|
9487
|
-
// the Info view. Poll-driven renders must preserve this conversation until
|
|
9488
|
-
// the user chooses another destination or the conversation disappears.
|
|
9489
|
-
if (tf.areaSelection) tf.areaSelection[targetArea] = id;
|
|
9490
|
-
}
|
|
9518
|
+
tfEnsurePageInArea(targetArea);
|
|
9519
|
+
tfApplyAreaView(targetArea, tfResolveAreaView(targetArea, conv));
|
|
9491
9520
|
renderRail();
|
|
9492
9521
|
renderActive();
|
|
9493
9522
|
hydrateConversationBody(conv ? convBucketKey(conv) : state.projectPath, id).catch((error) =>
|
|
@@ -9505,9 +9534,47 @@ function switchToConversation(id) {
|
|
|
9505
9534
|
// Status line + project picker
|
|
9506
9535
|
// ---------------------------------------------------------------------------
|
|
9507
9536
|
|
|
9537
|
+
let statusNotificationTimer;
|
|
9538
|
+
function positionStatusNotification() {
|
|
9539
|
+
const notice = document.getElementById('hub-status-notification');
|
|
9540
|
+
const composer = document.getElementById('persistent-composer');
|
|
9541
|
+
if (!notice || notice.hidden) return;
|
|
9542
|
+
const box = composer && composer.getBoundingClientRect();
|
|
9543
|
+
const visible = box && box.width > 0 && box.top > 0 && box.top < innerHeight;
|
|
9544
|
+
notice.style.bottom = (visible ? innerHeight - box.top + 8 : 16) + 'px';
|
|
9545
|
+
notice.style.left = (visible ? box.left + box.width / 2 : innerWidth / 2) + 'px';
|
|
9546
|
+
}
|
|
9508
9547
|
function showStatus(text, isError) {
|
|
9509
|
-
|
|
9510
|
-
|
|
9548
|
+
clearTimeout(statusNotificationTimer);
|
|
9549
|
+
let notice = document.getElementById('hub-status-notification');
|
|
9550
|
+
if (!notice) {
|
|
9551
|
+
notice = document.createElement('div');
|
|
9552
|
+
notice.id = 'hub-status-notification';
|
|
9553
|
+
document.body.appendChild(notice);
|
|
9554
|
+
window.addEventListener('resize', positionStatusNotification);
|
|
9555
|
+
document.addEventListener('scroll', positionStatusNotification, true);
|
|
9556
|
+
const composer = document.getElementById('persistent-composer');
|
|
9557
|
+
if (composer && typeof ResizeObserver !== 'undefined') new ResizeObserver(positionStatusNotification).observe(composer);
|
|
9558
|
+
}
|
|
9559
|
+
notice.className = 'cp-rerun-toast hub-status-notification' + (isError ? ' error' : '');
|
|
9560
|
+
notice.replaceChildren();
|
|
9561
|
+
notice.hidden = !text;
|
|
9562
|
+
notice.setAttribute('role', isError ? 'alert' : 'status');
|
|
9563
|
+
notice.setAttribute('aria-live', isError ? 'assertive' : 'polite');
|
|
9564
|
+
const message = document.createElement('span');
|
|
9565
|
+
message.textContent = text || '';
|
|
9566
|
+
notice.appendChild(message);
|
|
9567
|
+
if (isError) {
|
|
9568
|
+
const dismiss = document.createElement('button');
|
|
9569
|
+
dismiss.type = 'button';
|
|
9570
|
+
dismiss.setAttribute('aria-label', 'Dismiss notification');
|
|
9571
|
+
dismiss.textContent = '\u00d7';
|
|
9572
|
+
dismiss.onclick = () => { notice.hidden = true; };
|
|
9573
|
+
notice.appendChild(dismiss);
|
|
9574
|
+
} else {
|
|
9575
|
+
statusNotificationTimer = setTimeout(() => { notice.hidden = true; }, 5000);
|
|
9576
|
+
}
|
|
9577
|
+
positionStatusNotification();
|
|
9511
9578
|
}
|
|
9512
9579
|
|
|
9513
9580
|
async function pickProject() {
|
|
@@ -10134,7 +10201,7 @@ function wireEvents() {
|
|
|
10134
10201
|
// just defocuses the current conversation and focuses the always-present
|
|
10135
10202
|
// composer, the same way clicking an employee starts/focuses a conversation.
|
|
10136
10203
|
els['new-conv-btn'].addEventListener('click', () => {
|
|
10137
|
-
|
|
10204
|
+
tf.areaSelection[tfAreaSelectionKey(tf.area)] = 'delegate';
|
|
10138
10205
|
// renderActive() re-applies the workspace mode unconditionally (needed
|
|
10139
10206
|
// when coming from the Project Info page, since activeId is already
|
|
10140
10207
|
// null there too and focusComposerForDelegate only re-renders when
|
|
@@ -10270,6 +10337,7 @@ function wireEvents() {
|
|
|
10270
10337
|
}
|
|
10271
10338
|
});
|
|
10272
10339
|
}
|
|
10340
|
+
els['voice-input-btn'].addEventListener('click', startVoiceInput);
|
|
10273
10341
|
els['coach-text'].addEventListener('input', () => {
|
|
10274
10342
|
syncSendButton();
|
|
10275
10343
|
// Issue #1657 R2: drive the inline suggestion list while idle (no
|
|
@@ -10418,25 +10486,7 @@ function wireEvents() {
|
|
|
10418
10486
|
els['micro-manage-toggle'].focus();
|
|
10419
10487
|
scrollThreadForReview(activeConversation());
|
|
10420
10488
|
});
|
|
10421
|
-
|
|
10422
|
-
els['thread-panel'].addEventListener('toggle', () => {
|
|
10423
|
-
const conv = activeConversation();
|
|
10424
|
-
if (!conv) return;
|
|
10425
|
-
panelStateFor(conv.id).thread = els['thread-panel'].open;
|
|
10426
|
-
if (els['thread-panel'].open) requestAnimationFrame(() => restoreManagePosition(conv));
|
|
10427
|
-
});
|
|
10428
|
-
}
|
|
10429
|
-
|
|
10430
|
-
// #770: Deliverables panel toggle: persist open/close state per conversation.
|
|
10431
|
-
if (els['deliverables-panel']) {
|
|
10432
|
-
els['deliverables-panel'].addEventListener('toggle', () => {
|
|
10433
|
-
const conv = activeConversation();
|
|
10434
|
-
if (conv) panelStateFor(conv.id).deliverables = els['deliverables-panel'].open;
|
|
10435
|
-
});
|
|
10436
|
-
}
|
|
10437
|
-
|
|
10438
|
-
// Issue #512 R7.7: unified action-bar review buttons. They live in the same
|
|
10439
|
-
// bar as the coaching chips; the chips/input never disappear.
|
|
10489
|
+
// The live review controls retain their handlers when moved into a review turn.
|
|
10440
10490
|
if (els['review-approve']) {
|
|
10441
10491
|
els['review-approve'].addEventListener('click', () => approveReview({ resumeAfterApproval: true }));
|
|
10442
10492
|
}
|
|
@@ -10551,18 +10601,6 @@ function wireEvents() {
|
|
|
10551
10601
|
return;
|
|
10552
10602
|
}
|
|
10553
10603
|
});
|
|
10554
|
-
if (els['conversation-focus-btn']) {
|
|
10555
|
-
els['conversation-focus-btn'].addEventListener('click', openConversationFocus);
|
|
10556
|
-
}
|
|
10557
|
-
if (els['conversation-focus-close']) {
|
|
10558
|
-
els['conversation-focus-close'].addEventListener('click', closeConversationFocus);
|
|
10559
|
-
}
|
|
10560
|
-
if (els['conversation-focus-overlay']) {
|
|
10561
|
-
els['conversation-focus-overlay'].addEventListener('click', (e) => {
|
|
10562
|
-
if (e.target === els['conversation-focus-overlay']) closeConversationFocus();
|
|
10563
|
-
});
|
|
10564
|
-
}
|
|
10565
|
-
document.addEventListener('keydown', handleConversationFocusEscape);
|
|
10566
10604
|
|
|
10567
10605
|
// Issue #1657: #coach-text's own keydown handler (wired above, alongside
|
|
10568
10606
|
// arrow-key suggestion navigation) now owns Enter-to-send directly via
|
|
@@ -11020,10 +11058,7 @@ const SRCH_MIN_QUERY_LENGTH = 2;
|
|
|
11020
11058
|
const SRCH_DEBOUNCE_MS = 150;
|
|
11021
11059
|
// Every element search hides to show results. Their pre-search hidden flags are captured verbatim
|
|
11022
11060
|
// so clearing restores the exact surface state, whatever it was (R6).
|
|
11023
|
-
const SRCH_HIDEABLE_IDS = [
|
|
11024
|
-
'hub-conv-page', 'proj-overview-page', 'proj-kanban',
|
|
11025
|
-
'manager-info-view', 'manager-conv-host', 'company-info-view', 'company-conv-host',
|
|
11026
|
-
];
|
|
11061
|
+
const SRCH_HIDEABLE_IDS = ['project', 'all', 'manager', 'company'].flatMap(srchHiddenForScope);
|
|
11027
11062
|
const SRCH_PANEL_IDS = ['project-results', 'overview-results', 'manager-results', 'company-results'];
|
|
11028
11063
|
|
|
11029
11064
|
const srch = {
|
|
@@ -11061,7 +11096,7 @@ function srchHiddenForScope(scope) {
|
|
|
11061
11096
|
if (scope === 'company') return ['company-info-view', 'company-conv-host'];
|
|
11062
11097
|
if (scope === 'manager') return ['manager-info-view', 'manager-conv-host'];
|
|
11063
11098
|
if (scope === 'all') return ['proj-overview-page', 'proj-kanban'];
|
|
11064
|
-
return ['hub-conv-page'];
|
|
11099
|
+
return ['proj-context-top', 'hub-conv-page'];
|
|
11065
11100
|
}
|
|
11066
11101
|
|
|
11067
11102
|
function srchSyncChip() {
|
|
@@ -11142,6 +11177,10 @@ function srchHidePanel() {
|
|
|
11142
11177
|
srch.surface = null;
|
|
11143
11178
|
}
|
|
11144
11179
|
srch.active = false;
|
|
11180
|
+
// Scope switches may happen while searching. Resolve the current destination
|
|
11181
|
+
// instead of letting an old snapshot override the current scope's selection.
|
|
11182
|
+
if (tf.area === 'projects') tfApplyWorkspaceMode();
|
|
11183
|
+
else if (tf.area === 'manager' || tf.area === 'company') tfRenderScopeView(tf.area);
|
|
11145
11184
|
}
|
|
11146
11185
|
|
|
11147
11186
|
function srchClear() {
|
|
@@ -12263,75 +12302,83 @@ async function tfSelectProjectView(view, projectId) {
|
|
|
12263
12302
|
}
|
|
12264
12303
|
|
|
12265
12304
|
function tfStoredTreeWidth() {
|
|
12266
|
-
|
|
12305
|
+
let raw;
|
|
12306
|
+
try { raw = window.localStorage.getItem(STORAGE_KEY_TREE_WIDTH); }
|
|
12307
|
+
catch { return state.treeNavWidth || TREE_WIDTH_DEFAULT; }
|
|
12267
12308
|
const parsed = Number(raw);
|
|
12268
|
-
if (!Number.isFinite(parsed)) return TREE_WIDTH_DEFAULT;
|
|
12309
|
+
if (raw === null || raw.trim() === '' || !Number.isFinite(parsed)) return TREE_WIDTH_DEFAULT;
|
|
12269
12310
|
return Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(parsed)));
|
|
12270
12311
|
}
|
|
12271
12312
|
|
|
12272
12313
|
function tfApplyTreeNavState() {
|
|
12273
|
-
|
|
12274
|
-
|
|
12275
|
-
|
|
12276
|
-
|
|
12277
|
-
|
|
12278
|
-
|
|
12279
|
-
|
|
12280
|
-
|
|
12281
|
-
|
|
12282
|
-
|
|
12283
|
-
|
|
12314
|
+
let collapsed = !!state.treeNavCollapsed;
|
|
12315
|
+
try { collapsed = window.localStorage.getItem(STORAGE_KEY_TREE_COLLAPSED) === '1'; } catch { /* Session state remains usable without storage. */ }
|
|
12316
|
+
document.querySelectorAll('.scope-shell').forEach((workspace) => {
|
|
12317
|
+
workspace.style.setProperty('--tree-sidebar-width', tfStoredTreeWidth() + 'px');
|
|
12318
|
+
workspace.classList.toggle('tree-collapsed', collapsed);
|
|
12319
|
+
const button = workspace.querySelector('.tree-collapse-btn');
|
|
12320
|
+
if (button) {
|
|
12321
|
+
const label = collapsed ? 'Expand navigation' : 'Collapse navigation';
|
|
12322
|
+
button.setAttribute('aria-label', label);
|
|
12323
|
+
button.setAttribute('aria-expanded', String(!collapsed));
|
|
12324
|
+
button.title = label;
|
|
12325
|
+
}
|
|
12326
|
+
});
|
|
12284
12327
|
}
|
|
12285
12328
|
|
|
12286
12329
|
function tfSetTreeCollapsed(collapsed) {
|
|
12287
|
-
|
|
12288
|
-
|
|
12330
|
+
state.treeNavCollapsed = collapsed;
|
|
12331
|
+
try {
|
|
12332
|
+
if (collapsed) window.localStorage.setItem(STORAGE_KEY_TREE_COLLAPSED, '1');
|
|
12333
|
+
else window.localStorage.removeItem(STORAGE_KEY_TREE_COLLAPSED);
|
|
12334
|
+
} catch { /* Keep the preference for this session when storage is blocked. */ }
|
|
12289
12335
|
tfApplyTreeNavState();
|
|
12290
12336
|
}
|
|
12291
12337
|
|
|
12292
12338
|
function tfWireTreeNavControls() {
|
|
12293
|
-
const workspace = document.getElementById('proj-workspace');
|
|
12294
|
-
const collapseBtn = document.getElementById('tree-collapse-btn');
|
|
12295
|
-
const resizer = document.getElementById('tree-resizer');
|
|
12296
|
-
if (!workspace) return;
|
|
12297
12339
|
tfApplyTreeNavState();
|
|
12298
|
-
|
|
12299
|
-
collapseBtn
|
|
12300
|
-
|
|
12301
|
-
|
|
12302
|
-
|
|
12303
|
-
|
|
12304
|
-
|
|
12305
|
-
|
|
12306
|
-
|
|
12307
|
-
|
|
12308
|
-
|
|
12309
|
-
|
|
12310
|
-
|
|
12311
|
-
dragging = false;
|
|
12312
|
-
|
|
12313
|
-
|
|
12314
|
-
|
|
12315
|
-
|
|
12316
|
-
|
|
12317
|
-
|
|
12318
|
-
|
|
12319
|
-
|
|
12320
|
-
|
|
12321
|
-
|
|
12322
|
-
|
|
12323
|
-
|
|
12324
|
-
|
|
12325
|
-
|
|
12326
|
-
|
|
12327
|
-
|
|
12328
|
-
|
|
12329
|
-
|
|
12330
|
-
|
|
12331
|
-
|
|
12332
|
-
|
|
12333
|
-
|
|
12334
|
-
|
|
12340
|
+
document.querySelectorAll('.scope-shell').forEach((workspace) => {
|
|
12341
|
+
const collapseBtn = workspace.querySelector('.tree-collapse-btn');
|
|
12342
|
+
const resizer = workspace.querySelector('.tree-resizer');
|
|
12343
|
+
if (collapseBtn && !collapseBtn.dataset.wired) {
|
|
12344
|
+
collapseBtn.dataset.wired = '1';
|
|
12345
|
+
collapseBtn.addEventListener('click', () => {
|
|
12346
|
+
tfSetTreeCollapsed(!workspace.classList.contains('tree-collapsed'));
|
|
12347
|
+
});
|
|
12348
|
+
}
|
|
12349
|
+
if (resizer && !resizer.dataset.wired) {
|
|
12350
|
+
resizer.dataset.wired = '1';
|
|
12351
|
+
let startX = 0;
|
|
12352
|
+
let startWidth = TREE_WIDTH_DEFAULT;
|
|
12353
|
+
let dragging = false;
|
|
12354
|
+
const stopDrag = () => {
|
|
12355
|
+
if (!dragging) return;
|
|
12356
|
+
dragging = false;
|
|
12357
|
+
workspace.classList.remove('tree-resizing');
|
|
12358
|
+
document.removeEventListener('pointermove', onMove);
|
|
12359
|
+
document.removeEventListener('pointerup', stopDrag);
|
|
12360
|
+
document.removeEventListener('pointercancel', stopDrag);
|
|
12361
|
+
};
|
|
12362
|
+
const onMove = (event) => {
|
|
12363
|
+
if (!dragging) return;
|
|
12364
|
+
const nextWidth = Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, startWidth + event.clientX - startX));
|
|
12365
|
+
state.treeNavWidth = Math.round(nextWidth);
|
|
12366
|
+
try { window.localStorage.setItem(STORAGE_KEY_TREE_WIDTH, String(state.treeNavWidth)); } catch { /* Session fallback. */ }
|
|
12367
|
+
tfApplyTreeNavState();
|
|
12368
|
+
};
|
|
12369
|
+
resizer.addEventListener('pointerdown', (event) => {
|
|
12370
|
+
event.preventDefault();
|
|
12371
|
+
tfSetTreeCollapsed(false);
|
|
12372
|
+
dragging = true;
|
|
12373
|
+
startX = event.clientX;
|
|
12374
|
+
startWidth = tfStoredTreeWidth();
|
|
12375
|
+
workspace.classList.add('tree-resizing');
|
|
12376
|
+
document.addEventListener('pointermove', onMove);
|
|
12377
|
+
document.addEventListener('pointerup', stopDrag);
|
|
12378
|
+
document.addEventListener('pointercancel', stopDrag);
|
|
12379
|
+
});
|
|
12380
|
+
}
|
|
12381
|
+
});
|
|
12335
12382
|
}
|
|
12336
12383
|
|
|
12337
12384
|
// ---------------------------------------------------------------------------
|
|
@@ -12888,88 +12935,46 @@ function tfTreeSep() {
|
|
|
12888
12935
|
return sep;
|
|
12889
12936
|
}
|
|
12890
12937
|
function tfRenderTree() {
|
|
12891
|
-
|
|
12938
|
+
renderScopeNavigation(document.getElementById('tree-sidebar'), 'projects');
|
|
12939
|
+
}
|
|
12940
|
+
|
|
12941
|
+
function renderScopeNavigation(host, area) {
|
|
12892
12942
|
if (!host) return;
|
|
12893
|
-
|
|
12894
|
-
|
|
12895
|
-
|
|
12896
|
-
|
|
12897
|
-
host.
|
|
12898
|
-
// #521: Project Info is the project's own page (brief + learnings, not stacked
|
|
12899
|
-
// over every conversation). This entry deselects any active run, shows that page.
|
|
12943
|
+
const isProject = area === 'projects';
|
|
12944
|
+
const oldSection = isProject
|
|
12945
|
+
? els['conv-list'].closest('.rail-section--runs')
|
|
12946
|
+
: host.querySelector('.rail-section--runs');
|
|
12947
|
+
host.replaceChildren();
|
|
12900
12948
|
const briefNav = document.createElement('button');
|
|
12901
|
-
briefNav.id = 'tree-brief-nav';
|
|
12949
|
+
if (isProject) briefNav.id = 'tree-brief-nav';
|
|
12902
12950
|
briefNav.type = 'button';
|
|
12903
12951
|
briefNav.className = 'tree-brief-nav';
|
|
12904
|
-
briefNav.
|
|
12905
|
-
briefNav.addEventListener('click', () =>
|
|
12906
|
-
state.activeId = null;
|
|
12907
|
-
state.showProjectInfo = true; // Issue #1657: the one explicit trigger for the Brief page.
|
|
12908
|
-
// #1565: was persistConversations() (debounced full-project PUT - reads/rewrites every
|
|
12909
|
-
// conversation, freezing Electron's main process on large histories). Clearing the active
|
|
12910
|
-
// selection is a body-safe activeId-only PATCH, same narrow path every other activeId
|
|
12911
|
-
// change already uses.
|
|
12912
|
-
if (typeof persistActiveConversationSelection === 'function') {
|
|
12913
|
-
persistActiveConversationSelection(null, { projectPath: state.projectPath || '' });
|
|
12914
|
-
}
|
|
12915
|
-
if (typeof renderRail === 'function') renderRail();
|
|
12916
|
-
renderActive();
|
|
12917
|
-
tfRenderProjectContextTop();
|
|
12918
|
-
tfApplyWorkspaceMode();
|
|
12919
|
-
});
|
|
12952
|
+
briefNav.textContent = '📋 ' + (isProject ? 'Project' : area === 'company' ? 'Company' : 'Manager') + ' Info';
|
|
12953
|
+
briefNav.addEventListener('click', () => tfToggleAreaView(area, 'info'));
|
|
12920
12954
|
host.appendChild(briefNav);
|
|
12921
12955
|
host.appendChild(tfTreeSep());
|
|
12922
|
-
|
|
12923
|
-
// lifecycle actions now live where their output lands — Run Project Onboarding
|
|
12924
|
-
// in the Brief section and Sleep on learnings in the Learnings section
|
|
12925
|
-
// (see tfRenderProjectContextTop). No separate tree section.
|
|
12926
|
-
// The legacy standalone "+ New job" entry (#new-conv-btn → openModal → the
|
|
12927
|
-
// #job-catalog/#next1/#instructions/#start chain) lives in the old `.rail`,
|
|
12928
|
-
// which the shell suppresses (display:none). Relocate the live node to the
|
|
12929
|
-
// top of the visible tree so the standalone conversation flow that the
|
|
12930
|
-
// shipped #339/#347/#429 Hub tests depend on stays clickable. Moving the
|
|
12931
|
-
// node preserves its cached `els` reference and wired click listener.
|
|
12932
|
-
appendManagementActions(host, 'projects');
|
|
12933
|
-
host.appendChild(tfTreeSep());
|
|
12934
|
-
// #693 R1 (PR round 2): the per-employee "Runs" accordions (renderRail's
|
|
12935
|
-
// conv-employee-group) ARE the single employees section — they list every
|
|
12936
|
-
// project employee with that employee's runs nested under their accordion.
|
|
12937
|
-
// Relocate that section into the tree; the redundant emp-hero list is retired.
|
|
12938
|
-
const convList = (typeof els !== 'undefined' && els['conv-list']) || document.getElementById('conv-list');
|
|
12939
|
-
const runsSection = convList && convList.closest('.rail-section--runs');
|
|
12940
|
-
if (runsSection) {
|
|
12941
|
-
runsSection.classList.add('tree-runs');
|
|
12942
|
-
host.appendChild(runsSection);
|
|
12943
|
-
}
|
|
12944
|
-
// #693 R1 (PR round 2): the redundant emp-hero employee list was removed. The
|
|
12945
|
-
// relocated Runs section above (renderRail's conv-employee-group) is now the
|
|
12946
|
-
// single employees section — every project employee, with their runs nested
|
|
12947
|
-
// under their own accordion, plus per-employee assign/hire-manager.
|
|
12948
|
-
|
|
12949
|
-
// #521 R5/R6.4: the project brief and learnings moved OUT of the tree into the
|
|
12950
|
-
// collapsible top sections (#proj-brief-acc / #proj-learnings-acc), rendered by
|
|
12951
|
-
// tfRenderProjectContextTop(). The tree is now employees + jobs only.
|
|
12956
|
+
appendManagementActions(host, area);
|
|
12952
12957
|
host.appendChild(tfTreeSep());
|
|
12953
|
-
|
|
12954
|
-
|
|
12955
|
-
|
|
12956
|
-
|
|
12957
|
-
|
|
12958
|
-
|
|
12959
|
-
|
|
12960
|
-
|
|
12961
|
-
|
|
12962
|
-
|
|
12963
|
-
|
|
12964
|
-
|
|
12965
|
-
|
|
12966
|
-
|
|
12967
|
-
|
|
12968
|
-
|
|
12969
|
-
|
|
12970
|
-
|
|
12958
|
+
const section = oldSection || document.createElement('section');
|
|
12959
|
+
section.className = 'rail-section rail-section--runs tree-runs';
|
|
12960
|
+
if (!oldSection) {
|
|
12961
|
+
const title = document.createElement('div');
|
|
12962
|
+
title.className = 'rail-section-label';
|
|
12963
|
+
title.textContent = 'Employees';
|
|
12964
|
+
section.appendChild(title);
|
|
12965
|
+
const list = document.createElement('div');
|
|
12966
|
+
list.className = 'conv-list';
|
|
12967
|
+
section.appendChild(list);
|
|
12968
|
+
}
|
|
12969
|
+
host.appendChild(section);
|
|
12970
|
+
if (!isProject) renderScopeEmployees(section.querySelector('.conv-list'), area);
|
|
12971
|
+
if (!isProject) {
|
|
12972
|
+
const note = document.createElement('div');
|
|
12973
|
+
note.className = 'area-rail-note';
|
|
12974
|
+
note.textContent = "These employees work across every project you manage. Launch a job here (+) for cross-project work. Its run stays on this tab; the same employee's job launched inside a project stays there. Coach and verify like any employee.";
|
|
12975
|
+
host.appendChild(note);
|
|
12976
|
+
}
|
|
12971
12977
|
host.appendChild(tfTreeSep());
|
|
12972
|
-
|
|
12973
12978
|
// Dot legend.
|
|
12974
12979
|
const legend = document.createElement('div');
|
|
12975
12980
|
legend.className = 'tree-legend';
|
|
@@ -12994,10 +12999,9 @@ function tfRenderTree() {
|
|
|
12994
12999
|
legend.appendChild(row);
|
|
12995
13000
|
}
|
|
12996
13001
|
host.appendChild(legend);
|
|
12997
|
-
|
|
12998
|
-
|
|
12999
|
-
|
|
13000
|
-
});
|
|
13002
|
+
const fallback = isProject ? activeConversation() : tfActiveScopedConv(area);
|
|
13003
|
+
briefNav.classList.toggle('active', tfResolveAreaView(area, fallback).info);
|
|
13004
|
+
tfReopenPendingRunDelete(host);
|
|
13001
13005
|
}
|
|
13002
13006
|
|
|
13003
13007
|
function tfSelectAssignmentConversation(assignment) {
|
|
@@ -13028,6 +13032,9 @@ function tfProjectOnboardingRun() {
|
|
|
13028
13032
|
}
|
|
13029
13033
|
|
|
13030
13034
|
function tfRenderProjectContextTop() {
|
|
13035
|
+
const heading = document.getElementById('project-info-heading');
|
|
13036
|
+
const project = tf.projects.find((entry) => entry.id === tf.activeProjectId);
|
|
13037
|
+
if (heading) heading.textContent = project ? project.name : ((state.projectPath || '').split(/[\\/]/).filter(Boolean).pop() || 'Project');
|
|
13031
13038
|
// Issue #1198: which folder this project points at, always on screen.
|
|
13032
13039
|
tfRenderProjectFolderRow();
|
|
13033
13040
|
// Onboarding loop framing renders ABOVE the accordions (#proj-onboarding-banner)
|
|
@@ -14349,16 +14356,51 @@ function tfActiveMgrConv() {
|
|
|
14349
14356
|
// conversations behind that destination, but it may not choose a different one.
|
|
14350
14357
|
// A deleted or re-scoped conversation invalidates the selection and restores the
|
|
14351
14358
|
// area's normal auto-home behavior.
|
|
14359
|
+
function tfAreaSelectionKey(area) {
|
|
14360
|
+
return area === 'projects' ? 'project:' + (tf.activeProjectId || state.projectPath || 'current') : area;
|
|
14361
|
+
}
|
|
14362
|
+
|
|
14352
14363
|
function tfResolveAreaView(area, fallbackConv) {
|
|
14353
|
-
const
|
|
14364
|
+
const key = tfAreaSelectionKey(area);
|
|
14365
|
+
const selected = tf.areaSelection[key];
|
|
14354
14366
|
if (selected === 'info') return { info: true, conversation: null };
|
|
14355
14367
|
if (selected === 'delegate') return { info: false, conversation: null, delegate: true };
|
|
14356
14368
|
if (selected) {
|
|
14357
14369
|
const conv = findConversation(selected);
|
|
14358
|
-
|
|
14359
|
-
|
|
14370
|
+
const scope = area === 'projects' ? 'project' : area;
|
|
14371
|
+
const belongs = conv && convScope(conv) === scope && (area !== 'projects' || projectConversations().includes(conv));
|
|
14372
|
+
if (belongs) return { info: false, conversation: conv };
|
|
14373
|
+
tf.areaSelection[key] = null;
|
|
14374
|
+
}
|
|
14375
|
+
const conv = fallbackConv && convScope(fallbackConv) === (area === 'projects' ? 'project' : area) ? fallbackConv : null;
|
|
14376
|
+
return { info: area !== 'projects' && !conv, conversation: conv, delegate: area === 'projects' && !conv };
|
|
14377
|
+
}
|
|
14378
|
+
|
|
14379
|
+
// Info and runs use hidden on every scope; search is an overlay on that selection.
|
|
14380
|
+
function tfApplyAreaView(area, view) {
|
|
14381
|
+
const isProject = area === 'projects';
|
|
14382
|
+
const info = document.getElementById(isProject ? 'proj-context-top' : area + '-info-view');
|
|
14383
|
+
const host = document.getElementById(isProject ? 'hub-conv-page' : area + '-conv-host');
|
|
14384
|
+
const searching = typeof srch !== 'undefined' && srch.active;
|
|
14385
|
+
if (info) info.hidden = searching || !view.info;
|
|
14386
|
+
if (host && (!isProject || host.closest('#proj-workspace'))) host.hidden = searching || view.info;
|
|
14387
|
+
const page = document.getElementById('hub-conv-page');
|
|
14388
|
+
if (!isProject && host && page && host.contains(page)) page.hidden = searching || view.info;
|
|
14389
|
+
const rail = document.getElementById(isProject ? 'tree-sidebar' : area + '-rail');
|
|
14390
|
+
const button = rail && rail.querySelector('.tree-brief-nav');
|
|
14391
|
+
if (button) button.classList.toggle('active', view.info);
|
|
14392
|
+
}
|
|
14393
|
+
|
|
14394
|
+
function tfRenderScopeView(area) {
|
|
14395
|
+
const view = tfResolveAreaView(area, tfActiveScopedConv(area));
|
|
14396
|
+
if (tf.area === area && !view.info) {
|
|
14397
|
+
state.activeId = view.conversation ? view.conversation.id : null;
|
|
14398
|
+
tfEnsurePageInArea(area);
|
|
14399
|
+
renderActive();
|
|
14400
|
+
} else if (tf.area === area) {
|
|
14401
|
+
tfEnsurePageInArea('projects');
|
|
14360
14402
|
}
|
|
14361
|
-
|
|
14403
|
+
tfApplyAreaView(area, view);
|
|
14362
14404
|
}
|
|
14363
14405
|
|
|
14364
14406
|
function tfAreaScopedConversations(area) {
|
|
@@ -14403,138 +14445,6 @@ function tfStartManagerPersonaJob(jobId) {
|
|
|
14403
14445
|
}
|
|
14404
14446
|
|
|
14405
14447
|
|
|
14406
|
-
// #1416: capture each employee group's open/closed state from the area rail (Manager or
|
|
14407
|
-
// Company) before it is rebuilt, mirroring renderRail()'s preservedEmployeeGroupOpen() for
|
|
14408
|
-
// the Projects rail. Without this, tfRenderManager()/tfRenderCompany()'s ~1-1.5s poll-driven
|
|
14409
|
-
// rebuild wiped the DOM and re-created every group, so a manually collapsed group snapped
|
|
14410
|
-
// back open on the next tick and there was no way to tell a no-run group from one the user
|
|
14411
|
-
// deliberately expanded.
|
|
14412
|
-
function captureAreaEmployeeGroupOpenState(rail) {
|
|
14413
|
-
const openState = new Map();
|
|
14414
|
-
if (!rail) return openState;
|
|
14415
|
-
rail.querySelectorAll('details.conv-employee-group[data-employee-key]').forEach((details) => {
|
|
14416
|
-
const key = details.dataset ? details.dataset.employeeKey : '';
|
|
14417
|
-
if (key) openState.set(key, details.open);
|
|
14418
|
-
});
|
|
14419
|
-
return openState;
|
|
14420
|
-
}
|
|
14421
|
-
|
|
14422
|
-
// #702: render a hired persona as an employee group on the Manager rail — the SAME
|
|
14423
|
-
// visual as the Projects rail employee groups (avatar + name + role + count + "+" that
|
|
14424
|
-
// opens the job palette), with the persona's MANAGER-invoked runs listed under it.
|
|
14425
|
-
// Keeps a persona looking like an employee, consistent across tabs.
|
|
14426
|
-
function tfRenderAreaPersonaGroup(rail, persona, area, employeeGroupOpenState) {
|
|
14427
|
-
const sample = { personaKey: persona.key };
|
|
14428
|
-
const runs = tfAreaScopedConversations(area).filter(
|
|
14429
|
-
(c) => c.personaKey === persona.key && conversationUiState(c) === 'complete'
|
|
14430
|
-
);
|
|
14431
|
-
const details = document.createElement('details');
|
|
14432
|
-
details.className = 'conv-employee-group';
|
|
14433
|
-
details.dataset.employeeKey = persona.key;
|
|
14434
|
-
// Active runs now live in the "In Progress" section above; this accordion shows only
|
|
14435
|
-
// completed runs so it defaults closed (user expands when they want history).
|
|
14436
|
-
const defaultOpen = false;
|
|
14437
|
-
details.open = employeeGroupOpenState && employeeGroupOpenState.has(persona.key)
|
|
14438
|
-
? employeeGroupOpenState.get(persona.key)
|
|
14439
|
-
: defaultOpen;
|
|
14440
|
-
const summary = document.createElement('summary');
|
|
14441
|
-
summary.className = 'conv-employee-tab';
|
|
14442
|
-
const avatar = buildConversationEmployeeAvatar(sample, 'conv-employee-avatar');
|
|
14443
|
-
const copy = document.createElement('span'); copy.className = 'conv-employee-tab-copy';
|
|
14444
|
-
const label = document.createElement('strong'); label.className = 'conv-employee-tab-label';
|
|
14445
|
-
label.textContent = persona.displayName || persona.key;
|
|
14446
|
-
const detail = document.createElement('small'); detail.className = 'conv-employee-tab-detail';
|
|
14447
|
-
detail.textContent = persona.role || 'AI Employee';
|
|
14448
|
-
copy.appendChild(label); copy.appendChild(detail);
|
|
14449
|
-
const addBtn = document.createElement('button'); addBtn.type = 'button'; addBtn.className = 'conv-employee-add';
|
|
14450
|
-
addBtn.textContent = '+';
|
|
14451
|
-
addBtn.title = 'Launch a job for ' + (persona.displayName || persona.key);
|
|
14452
|
-
addBtn.setAttribute('aria-label', 'Launch a job for ' + (persona.displayName || persona.key));
|
|
14453
|
-
addBtn.addEventListener('click', (e) => {
|
|
14454
|
-
e.preventDefault(); e.stopPropagation();
|
|
14455
|
-
// Launch from this area: palette prefiltered to the employee. startRun stamps
|
|
14456
|
-
// the active area on the conversation so it returns to the same rail.
|
|
14457
|
-
focusComposerForDelegate({ employeeId: (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.employeeId) || 'claude', prefixSearch: '/' + persona.key, personaKey: persona.key.startsWith('custom:') ? persona.key : undefined, actionContext: managementActionContext(area) });
|
|
14458
|
-
});
|
|
14459
|
-
const count = document.createElement('span'); count.className = 'conv-employee-tab-count';
|
|
14460
|
-
count.textContent = String(runs.length);
|
|
14461
|
-
summary.appendChild(avatar); summary.appendChild(copy); summary.appendChild(addBtn); summary.appendChild(count);
|
|
14462
|
-
details.appendChild(summary);
|
|
14463
|
-
if (!runs.length) {
|
|
14464
|
-
const listDiv = document.createElement('div'); listDiv.className = 'conv-employee-list';
|
|
14465
|
-
const hint = document.createElement('div'); hint.className = 'eh-empty';
|
|
14466
|
-
hint.textContent = 'No runs yet, + to launch a job.';
|
|
14467
|
-
listDiv.appendChild(hint);
|
|
14468
|
-
details.appendChild(listDiv);
|
|
14469
|
-
} else {
|
|
14470
|
-
details.appendChild(buildAreaGroupList(runs));
|
|
14471
|
-
}
|
|
14472
|
-
appendEmployeeGroupWithActions(rail, details);
|
|
14473
|
-
}
|
|
14474
|
-
|
|
14475
|
-
// #1397/#1398/#1606: shared employee rail for the Manager and Company tabs. Mirrors the
|
|
14476
|
-
// Projects-tab layout: In Progress (active runs) → per-employee accordions (completed
|
|
14477
|
-
// runs) → No FRAIM Employee group (completed null-persona runs). FRAIMworker is always
|
|
14478
|
-
// present via tfAreaRailPersonas; named employees join when the area is their home or
|
|
14479
|
-
// they have an actual run there.
|
|
14480
|
-
function tfRenderAreaEmployeeRail(rail, area, employeeGroupOpenState) {
|
|
14481
|
-
const allScopedConvs = tfAreaScopedConversations(area);
|
|
14482
|
-
const activeConvs = allScopedConvs.filter((c) => conversationUiState(c) !== 'complete');
|
|
14483
|
-
const doneNullPersonaConvs = allScopedConvs.filter(
|
|
14484
|
-
(c) => conversationUiState(c) === 'complete' && !c.personaKey
|
|
14485
|
-
);
|
|
14486
|
-
// Section 1: In Progress (active runs from all personas)
|
|
14487
|
-
const areaOpenFn = (key, defaultOpen) =>
|
|
14488
|
-
employeeGroupOpenState && employeeGroupOpenState.has(key)
|
|
14489
|
-
? employeeGroupOpenState.get(key)
|
|
14490
|
-
: defaultOpen;
|
|
14491
|
-
const ipDetails = buildInProgressSection(activeConvs.length, areaOpenFn);
|
|
14492
|
-
if (activeConvs.length > 0) {
|
|
14493
|
-
ipDetails.appendChild(buildAreaGroupList(activeConvs, { showEmployeeName: true }));
|
|
14494
|
-
} else {
|
|
14495
|
-
const emptyWrap = document.createElement('div'); emptyWrap.className = 'conv-employee-list';
|
|
14496
|
-
const ipEmpty = document.createElement('div'); ipEmpty.className = 'eh-empty';
|
|
14497
|
-
ipEmpty.textContent = 'Nothing running. Use + to assign a job below.';
|
|
14498
|
-
emptyWrap.appendChild(ipEmpty);
|
|
14499
|
-
ipDetails.appendChild(emptyWrap);
|
|
14500
|
-
}
|
|
14501
|
-
rail.appendChild(ipDetails);
|
|
14502
|
-
// Section 2: Per-employee accordions (completed runs, named personas)
|
|
14503
|
-
const areaPersonas = tfAreaRailPersonas(area);
|
|
14504
|
-
if (areaPersonas.length > 0) {
|
|
14505
|
-
const head = document.createElement('div'); head.className = 'area-rail-head'; head.textContent = 'Your employees';
|
|
14506
|
-
rail.appendChild(head);
|
|
14507
|
-
for (const persona of areaPersonas) {
|
|
14508
|
-
tfRenderAreaPersonaGroup(rail, persona, area, employeeGroupOpenState);
|
|
14509
|
-
}
|
|
14510
|
-
const pNote = document.createElement('div'); pNote.className = 'area-rail-note';
|
|
14511
|
-
pNote.textContent = "These employees work across every project you manage. Launch a job here (+) for cross-project work. Its run stays on this tab; the same employee's job launched inside a project stays there. Coach and verify like any employee.";
|
|
14512
|
-
rail.appendChild(pNote);
|
|
14513
|
-
}
|
|
14514
|
-
// Section 3: No FRAIM Employee group (completed null-persona runs)
|
|
14515
|
-
if (doneNullPersonaConvs.length > 0) {
|
|
14516
|
-
const wcDetails = document.createElement('details');
|
|
14517
|
-
wcDetails.className = 'conv-employee-group conv-employee-group--adhoc';
|
|
14518
|
-
wcDetails.dataset.employeeKey = NO_FRAIM_EMPLOYEE_GROUP_KEY;
|
|
14519
|
-
wcDetails.open = employeeGroupOpenState && employeeGroupOpenState.has(NO_FRAIM_EMPLOYEE_GROUP_KEY)
|
|
14520
|
-
? employeeGroupOpenState.get(NO_FRAIM_EMPLOYEE_GROUP_KEY)
|
|
14521
|
-
: true;
|
|
14522
|
-
const wcSummary = document.createElement('summary');
|
|
14523
|
-
wcSummary.className = 'conv-employee-tab';
|
|
14524
|
-
const wcAvatar = buildNoFraimEmployeeAvatar('conv-employee-avatar');
|
|
14525
|
-
const wcCopy = document.createElement('span'); wcCopy.className = 'conv-employee-tab-copy';
|
|
14526
|
-
const wcLabel = document.createElement('strong'); wcLabel.className = 'conv-employee-tab-label';
|
|
14527
|
-
wcLabel.textContent = NO_FRAIM_EMPLOYEE_LABEL;
|
|
14528
|
-
wcCopy.appendChild(wcLabel);
|
|
14529
|
-
const wcCount = document.createElement('span'); wcCount.className = 'conv-employee-tab-count';
|
|
14530
|
-
wcCount.textContent = String(doneNullPersonaConvs.length);
|
|
14531
|
-
wcSummary.appendChild(wcAvatar); wcSummary.appendChild(wcCopy); wcSummary.appendChild(wcCount);
|
|
14532
|
-
wcDetails.appendChild(wcSummary);
|
|
14533
|
-
wcDetails.appendChild(buildAreaGroupList(doneNullPersonaConvs));
|
|
14534
|
-
rail.appendChild(wcDetails);
|
|
14535
|
-
}
|
|
14536
|
-
}
|
|
14537
|
-
|
|
14538
14448
|
// Move the shared .page conversation panel into the specified area's workspace-conv host.
|
|
14539
14449
|
// All .workspace-conv CSS rules (header/rail hidden, conv fills space) apply automatically
|
|
14540
14450
|
// because the area-conv-host elements carry the workspace-conv class.
|
|
@@ -14551,22 +14461,19 @@ function tfEnsurePageInArea(area) {
|
|
|
14551
14461
|
|
|
14552
14462
|
// Toggle a Company/Manager area between its info view (accordions) and conversation panel.
|
|
14553
14463
|
function tfToggleAreaView(area, view) {
|
|
14554
|
-
|
|
14555
|
-
|
|
14556
|
-
|
|
14557
|
-
|
|
14558
|
-
|
|
14559
|
-
tfEnsurePageInArea(
|
|
14560
|
-
|
|
14464
|
+
tf.areaSelection[tfAreaSelectionKey(area)] = view;
|
|
14465
|
+
if (typeof srchClear === 'function') srchClear();
|
|
14466
|
+
state.activeId = null;
|
|
14467
|
+
persistActiveConversationSelection(null, area === 'projects' ? { projectPath: state.projectPath || '' } : { scope: area });
|
|
14468
|
+
if (area === 'projects') {
|
|
14469
|
+
tfEnsurePageInArea(area);
|
|
14470
|
+
renderRail();
|
|
14561
14471
|
renderActive();
|
|
14562
|
-
|
|
14563
|
-
|
|
14564
|
-
|
|
14565
|
-
// the run view just because a conversation is still running. Overwritten by
|
|
14566
|
-
// tfStartOnboardingJob() when a genuinely new run starts in this area, or by the user
|
|
14567
|
-
// opening a run directly (switchToConversation).
|
|
14568
|
-
if (tf.areaSelection && (area === 'manager' || area === 'company')) tf.areaSelection[area] = 'info';
|
|
14472
|
+
tfRenderProjectContextTop();
|
|
14473
|
+
} else {
|
|
14474
|
+
tfRenderScopeView(area);
|
|
14569
14475
|
}
|
|
14476
|
+
tfApplyAreaView(area, tfResolveAreaView(area, null));
|
|
14570
14477
|
}
|
|
14571
14478
|
|
|
14572
14479
|
function tfRenderCompany() {
|
|
@@ -14579,32 +14486,7 @@ function tfRenderCompany() {
|
|
|
14579
14486
|
// closure-owned unsaved values. Save/Reset clears the guard below; the status
|
|
14580
14487
|
// state keeps their feedback stable if the next poll then performs a render.
|
|
14581
14488
|
if (!state.brandEditorDraftActive && typeof tfRenderBrandEditor === 'function') tfRenderBrandEditor();
|
|
14582
|
-
|
|
14583
|
-
if (rail) {
|
|
14584
|
-
// #1416: capture the current open/closed state before wiping the rail so the
|
|
14585
|
-
// ~1.5s poll-driven rebuild below doesn't snap a manually collapsed group back open.
|
|
14586
|
-
const employeeGroupOpenState = captureAreaEmployeeGroupOpenState(rail);
|
|
14587
|
-
rail.innerHTML = '';
|
|
14588
|
-
const orgConv = tfActiveOrgConv();
|
|
14589
|
-
const infoBtn = document.createElement('button');
|
|
14590
|
-
infoBtn.type = 'button';
|
|
14591
|
-
infoBtn.className = 'area-rail-info-btn' + (tfResolveAreaView('company', orgConv).info ? ' info-active' : '');
|
|
14592
|
-
infoBtn.textContent = '📋 Company Info';
|
|
14593
|
-
infoBtn.addEventListener('click', () => tfToggleAreaView('company', 'info'));
|
|
14594
|
-
rail.appendChild(infoBtn);
|
|
14595
|
-
appendManagementActions(rail, 'company');
|
|
14596
|
-
// Issue #1124: when a company-scoped org conv exists, add a visible rail entry so
|
|
14597
|
-
// the user can see and navigate to it. Previously the rail had only the static info
|
|
14598
|
-
// button and the conversation was unreachable from the left nav.
|
|
14599
|
-
// #693 R1 (PR round 2): the "Company jobs" launcher list is retired. Its jobs
|
|
14600
|
-
// now run from the section they populate — Run Organization Onboarding in
|
|
14601
|
-
// "Context & rules", Synthesize company learnings in "Company learnings".
|
|
14602
|
-
// #1398: FRAIMworker (and any hired employee with a company-scoped run, e.g. Aida)
|
|
14603
|
-
// get the same employee-group presentation as the Manager rail — not just an orphan
|
|
14604
|
-
// entry for the single "most active" org conv.
|
|
14605
|
-
tfRenderAreaEmployeeRail(rail, 'company', employeeGroupOpenState);
|
|
14606
|
-
tfReopenPendingRunDelete(rail);
|
|
14607
|
-
}
|
|
14489
|
+
renderScopeNavigation(document.getElementById('company-rail'), 'company');
|
|
14608
14490
|
const profile = document.getElementById('company-profile');
|
|
14609
14491
|
const learn = document.getElementById('company-learnings');
|
|
14610
14492
|
if (profile) {
|
|
@@ -14665,27 +14547,7 @@ function tfRenderCompany() {
|
|
|
14665
14547
|
// #693 R1 (PR round 2): Company's learnings job is organizational-learning-synthesis, not sleep-on-learnings.
|
|
14666
14548
|
tfEnsureAccAction('company-learn-acc', '🧠', 'Synthesize company learnings', () => tfStartOrgLearningSynthesis());
|
|
14667
14549
|
tfMaybeRenderOrgPushBanner();
|
|
14668
|
-
|
|
14669
|
-
// otherwise show the info view (accordions). This uses the shared .page element
|
|
14670
|
-
// moved via tfEnsurePageInArea() so renderActive() renders the full panel.
|
|
14671
|
-
const orgConv = tfActiveOrgConv();
|
|
14672
|
-
const host = document.getElementById('company-conv-host');
|
|
14673
|
-
const info = document.getElementById('company-info-view');
|
|
14674
|
-
// #1397: resolve the area's one stable destination — an explicit conversation choice,
|
|
14675
|
-
// an explicit "Company Info" choice, or (absent either) the org conv auto-home. Replaces
|
|
14676
|
-
// the former Info-only tf.viewPinned special case with one navigation-state contract.
|
|
14677
|
-
const companyView = tfResolveAreaView('company', orgConv);
|
|
14678
|
-
if (tf.area === 'company' && !companyView.info && (companyView.conversation || companyView.delegate)) {
|
|
14679
|
-
state.activeId = companyView.conversation ? companyView.conversation.id : null;
|
|
14680
|
-
tfEnsurePageInArea('company');
|
|
14681
|
-
if (host) host.hidden = false;
|
|
14682
|
-
if (info) info.hidden = true;
|
|
14683
|
-
renderActive();
|
|
14684
|
-
} else {
|
|
14685
|
-
if (host) host.hidden = true;
|
|
14686
|
-
if (info) info.hidden = false;
|
|
14687
|
-
if (tf.area === 'company') tfEnsurePageInArea('projects');
|
|
14688
|
-
}
|
|
14550
|
+
tfRenderScopeView('company');
|
|
14689
14551
|
}
|
|
14690
14552
|
|
|
14691
14553
|
// #594 R3: show push banner in Company area after an org onboarding run completes
|
|
@@ -14747,32 +14609,7 @@ function tfMaybeRenderOrgPushBanner() {
|
|
|
14747
14609
|
|
|
14748
14610
|
function tfRenderManager() {
|
|
14749
14611
|
// #521 R6: manager-level job (manager-agreements) lives ONLY in this left rail.
|
|
14750
|
-
|
|
14751
|
-
if (rail) {
|
|
14752
|
-
// #1416: capture the current open/closed state before wiping the rail so the
|
|
14753
|
-
// ~1s poll-driven rebuild below doesn't snap a manually collapsed group back open.
|
|
14754
|
-
const employeeGroupOpenState = captureAreaEmployeeGroupOpenState(rail);
|
|
14755
|
-
rail.innerHTML = '';
|
|
14756
|
-
const mgrConv = tfActiveMgrConv();
|
|
14757
|
-
const infoBtn = document.createElement('button');
|
|
14758
|
-
infoBtn.type = 'button';
|
|
14759
|
-
infoBtn.className = 'area-rail-info-btn' + (tfResolveAreaView('manager', mgrConv).info ? ' info-active' : '');
|
|
14760
|
-
infoBtn.textContent = '📋 Manager Info';
|
|
14761
|
-
infoBtn.addEventListener('click', () => tfToggleAreaView('manager', 'info'));
|
|
14762
|
-
rail.appendChild(infoBtn);
|
|
14763
|
-
appendManagementActions(rail, 'manager');
|
|
14764
|
-
// #693 R1 (PR round 2): the "Manager jobs" launcher list is retired.
|
|
14765
|
-
// Manager agreements now runs from the "Context & rules" section it populates.
|
|
14766
|
-
// Issue #702/#1398: render an EMPLOYEE GROUP for FRAIMworker plus every hired persona
|
|
14767
|
-
// that is either home to this area (Ashley on Manager) or has an actual run here (e.g.
|
|
14768
|
-
// Aida's manager-scoped runs) — the same avatar + name + runs + "+" presentation as the
|
|
14769
|
-
// Projects rail. `mgrConv` (the area's own top-level run, e.g. manager-agreements) gets
|
|
14770
|
-
// its own rail entry unless a group above already lists it.
|
|
14771
|
-
tfRenderAreaEmployeeRail(rail, 'manager', employeeGroupOpenState);
|
|
14772
|
-
// #824: the manager rail also attaches run-delete controls; re-attach an open
|
|
14773
|
-
// confirm this innerHTML='' rebuild would otherwise drop.
|
|
14774
|
-
tfReopenPendingRunDelete(rail);
|
|
14775
|
-
}
|
|
14612
|
+
renderScopeNavigation(document.getElementById('manager-rail'), 'manager');
|
|
14776
14613
|
const profile = document.getElementById('manager-profile');
|
|
14777
14614
|
const learn = document.getElementById('manager-learnings');
|
|
14778
14615
|
const reverse = document.getElementById('reverse-mentoring');
|
|
@@ -14864,25 +14701,7 @@ function tfRenderManager() {
|
|
|
14864
14701
|
// Cancel/Save/Check button handlers) call renderConfiguredAgentsPanel() itself,
|
|
14865
14702
|
// not this function, so they are unaffected and still render immediately.
|
|
14866
14703
|
if (!state.configuredAgentEditingId) renderConfiguredAgentsPanel();
|
|
14867
|
-
|
|
14868
|
-
// otherwise show the info view (accordions). Uses the shared .page element.
|
|
14869
|
-
const mgrConv = tfActiveMgrConv();
|
|
14870
|
-
const mgrHost = document.getElementById('manager-conv-host');
|
|
14871
|
-
const mgrInfo = document.getElementById('manager-info-view');
|
|
14872
|
-
// #1397: resolve the area's one stable destination — see tfRenderCompany's companyView
|
|
14873
|
-
// for the full rationale. Replaces the former Info-only tf.viewPinned special case.
|
|
14874
|
-
const managerView = tfResolveAreaView('manager', mgrConv);
|
|
14875
|
-
if (tf.area === 'manager' && !managerView.info && (managerView.conversation || managerView.delegate)) {
|
|
14876
|
-
state.activeId = managerView.conversation ? managerView.conversation.id : null;
|
|
14877
|
-
tfEnsurePageInArea('manager');
|
|
14878
|
-
if (mgrHost) mgrHost.hidden = false;
|
|
14879
|
-
if (mgrInfo) mgrInfo.hidden = true;
|
|
14880
|
-
renderActive();
|
|
14881
|
-
} else {
|
|
14882
|
-
if (mgrHost) mgrHost.hidden = true;
|
|
14883
|
-
if (mgrInfo) mgrInfo.hidden = false;
|
|
14884
|
-
if (tf.area === 'manager') tfEnsurePageInArea('projects');
|
|
14885
|
-
}
|
|
14704
|
+
tfRenderScopeView('manager');
|
|
14886
14705
|
}
|
|
14887
14706
|
// #533: Reverse mentoring now renders the real manager-coaching learnings via
|
|
14888
14707
|
// tfRenderLearningsList(scope='reverse') — consistent with the other learning
|
|
@@ -16139,10 +15958,10 @@ function tfApplyOrgBrand(brand) {
|
|
|
16139
15958
|
coEl.hidden = !show;
|
|
16140
15959
|
}
|
|
16141
15960
|
document.title = (state.orgBrand && state.orgBrand.name) ? state.orgBrand.name + ' Hub' : 'FRAIM AI Hub';
|
|
16142
|
-
//
|
|
15961
|
+
// Scope Info eyebrows reflect the company name (the FRAIM
|
|
16143
15962
|
// co-mark stays in the nav — co-branding, not white-label).
|
|
16144
15963
|
var eyebrow = (state.orgBrand && state.orgBrand.name) ? state.orgBrand.name : 'FRAIM Hub';
|
|
16145
|
-
['company-info-view', 'manager-info-view'].forEach(function (id) {
|
|
15964
|
+
['company-info-view', 'manager-info-view', 'proj-context-top'].forEach(function (id) {
|
|
16146
15965
|
var hostEl = document.getElementById(id);
|
|
16147
15966
|
var el = hostEl && hostEl.querySelector('.eyebrow');
|
|
16148
15967
|
if (el) el.textContent = eyebrow;
|
|
@@ -16422,8 +16241,8 @@ async function tfStartOnboardingJob(jobId, message, targetArea, userContext) {
|
|
|
16422
16241
|
// win over an earlier selection (Info view or a different conversation), otherwise the
|
|
16423
16242
|
// just-started run (e.g. create-hub-configured-agent) would stay invisible behind a
|
|
16424
16243
|
// stale manual choice. startRun() already set state.activeId to the new conversation.
|
|
16425
|
-
if (tf.areaSelection &&
|
|
16426
|
-
tf.areaSelection[targetArea] = state.activeId;
|
|
16244
|
+
if (tf.areaSelection && state.activeId) {
|
|
16245
|
+
tf.areaSelection[tfAreaSelectionKey(targetArea)] = state.activeId;
|
|
16427
16246
|
}
|
|
16428
16247
|
// After run creation refresh the area panel so the conversation becomes visible.
|
|
16429
16248
|
if (targetArea === 'company') tfRenderCompany();
|