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.
@@ -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
- 'status-line', 'coach-note',
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
- els['status-line'].textContent = bootstrap.project.message || '';
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 || !els['thread-panel'].open) return;
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
- if (activeConversationId === id || state.activeId === id) {
2414
- activeConversationId = null;
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
- if (!(activeConversationId === id || state.activeId === id || deletedConversationIds.has(activeConversationId) || deletedConversationIds.has(state.activeId))) return;
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
- // Shared by renderRail() (Projects), tfRenderAreaEmployeeRail() (Manager/Company),
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[]> — pass new Map() for Manager/Company (no delegation yet).
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
- const activeRailProjectKey = (typeof tf !== 'undefined' && tf && tf.activeProjectId)
2846
- || state.projectPath
2847
- || 'current';
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
- els['conv-list'].querySelectorAll('details.conv-employee-group[data-employee-key]').forEach((details) => {
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
- // Only preserve the toggle state of groups that actually have runs. A hired persona
2853
- // renders as a zero-run group BEFORE conversations hydrate; capturing that transient
2854
- // collapsed state would wrongly keep the group collapsed after it gains runs. No-run
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
- els['conv-list'].innerHTML = '';
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 allProjectConversations = projectConversations();
2874
- const managedChildren = allProjectConversations.filter(isManagedDelegationChild);
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 allProjectConversations) {
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 = allProjectConversations.filter((conv) =>
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 filteredEmployeeKeys = state.selectedPersonaKey
2976
- ? new Set([state.selectedPersonaKey].filter((k) => projectEmployeeKeys.has(k)))
2977
- : projectEmployeeKeys;
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 = typeof tfPersonaByKey === 'function' ? tfPersonaByKey(key) : null;
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 allProjectConversations) {
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
- els['conv-list'].appendChild(inProgressSec);
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(els['conv-list'], details);
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
- els['conv-list'].appendChild(wcDetails);
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
- els['conv-list'].appendChild(pendingRow);
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
- els['conv-list'].appendChild(timeoutRow);
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 = els['conv-list'].closest('.rail-section--runs');
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(els['conv-list']);
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 there is no active conversation (which normally keeps it in mode-brief).
3794
+ // even when the manager was reading Project Info.
3727
3795
  const wc = document.querySelector('#proj-workspace .workspace-conv');
3728
- if (wc) { wc.classList.add('mode-conv'); wc.classList.remove('mode-brief'); }
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
- const wc = document.querySelector('#proj-workspace .workspace-conv');
3877
- if (!wc) return;
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 || statusChanged || threadWasNearBottom) && (!switchedConv || manageViewFor(conv).messages.follow);
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
- renderDeliverablesPanel(conv); // #770
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[open] grows to fill the
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 reviewOffset = Math.max(24, host.clientHeight * 0.16);
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.scrollTop = desiredTop;
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
- if (!actions) return;
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
- return;
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
- // #770 A1: single-run deliverables live in a stable collapsible panel in the
7236
- // support stack, decoupled from the transient approve/reject decision UI. Fed by
7237
- // the same format derivation as the old in-thread strip, so a genuine structured
7238
- // handoff / recorded run artifact yields open/view chips; prose-only replies
7239
- // yield nothing (panel hidden). For manager-oversight runs the format includes
7240
- // a union of Mandy's own artifacts and every reviewed child's artifacts (#1566).
7241
- function renderDeliverablesPanel(conv) {
7242
- const panel = els['deliverables-panel'];
7243
- const body = els['deliverables-body'];
7244
- if (!panel || !body) return;
7245
- const fmt = conv ? deriveDeliverableFormat(conv) : null;
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
- const summary = els['deliverables-summary'];
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
- state.showProjectInfo = false;
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
- // #status-line, etc.) so a screen reader announces the message instead of it being silently
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
- if (context.area === 'company' || context.area === 'manager') tf.areaSelection[context.area] = conv.id;
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
- // For Company/Manager: move the shared .page into this area's conv-host and
9479
- // switch from the info view to the full conversation surface.
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
- els['status-line'].textContent = text || '';
9510
- els['status-line'].classList.toggle('error', !!isError);
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
- state.showProjectInfo = false; // Issue #1657: return to the composer, not the Brief page.
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
- if (els['thread-panel']) {
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
- const raw = window.localStorage.getItem(STORAGE_KEY_TREE_WIDTH);
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
- const workspace = document.getElementById('proj-workspace');
12274
- const collapseBtn = document.getElementById('tree-collapse-btn');
12275
- if (!workspace) return;
12276
- const collapsed = window.localStorage.getItem(STORAGE_KEY_TREE_COLLAPSED) === '1';
12277
- workspace.style.setProperty('--tree-sidebar-width', tfStoredTreeWidth() + 'px');
12278
- workspace.classList.toggle('tree-collapsed', collapsed);
12279
- if (collapseBtn) {
12280
- const label = collapsed ? 'Expand project navigation' : 'Collapse project navigation';
12281
- collapseBtn.setAttribute('aria-label', label);
12282
- collapseBtn.title = label;
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
- if (collapsed) window.localStorage.setItem(STORAGE_KEY_TREE_COLLAPSED, '1');
12288
- else window.localStorage.removeItem(STORAGE_KEY_TREE_COLLAPSED);
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
- if (collapseBtn && !collapseBtn.dataset.wired) {
12299
- collapseBtn.dataset.wired = '1';
12300
- collapseBtn.addEventListener('click', () => {
12301
- tfSetTreeCollapsed(!workspace.classList.contains('tree-collapsed'));
12302
- });
12303
- }
12304
- if (resizer && !resizer.dataset.wired) {
12305
- resizer.dataset.wired = '1';
12306
- let startX = 0;
12307
- let startWidth = TREE_WIDTH_DEFAULT;
12308
- let dragging = false;
12309
- const stopDrag = () => {
12310
- if (!dragging) return;
12311
- dragging = false;
12312
- workspace.classList.remove('tree-resizing');
12313
- document.removeEventListener('pointermove', onMove);
12314
- document.removeEventListener('pointerup', stopDrag);
12315
- document.removeEventListener('pointercancel', stopDrag);
12316
- };
12317
- const onMove = (event) => {
12318
- if (!dragging) return;
12319
- const nextWidth = Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, startWidth + event.clientX - startX));
12320
- window.localStorage.setItem(STORAGE_KEY_TREE_WIDTH, String(Math.round(nextWidth)));
12321
- workspace.style.setProperty('--tree-sidebar-width', Math.round(nextWidth) + 'px');
12322
- };
12323
- resizer.addEventListener('pointerdown', (event) => {
12324
- event.preventDefault();
12325
- tfSetTreeCollapsed(false);
12326
- dragging = true;
12327
- startX = event.clientX;
12328
- startWidth = tfStoredTreeWidth();
12329
- workspace.classList.add('tree-resizing');
12330
- document.addEventListener('pointermove', onMove);
12331
- document.addEventListener('pointerup', stopDrag);
12332
- document.addEventListener('pointercancel', stopDrag);
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
- const host = document.getElementById('tree-sidebar');
12938
+ renderScopeNavigation(document.getElementById('tree-sidebar'), 'projects');
12939
+ }
12940
+
12941
+ function renderScopeNavigation(host, area) {
12892
12942
  if (!host) return;
12893
- // #533 R5: remember which accordions are open so a status-driven re-render (e.g.
12894
- // marking a job done) only recolors dots — it must not collapse the manager's tree.
12895
- const openState = {};
12896
- host.querySelectorAll('details[data-acc-key]').forEach((d) => { openState[d.dataset.accKey] = d.open; });
12897
- host.innerHTML = '';
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.innerHTML = '<span class="tbn-ico">📋</span> <span>Project Info</span>';
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
- // #693 R1: the standalone "Project Updates" accordion is retired. Its two
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
- // Issue #945: + New employee node (Design B in-place creation).
12955
- const newEmpNode = document.createElement('div');
12956
- newEmpNode.className = 'ipe-new';
12957
- newEmpNode.setAttribute('role', 'button');
12958
- newEmpNode.setAttribute('tabindex', '0');
12959
- newEmpNode.title = 'Create a custom employee for this project';
12960
- const newEmpIcon = document.createElement('span');
12961
- newEmpIcon.className = 'ipe-new-icon';
12962
- newEmpIcon.setAttribute('aria-hidden', 'true');
12963
- newEmpIcon.textContent = '+';
12964
- const newEmpLabel = document.createElement('span');
12965
- newEmpLabel.textContent = 'New employee';
12966
- newEmpNode.appendChild(newEmpIcon);
12967
- newEmpNode.appendChild(newEmpLabel);
12968
- newEmpNode.addEventListener('click', () => openInlineCreateEmployee());
12969
- newEmpNode.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openInlineCreateEmployee(); } });
12970
- host.appendChild(newEmpNode);
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
- // #533 R5: restore the open/closed state captured before the rebuild.
12998
- host.querySelectorAll('details[data-acc-key]').forEach((d) => {
12999
- if (Object.prototype.hasOwnProperty.call(openState, d.dataset.accKey)) d.open = openState[d.dataset.accKey];
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 selected = tf.areaSelection && tf.areaSelection[area];
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
- if (conv && convScope(conv) === area) return { info: false, conversation: conv };
14359
- tf.areaSelection[area] = null;
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
- return { info: !fallbackConv, conversation: fallbackConv || null };
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
- const host = document.getElementById(area + '-conv-host');
14555
- const info = document.getElementById(area + '-info-view');
14556
- if (view === 'info') {
14557
- if (host) host.hidden = true;
14558
- if (info) info.hidden = false;
14559
- tfEnsurePageInArea('projects');
14560
- state.activeId = null;
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
- // #1397: a manual switch to the info view is a deliberate navigation choice, exactly
14563
- // like picking a specific conversation — record it as this area's explicit selection so
14564
- // the next poll-driven tfRenderManager()/tfRenderCompany() doesn't silently flip back to
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
- const rail = document.getElementById('company-rail');
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
- // Show the full coaching conversation panel when there is an active org conv;
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
- const rail = document.getElementById('manager-rail');
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
- // Show the full coaching conversation panel when there is an active manager conv;
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
- // R4.1: Company/Manager area eyebrows reflect the company name (the FRAIM
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 && (targetArea === 'manager' || targetArea === 'company') && state.activeId) {
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();