fraim-hub 2.0.335 → 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.
@@ -144,7 +144,7 @@ function gatherElements() {
144
144
  'coach-text', 'send', 'micro-manage', 'micro-log', 'resume-command', 'resume-command-code', 'resume-command-copy',
145
145
  'coach-note',
146
146
  // Issue #1651: composer attachments.
147
- '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',
148
148
  'thread-panel', 'thread-panel-kicker', 'micro-manage-toggle', 'micro-return', 'micro-activity-state',
149
149
  'quick-coach-btns',
150
150
  'positive-reinforcement-btn', 'positive-reinforcement-dropdown',
@@ -332,6 +332,7 @@ function mergePersonaProjection(incoming, previous) {
332
332
  function applyBootstrap(bootstrap, docUrl) {
333
333
  bootstrap = mergePersonaProjection(bootstrap, state.bootstrap);
334
334
  state.bootstrap = bootstrap;
335
+ syncVoiceInput();
335
336
  state.projectPath = bootstrap.project.path;
336
337
  // Issue #1044: seed client-side tombstone set from server preferences so
337
338
  // tfEnsureCurrentProject does not re-inject a path the user just deleted.
@@ -2164,6 +2165,64 @@ function syncQuickCoachButtons(conv) {
2164
2165
  }
2165
2166
  }
2166
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
+
2167
2226
  function composerIsArmed() {
2168
2227
  return !!(
2169
2228
  (els['coach-text'] && els['coach-text'].value.trim().length > 0) ||
@@ -2688,8 +2747,7 @@ function showCustomEmployeeDeleteConfirm(group, detailsEl) {
2688
2747
  // ---------------------------------------------------------------------------
2689
2748
 
2690
2749
  // Build a run-item button for any area rail.
2691
- // Shared by renderRail() (Projects), tfRenderAreaEmployeeRail() (Manager/Company),
2692
- // and tfRenderAreaPersonaGroup(). Extracted from renderRail's inner buildRunButton.
2750
+ // Used by the shared employee builder for Projects, Manager, and Company.
2693
2751
  function buildAreaRunButton(conv, options) {
2694
2752
  options = options || {};
2695
2753
  const btn = document.createElement('button');
@@ -2753,7 +2811,7 @@ function buildAreaRunButton(conv, options) {
2753
2811
  }
2754
2812
 
2755
2813
  // Build a <div class="conv-employee-list"> with run buttons and optional delegation children.
2756
- // childrenByManager: Map<id, conv[]> — pass new Map() for Manager/Company (no delegation yet).
2814
+ // childrenByManager: Map<id, conv[]> — scoped by the caller before nesting.
2757
2815
  function buildAreaGroupList(conversations, opts, childrenByManager) {
2758
2816
  childrenByManager = childrenByManager || new Map();
2759
2817
  opts = opts || {};
@@ -2845,19 +2903,21 @@ function appendEmployeeGroupWithActions(container, details) {
2845
2903
 
2846
2904
  function renderRail() {
2847
2905
  renderTeamRoster();
2848
- const activeRailProjectKey = (typeof tf !== 'undefined' && tf && tf.activeProjectId)
2849
- || state.projectPath
2850
- || '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;
2851
2914
  const employeeGroupOpenState = new Map();
2852
- 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) => {
2853
2916
  const key = details.dataset ? details.dataset.employeeKey : '';
2854
2917
  const projectKey = details.dataset ? (details.dataset.projectKey || activeRailProjectKey) : activeRailProjectKey;
2855
- // Only preserve the toggle state of groups that actually have runs. A hired persona
2856
- // renders as a zero-run group BEFORE conversations hydrate; capturing that transient
2857
- // collapsed state would wrongly keep the group collapsed after it gains runs. No-run
2858
- // groups always follow the default (collapsed); with-run groups default open (#726 UX).
2859
- const hasRuns = !!details.querySelector('.conv-item');
2860
- 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);
2861
2921
  });
2862
2922
  const employeeGroupStateKey = (key) => activeRailProjectKey + ':' + key;
2863
2923
  // Preserve the user's open/closed toggle across re-renders; otherwise fall back to
@@ -2868,13 +2928,15 @@ function renderRail() {
2868
2928
  ? employeeGroupOpenState.get(employeeGroupStateKey(key))
2869
2929
  : defaultOpen
2870
2930
  );
2871
- els['conv-list'].innerHTML = '';
2931
+ host.innerHTML = '';
2872
2932
  // R4: filter by selected persona when one is active.
2873
2933
  // #693 R1: the "Project Updates" section was retired; project-lifecycle runs
2874
2934
  // (project-onboarding + sleep-on-learnings) now surface in the Runs list under
2875
2935
  // their employee like any other run, so their history stays reachable.
2876
- const allProjectConversations = projectConversations();
2877
- 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);
2878
2940
  const childrenByManager = new Map();
2879
2941
  const childIdentity = (child) => child && (child.id || child.runId);
2880
2942
  const addChildForManager = (managerId, child) => {
@@ -2897,7 +2959,7 @@ function renderRail() {
2897
2959
  if (task.jobId && child.jobId && task.jobId !== child.jobId) return false;
2898
2960
  return true;
2899
2961
  };
2900
- for (const manager of allProjectConversations) {
2962
+ for (const manager of scopedConversations) {
2901
2963
  const ledger = normalizeDelegationLedger(manager.delegation);
2902
2964
  if (!ledger) continue;
2903
2965
  for (const task of ledger.tasks || []) {
@@ -2908,12 +2970,12 @@ function renderRail() {
2908
2970
  }
2909
2971
  }
2910
2972
  }
2911
- const list = allProjectConversations.filter((conv) =>
2912
- (!state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey) &&
2973
+ const list = scopedConversations.filter((conv) =>
2974
+ (!isProject || !state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey) &&
2913
2975
  !isManagedDelegationChild(conv) &&
2914
2976
  // #702/#708/#1408: manager/company-scoped runs surface on those tabs, not in a
2915
2977
  // project, decided by scope alone — no job id is ever excluded from Projects.
2916
- convScope(conv) === 'project'
2978
+ convScope(conv) === (isProject ? 'project' : area)
2917
2979
  );
2918
2980
 
2919
2981
  // Issue #851 Option A: In Progress section (top) + per-employee accordion tree.
@@ -2975,13 +3037,15 @@ function renderRail() {
2975
3037
  ...managerTeamKeys,
2976
3038
  ]);
2977
3039
  // Apply persona filter.
2978
- const filteredEmployeeKeys = state.selectedPersonaKey
2979
- ? new Set([state.selectedPersonaKey].filter((k) => projectEmployeeKeys.has(k)))
2980
- : 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());
2981
3045
 
2982
3046
  for (const key of filteredEmployeeKeys) {
2983
3047
  if (doneGroups.has(key)) continue;
2984
- const p = typeof tfPersonaByKey === 'function' ? tfPersonaByKey(key) : null;
3048
+ const p = getConversationPersona({ personaKey: key });
2985
3049
  doneGroups.set(key, {
2986
3050
  key,
2987
3051
  label: p ? p.displayName : key,
@@ -2993,7 +3057,7 @@ function renderRail() {
2993
3057
 
2994
3058
  // Compute latest kickoff timestamp per employee for employee tree sorting.
2995
3059
  const employeeLatest = new Map();
2996
- for (const conv of allProjectConversations) {
3060
+ for (const conv of scopedConversations) {
2997
3061
  if (!conv) continue;
2998
3062
  const key = conv.personaKey || conversationAgentName(conv);
2999
3063
  if (!key) continue;
@@ -3004,6 +3068,7 @@ function renderRail() {
3004
3068
  // Sort employee groups: employees with completed runs first (latest kickoff),
3005
3069
  // then no-run employees last (stable by key).
3006
3070
  const sortedDoneGroups = [...doneGroups.values()].sort((a, b) => {
3071
+ if (!isProject) return (areaPersonaOrder.get(a.key) ?? Infinity) - (areaPersonaOrder.get(b.key) ?? Infinity);
3007
3072
  const aLatest = employeeLatest.get(a.key) || 0;
3008
3073
  const bLatest = employeeLatest.get(b.key) || 0;
3009
3074
  const aHas = aLatest > 0 ? 1 : 0;
@@ -3028,7 +3093,7 @@ function renderRail() {
3028
3093
  emptyWrap.appendChild(em);
3029
3094
  inProgressSec.appendChild(emptyWrap);
3030
3095
  }
3031
- els['conv-list'].appendChild(inProgressSec);
3096
+ host.appendChild(inProgressSec);
3032
3097
 
3033
3098
  // ----- SECTION 2: Per-employee accordion tree -----
3034
3099
  // Each employee gets their own accordion showing their COMPLETED runs underneath,
@@ -3076,11 +3141,11 @@ function renderRail() {
3076
3141
  // personaKey is passed separately so renderCpRows can look up the jobIds correctly.
3077
3142
  if (group.key.startsWith('custom:')) {
3078
3143
  const displaySlug = group.label ? group.label.toLowerCase().replace(/\s+/g, '-') : group.key.replace('custom:', '');
3079
- focusComposerForDelegate({ prefixSearch: '/' + displaySlug, personaKey: group.key });
3144
+ focusComposerForDelegate({ prefixSearch: '/' + displaySlug, personaKey: group.key, actionContext: managementActionContext(area) });
3080
3145
  return;
3081
3146
  }
3082
3147
  const personaKey = group.sample && group.sample.personaKey ? group.key : null;
3083
- 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) });
3084
3149
  });
3085
3150
  }
3086
3151
  let manageBtn = null;
@@ -3138,7 +3203,7 @@ function renderRail() {
3138
3203
  emptyWrap.appendChild(em);
3139
3204
  details.appendChild(emptyWrap);
3140
3205
  }
3141
- appendEmployeeGroupWithActions(els['conv-list'], details);
3206
+ appendEmployeeGroupWithActions(host, details);
3142
3207
  }
3143
3208
 
3144
3209
  // No FRAIM employee group: completed runs with no owning persona.
@@ -3165,29 +3230,29 @@ function renderRail() {
3165
3230
  wcSummary.appendChild(wcCount);
3166
3231
  wcDetails.appendChild(wcSummary);
3167
3232
  wcDetails.appendChild(buildAreaGroupList(noFraimEmployeeConvs, {}, childrenByManager));
3168
- els['conv-list'].appendChild(wcDetails);
3233
+ host.appendChild(wcDetails);
3169
3234
  }
3170
3235
 
3171
3236
  // #769: hire-pending polling indicator
3172
- if (typeof tf !== 'undefined' && tf && tf.hirePendingState === 'polling') {
3237
+ if (isProject && typeof tf !== 'undefined' && tf && tf.hirePendingState === 'polling') {
3173
3238
  const pendingRow = document.createElement('div');
3174
3239
  pendingRow.className = 'hire-pending-row';
3175
3240
  pendingRow.id = 'hire-pending-row';
3176
3241
  pendingRow.innerHTML = '<div class="hire-pending-spinner"></div><span class="hire-pending-text">Checking your new hire…</span>';
3177
- els['conv-list'].appendChild(pendingRow);
3178
- } else if (typeof tf !== 'undefined' && tf && tf.hirePendingState === 'timeout') {
3242
+ host.appendChild(pendingRow);
3243
+ } else if (isProject && typeof tf !== 'undefined' && tf && tf.hirePendingState === 'timeout') {
3179
3244
  const timeoutRow = document.createElement('div');
3180
3245
  timeoutRow.className = 'hire-timeout-row';
3181
3246
  timeoutRow.id = 'hire-timeout-row';
3182
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>';
3183
- els['conv-list'].appendChild(timeoutRow);
3248
+ host.appendChild(timeoutRow);
3184
3249
  }
3185
3250
 
3186
3251
  // #851 Option A: tree always has content (at minimum In Progress + employee tree).
3187
- const runsSection = els['conv-list'].closest('.rail-section--runs');
3252
+ const runsSection = host.closest('.rail-section--runs');
3188
3253
  if (runsSection) runsSection.hidden = false;
3189
3254
  // #824: re-attach an open run-delete confirm that this rebuild would otherwise drop.
3190
- tfReopenPendingRunDelete(els['conv-list']);
3255
+ tfReopenPendingRunDelete(host);
3191
3256
  }
3192
3257
 
3193
3258
  function statusLabel(s) {
@@ -3726,9 +3791,9 @@ function showHireStrip(job, conversationId, instructions, employeeId) {
3726
3791
  state._hireStripPending = { job, conversationId, instructions, employeeId };
3727
3792
  els['hire-strip'].hidden = false;
3728
3793
  // Switch workspace to conv mode so the hire strip (inside .page) is visible
3729
- // even when there is no active conversation (which normally keeps it in mode-brief).
3794
+ // even when the manager was reading Project Info.
3730
3795
  const wc = document.querySelector('#proj-workspace .workspace-conv');
3731
- if (wc) { wc.classList.add('mode-conv'); wc.classList.remove('mode-brief'); }
3796
+ if (wc) tfToggleAreaView('projects', 'delegate');
3732
3797
  }
3733
3798
 
3734
3799
  function hideHireStrip() {
@@ -3864,20 +3929,8 @@ function syncMicroArchive(conv) {
3864
3929
  // coach, and micro-manage all have room. Toggle the mode class the CSS keys off,
3865
3930
  // and reflect the state in the sidebar's Brief nav entry.
3866
3931
  function tfApplyWorkspaceMode() {
3867
- const wc = document.querySelector('#proj-workspace .workspace-conv');
3868
- if (!wc) return;
3869
- const hasConv = typeof activeConversation === 'function' && !!activeConversation();
3870
- // Issue #1657 Treatment 3: "no active conversation" no longer implies "show
3871
- // the Brief page" by itself - the persistent composer's idle state (#empty)
3872
- // must stay visible then too (R1: present at all times). state.showProjectInfo
3873
- // is the explicit signal only the "Project Info" nav entry sets; any real
3874
- // active conversation still wins over it, so the many state.activeId call
3875
- // sites elsewhere don't each need to clear this flag themselves.
3876
- const showBrief = !!state.showProjectInfo && !hasConv;
3877
- wc.classList.toggle('mode-conv', !showBrief);
3878
- wc.classList.toggle('mode-brief', showBrief);
3879
- const briefNav = document.getElementById('tree-brief-nav');
3880
- if (briefNav) briefNav.classList.toggle('active', showBrief);
3932
+ if (typeof tf === 'undefined') return;
3933
+ tfApplyAreaView('projects', tfResolveAreaView('projects', activeConversation()));
3881
3934
  }
3882
3935
 
3883
3936
  function formatMicroAuditKind(event) {
@@ -6181,6 +6234,7 @@ function resizeComposerTextarea() {
6181
6234
  }
6182
6235
 
6183
6236
  function syncSendButton() {
6237
+ syncVoiceInput();
6184
6238
  syncReviewHint();
6185
6239
  resizeComposerTextarea();
6186
6240
  const conv = activeConversation();
@@ -6293,6 +6347,7 @@ function syncManagedDelegationAccess(conv = activeConversation()) {
6293
6347
  }
6294
6348
  }
6295
6349
  if (managed) closeAllCoachDropdowns();
6350
+ syncVoiceInput();
6296
6351
  syncComposerPlaceholder(conv);
6297
6352
  syncComposerStopButton(conv);
6298
6353
  }
@@ -7981,8 +8036,8 @@ function parseCpSearch(text) {
7981
8036
  function focusComposerForDelegate(opts) {
7982
8037
  state.cpActionContext = (opts && opts.actionContext) || managementActionContext(state.pendingNextJobArea || undefined);
7983
8038
  state.activeId = null;
7984
- persistActiveConversationSelection(null, { projectPath: state.projectPath || '' });
7985
- state.showProjectInfo = false;
8039
+ persistActiveConversationSelection(null, tf.area === 'projects' ? { projectPath: state.projectPath || '' } : { scope: tf.area });
8040
+ tf.areaSelection[tfAreaSelectionKey(tf.area)] = 'delegate';
7986
8041
  if (tf.area === 'company' || tf.area === 'manager') {
7987
8042
  tf.areaSelection[tf.area] = 'delegate';
7988
8043
  if (tf.area === 'company') tfRenderCompany();
@@ -8942,7 +8997,7 @@ async function startRun(job, instructions, employeeId, preassignedConvId, invoke
8942
8997
  };
8943
8998
  upsertConversation(conv);
8944
8999
  state.activeId = conv.id;
8945
- if (context.area === 'company' || context.area === 'manager') tf.areaSelection[context.area] = conv.id;
9000
+ tf.areaSelection[tfAreaSelectionKey(context.area)] = conv.id;
8946
9001
  persistActiveConversationSelection(conv.id);
8947
9002
  // Issue #539: make Cmd/Ctrl+Shift+R available as soon as the run is queued,
8948
9003
  // not only after the server responds with the run id.
@@ -9455,23 +9510,13 @@ function switchToConversation(id) {
9455
9510
  tfShowArea(targetArea);
9456
9511
  }
9457
9512
  state.activeId = id;
9513
+ tf.areaSelection[tfAreaSelectionKey(targetArea)] = id;
9458
9514
  persistActiveConversationSelection(id);
9459
9515
  // Issue #1065: a find session belongs to one thread. srchOpenResult re-opens it with the search
9460
9516
  // query immediately after this call, which is what lands the manager on the passage (R45).
9461
9517
  if (typeof convFindClose === 'function') convFindClose();
9462
- // For Company/Manager: move the shared .page into this area's conv-host and
9463
- // switch from the info view to the full conversation surface.
9464
- if (targetArea === 'company' || targetArea === 'manager') {
9465
- tfEnsurePageInArea(targetArea);
9466
- const host = document.getElementById(targetArea + '-conv-host');
9467
- const info = document.getElementById(targetArea + '-info-view');
9468
- if (host) host.hidden = false;
9469
- if (info) info.hidden = true;
9470
- // #1397: an area selection represents every explicit destination, not only
9471
- // the Info view. Poll-driven renders must preserve this conversation until
9472
- // the user chooses another destination or the conversation disappears.
9473
- if (tf.areaSelection) tf.areaSelection[targetArea] = id;
9474
- }
9518
+ tfEnsurePageInArea(targetArea);
9519
+ tfApplyAreaView(targetArea, tfResolveAreaView(targetArea, conv));
9475
9520
  renderRail();
9476
9521
  renderActive();
9477
9522
  hydrateConversationBody(conv ? convBucketKey(conv) : state.projectPath, id).catch((error) =>
@@ -10156,7 +10201,7 @@ function wireEvents() {
10156
10201
  // just defocuses the current conversation and focuses the always-present
10157
10202
  // composer, the same way clicking an employee starts/focuses a conversation.
10158
10203
  els['new-conv-btn'].addEventListener('click', () => {
10159
- state.showProjectInfo = false; // Issue #1657: return to the composer, not the Brief page.
10204
+ tf.areaSelection[tfAreaSelectionKey(tf.area)] = 'delegate';
10160
10205
  // renderActive() re-applies the workspace mode unconditionally (needed
10161
10206
  // when coming from the Project Info page, since activeId is already
10162
10207
  // null there too and focusComposerForDelegate only re-renders when
@@ -10292,6 +10337,7 @@ function wireEvents() {
10292
10337
  }
10293
10338
  });
10294
10339
  }
10340
+ els['voice-input-btn'].addEventListener('click', startVoiceInput);
10295
10341
  els['coach-text'].addEventListener('input', () => {
10296
10342
  syncSendButton();
10297
10343
  // Issue #1657 R2: drive the inline suggestion list while idle (no
@@ -11012,10 +11058,7 @@ const SRCH_MIN_QUERY_LENGTH = 2;
11012
11058
  const SRCH_DEBOUNCE_MS = 150;
11013
11059
  // Every element search hides to show results. Their pre-search hidden flags are captured verbatim
11014
11060
  // so clearing restores the exact surface state, whatever it was (R6).
11015
- const SRCH_HIDEABLE_IDS = [
11016
- 'hub-conv-page', 'proj-overview-page', 'proj-kanban',
11017
- 'manager-info-view', 'manager-conv-host', 'company-info-view', 'company-conv-host',
11018
- ];
11061
+ const SRCH_HIDEABLE_IDS = ['project', 'all', 'manager', 'company'].flatMap(srchHiddenForScope);
11019
11062
  const SRCH_PANEL_IDS = ['project-results', 'overview-results', 'manager-results', 'company-results'];
11020
11063
 
11021
11064
  const srch = {
@@ -11053,7 +11096,7 @@ function srchHiddenForScope(scope) {
11053
11096
  if (scope === 'company') return ['company-info-view', 'company-conv-host'];
11054
11097
  if (scope === 'manager') return ['manager-info-view', 'manager-conv-host'];
11055
11098
  if (scope === 'all') return ['proj-overview-page', 'proj-kanban'];
11056
- return ['hub-conv-page'];
11099
+ return ['proj-context-top', 'hub-conv-page'];
11057
11100
  }
11058
11101
 
11059
11102
  function srchSyncChip() {
@@ -11134,6 +11177,10 @@ function srchHidePanel() {
11134
11177
  srch.surface = null;
11135
11178
  }
11136
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);
11137
11184
  }
11138
11185
 
11139
11186
  function srchClear() {
@@ -12255,75 +12302,83 @@ async function tfSelectProjectView(view, projectId) {
12255
12302
  }
12256
12303
 
12257
12304
  function tfStoredTreeWidth() {
12258
- 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; }
12259
12308
  const parsed = Number(raw);
12260
- if (!Number.isFinite(parsed)) return TREE_WIDTH_DEFAULT;
12309
+ if (raw === null || raw.trim() === '' || !Number.isFinite(parsed)) return TREE_WIDTH_DEFAULT;
12261
12310
  return Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(parsed)));
12262
12311
  }
12263
12312
 
12264
12313
  function tfApplyTreeNavState() {
12265
- const workspace = document.getElementById('proj-workspace');
12266
- const collapseBtn = document.getElementById('tree-collapse-btn');
12267
- if (!workspace) return;
12268
- const collapsed = window.localStorage.getItem(STORAGE_KEY_TREE_COLLAPSED) === '1';
12269
- workspace.style.setProperty('--tree-sidebar-width', tfStoredTreeWidth() + 'px');
12270
- workspace.classList.toggle('tree-collapsed', collapsed);
12271
- if (collapseBtn) {
12272
- const label = collapsed ? 'Expand project navigation' : 'Collapse project navigation';
12273
- collapseBtn.setAttribute('aria-label', label);
12274
- collapseBtn.title = label;
12275
- }
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
+ });
12276
12327
  }
12277
12328
 
12278
12329
  function tfSetTreeCollapsed(collapsed) {
12279
- if (collapsed) window.localStorage.setItem(STORAGE_KEY_TREE_COLLAPSED, '1');
12280
- 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. */ }
12281
12335
  tfApplyTreeNavState();
12282
12336
  }
12283
12337
 
12284
12338
  function tfWireTreeNavControls() {
12285
- const workspace = document.getElementById('proj-workspace');
12286
- const collapseBtn = document.getElementById('tree-collapse-btn');
12287
- const resizer = document.getElementById('tree-resizer');
12288
- if (!workspace) return;
12289
12339
  tfApplyTreeNavState();
12290
- if (collapseBtn && !collapseBtn.dataset.wired) {
12291
- collapseBtn.dataset.wired = '1';
12292
- collapseBtn.addEventListener('click', () => {
12293
- tfSetTreeCollapsed(!workspace.classList.contains('tree-collapsed'));
12294
- });
12295
- }
12296
- if (resizer && !resizer.dataset.wired) {
12297
- resizer.dataset.wired = '1';
12298
- let startX = 0;
12299
- let startWidth = TREE_WIDTH_DEFAULT;
12300
- let dragging = false;
12301
- const stopDrag = () => {
12302
- if (!dragging) return;
12303
- dragging = false;
12304
- workspace.classList.remove('tree-resizing');
12305
- document.removeEventListener('pointermove', onMove);
12306
- document.removeEventListener('pointerup', stopDrag);
12307
- document.removeEventListener('pointercancel', stopDrag);
12308
- };
12309
- const onMove = (event) => {
12310
- if (!dragging) return;
12311
- const nextWidth = Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, startWidth + event.clientX - startX));
12312
- window.localStorage.setItem(STORAGE_KEY_TREE_WIDTH, String(Math.round(nextWidth)));
12313
- workspace.style.setProperty('--tree-sidebar-width', Math.round(nextWidth) + 'px');
12314
- };
12315
- resizer.addEventListener('pointerdown', (event) => {
12316
- event.preventDefault();
12317
- tfSetTreeCollapsed(false);
12318
- dragging = true;
12319
- startX = event.clientX;
12320
- startWidth = tfStoredTreeWidth();
12321
- workspace.classList.add('tree-resizing');
12322
- document.addEventListener('pointermove', onMove);
12323
- document.addEventListener('pointerup', stopDrag);
12324
- document.addEventListener('pointercancel', stopDrag);
12325
- });
12326
- }
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
+ });
12327
12382
  }
12328
12383
 
12329
12384
  // ---------------------------------------------------------------------------
@@ -12880,88 +12935,46 @@ function tfTreeSep() {
12880
12935
  return sep;
12881
12936
  }
12882
12937
  function tfRenderTree() {
12883
- const host = document.getElementById('tree-sidebar');
12938
+ renderScopeNavigation(document.getElementById('tree-sidebar'), 'projects');
12939
+ }
12940
+
12941
+ function renderScopeNavigation(host, area) {
12884
12942
  if (!host) return;
12885
- // #533 R5: remember which accordions are open so a status-driven re-render (e.g.
12886
- // marking a job done) only recolors dots — it must not collapse the manager's tree.
12887
- const openState = {};
12888
- host.querySelectorAll('details[data-acc-key]').forEach((d) => { openState[d.dataset.accKey] = d.open; });
12889
- host.innerHTML = '';
12890
- // #521: Project Info is the project's own page (brief + learnings, not stacked
12891
- // 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();
12892
12948
  const briefNav = document.createElement('button');
12893
- briefNav.id = 'tree-brief-nav';
12949
+ if (isProject) briefNav.id = 'tree-brief-nav';
12894
12950
  briefNav.type = 'button';
12895
12951
  briefNav.className = 'tree-brief-nav';
12896
- briefNav.innerHTML = '<span class="tbn-ico">📋</span> <span>Project Info</span>';
12897
- briefNav.addEventListener('click', () => {
12898
- state.activeId = null;
12899
- state.showProjectInfo = true; // Issue #1657: the one explicit trigger for the Brief page.
12900
- // #1565: was persistConversations() (debounced full-project PUT - reads/rewrites every
12901
- // conversation, freezing Electron's main process on large histories). Clearing the active
12902
- // selection is a body-safe activeId-only PATCH, same narrow path every other activeId
12903
- // change already uses.
12904
- if (typeof persistActiveConversationSelection === 'function') {
12905
- persistActiveConversationSelection(null, { projectPath: state.projectPath || '' });
12906
- }
12907
- if (typeof renderRail === 'function') renderRail();
12908
- renderActive();
12909
- tfRenderProjectContextTop();
12910
- tfApplyWorkspaceMode();
12911
- });
12952
+ briefNav.textContent = '📋 ' + (isProject ? 'Project' : area === 'company' ? 'Company' : 'Manager') + ' Info';
12953
+ briefNav.addEventListener('click', () => tfToggleAreaView(area, 'info'));
12912
12954
  host.appendChild(briefNav);
12913
12955
  host.appendChild(tfTreeSep());
12914
- // #693 R1: the standalone "Project Updates" accordion is retired. Its two
12915
- // lifecycle actions now live where their output lands — Run Project Onboarding
12916
- // in the Brief section and Sleep on learnings in the Learnings section
12917
- // (see tfRenderProjectContextTop). No separate tree section.
12918
- // The legacy standalone "+ New job" entry (#new-conv-btn → openModal → the
12919
- // #job-catalog/#next1/#instructions/#start chain) lives in the old `.rail`,
12920
- // which the shell suppresses (display:none). Relocate the live node to the
12921
- // top of the visible tree so the standalone conversation flow that the
12922
- // shipped #339/#347/#429 Hub tests depend on stays clickable. Moving the
12923
- // node preserves its cached `els` reference and wired click listener.
12924
- appendManagementActions(host, 'projects');
12925
- host.appendChild(tfTreeSep());
12926
- // #693 R1 (PR round 2): the per-employee "Runs" accordions (renderRail's
12927
- // conv-employee-group) ARE the single employees section — they list every
12928
- // project employee with that employee's runs nested under their accordion.
12929
- // Relocate that section into the tree; the redundant emp-hero list is retired.
12930
- const convList = (typeof els !== 'undefined' && els['conv-list']) || document.getElementById('conv-list');
12931
- const runsSection = convList && convList.closest('.rail-section--runs');
12932
- if (runsSection) {
12933
- runsSection.classList.add('tree-runs');
12934
- host.appendChild(runsSection);
12935
- }
12936
- // #693 R1 (PR round 2): the redundant emp-hero employee list was removed. The
12937
- // relocated Runs section above (renderRail's conv-employee-group) is now the
12938
- // single employees section — every project employee, with their runs nested
12939
- // under their own accordion, plus per-employee assign/hire-manager.
12940
-
12941
- // #521 R5/R6.4: the project brief and learnings moved OUT of the tree into the
12942
- // collapsible top sections (#proj-brief-acc / #proj-learnings-acc), rendered by
12943
- // tfRenderProjectContextTop(). The tree is now employees + jobs only.
12956
+ appendManagementActions(host, area);
12944
12957
  host.appendChild(tfTreeSep());
12945
-
12946
- // Issue #945: + New employee node (Design B in-place creation).
12947
- const newEmpNode = document.createElement('div');
12948
- newEmpNode.className = 'ipe-new';
12949
- newEmpNode.setAttribute('role', 'button');
12950
- newEmpNode.setAttribute('tabindex', '0');
12951
- newEmpNode.title = 'Create a custom employee for this project';
12952
- const newEmpIcon = document.createElement('span');
12953
- newEmpIcon.className = 'ipe-new-icon';
12954
- newEmpIcon.setAttribute('aria-hidden', 'true');
12955
- newEmpIcon.textContent = '+';
12956
- const newEmpLabel = document.createElement('span');
12957
- newEmpLabel.textContent = 'New employee';
12958
- newEmpNode.appendChild(newEmpIcon);
12959
- newEmpNode.appendChild(newEmpLabel);
12960
- newEmpNode.addEventListener('click', () => openInlineCreateEmployee());
12961
- newEmpNode.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openInlineCreateEmployee(); } });
12962
- 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
+ }
12963
12977
  host.appendChild(tfTreeSep());
12964
-
12965
12978
  // Dot legend.
12966
12979
  const legend = document.createElement('div');
12967
12980
  legend.className = 'tree-legend';
@@ -12986,10 +12999,9 @@ function tfRenderTree() {
12986
12999
  legend.appendChild(row);
12987
13000
  }
12988
13001
  host.appendChild(legend);
12989
- // #533 R5: restore the open/closed state captured before the rebuild.
12990
- host.querySelectorAll('details[data-acc-key]').forEach((d) => {
12991
- if (Object.prototype.hasOwnProperty.call(openState, d.dataset.accKey)) d.open = openState[d.dataset.accKey];
12992
- });
13002
+ const fallback = isProject ? activeConversation() : tfActiveScopedConv(area);
13003
+ briefNav.classList.toggle('active', tfResolveAreaView(area, fallback).info);
13004
+ tfReopenPendingRunDelete(host);
12993
13005
  }
12994
13006
 
12995
13007
  function tfSelectAssignmentConversation(assignment) {
@@ -13020,6 +13032,9 @@ function tfProjectOnboardingRun() {
13020
13032
  }
13021
13033
 
13022
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');
13023
13038
  // Issue #1198: which folder this project points at, always on screen.
13024
13039
  tfRenderProjectFolderRow();
13025
13040
  // Onboarding loop framing renders ABOVE the accordions (#proj-onboarding-banner)
@@ -14341,16 +14356,51 @@ function tfActiveMgrConv() {
14341
14356
  // conversations behind that destination, but it may not choose a different one.
14342
14357
  // A deleted or re-scoped conversation invalidates the selection and restores the
14343
14358
  // area's normal auto-home behavior.
14359
+ function tfAreaSelectionKey(area) {
14360
+ return area === 'projects' ? 'project:' + (tf.activeProjectId || state.projectPath || 'current') : area;
14361
+ }
14362
+
14344
14363
  function tfResolveAreaView(area, fallbackConv) {
14345
- const selected = tf.areaSelection && tf.areaSelection[area];
14364
+ const key = tfAreaSelectionKey(area);
14365
+ const selected = tf.areaSelection[key];
14346
14366
  if (selected === 'info') return { info: true, conversation: null };
14347
14367
  if (selected === 'delegate') return { info: false, conversation: null, delegate: true };
14348
14368
  if (selected) {
14349
14369
  const conv = findConversation(selected);
14350
- if (conv && convScope(conv) === area) return { info: false, conversation: conv };
14351
- 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');
14352
14402
  }
14353
- return { info: !fallbackConv, conversation: fallbackConv || null };
14403
+ tfApplyAreaView(area, view);
14354
14404
  }
14355
14405
 
14356
14406
  function tfAreaScopedConversations(area) {
@@ -14395,138 +14445,6 @@ function tfStartManagerPersonaJob(jobId) {
14395
14445
  }
14396
14446
 
14397
14447
 
14398
- // #1416: capture each employee group's open/closed state from the area rail (Manager or
14399
- // Company) before it is rebuilt, mirroring renderRail()'s preservedEmployeeGroupOpen() for
14400
- // the Projects rail. Without this, tfRenderManager()/tfRenderCompany()'s ~1-1.5s poll-driven
14401
- // rebuild wiped the DOM and re-created every group, so a manually collapsed group snapped
14402
- // back open on the next tick and there was no way to tell a no-run group from one the user
14403
- // deliberately expanded.
14404
- function captureAreaEmployeeGroupOpenState(rail) {
14405
- const openState = new Map();
14406
- if (!rail) return openState;
14407
- rail.querySelectorAll('details.conv-employee-group[data-employee-key]').forEach((details) => {
14408
- const key = details.dataset ? details.dataset.employeeKey : '';
14409
- if (key) openState.set(key, details.open);
14410
- });
14411
- return openState;
14412
- }
14413
-
14414
- // #702: render a hired persona as an employee group on the Manager rail — the SAME
14415
- // visual as the Projects rail employee groups (avatar + name + role + count + "+" that
14416
- // opens the job palette), with the persona's MANAGER-invoked runs listed under it.
14417
- // Keeps a persona looking like an employee, consistent across tabs.
14418
- function tfRenderAreaPersonaGroup(rail, persona, area, employeeGroupOpenState) {
14419
- const sample = { personaKey: persona.key };
14420
- const runs = tfAreaScopedConversations(area).filter(
14421
- (c) => c.personaKey === persona.key && conversationUiState(c) === 'complete'
14422
- );
14423
- const details = document.createElement('details');
14424
- details.className = 'conv-employee-group';
14425
- details.dataset.employeeKey = persona.key;
14426
- // Active runs now live in the "In Progress" section above; this accordion shows only
14427
- // completed runs so it defaults closed (user expands when they want history).
14428
- const defaultOpen = false;
14429
- details.open = employeeGroupOpenState && employeeGroupOpenState.has(persona.key)
14430
- ? employeeGroupOpenState.get(persona.key)
14431
- : defaultOpen;
14432
- const summary = document.createElement('summary');
14433
- summary.className = 'conv-employee-tab';
14434
- const avatar = buildConversationEmployeeAvatar(sample, 'conv-employee-avatar');
14435
- const copy = document.createElement('span'); copy.className = 'conv-employee-tab-copy';
14436
- const label = document.createElement('strong'); label.className = 'conv-employee-tab-label';
14437
- label.textContent = persona.displayName || persona.key;
14438
- const detail = document.createElement('small'); detail.className = 'conv-employee-tab-detail';
14439
- detail.textContent = persona.role || 'AI Employee';
14440
- copy.appendChild(label); copy.appendChild(detail);
14441
- const addBtn = document.createElement('button'); addBtn.type = 'button'; addBtn.className = 'conv-employee-add';
14442
- addBtn.textContent = '+';
14443
- addBtn.title = 'Launch a job for ' + (persona.displayName || persona.key);
14444
- addBtn.setAttribute('aria-label', 'Launch a job for ' + (persona.displayName || persona.key));
14445
- addBtn.addEventListener('click', (e) => {
14446
- e.preventDefault(); e.stopPropagation();
14447
- // Launch from this area: palette prefiltered to the employee. startRun stamps
14448
- // the active area on the conversation so it returns to the same rail.
14449
- 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) });
14450
- });
14451
- const count = document.createElement('span'); count.className = 'conv-employee-tab-count';
14452
- count.textContent = String(runs.length);
14453
- summary.appendChild(avatar); summary.appendChild(copy); summary.appendChild(addBtn); summary.appendChild(count);
14454
- details.appendChild(summary);
14455
- if (!runs.length) {
14456
- const listDiv = document.createElement('div'); listDiv.className = 'conv-employee-list';
14457
- const hint = document.createElement('div'); hint.className = 'eh-empty';
14458
- hint.textContent = 'No runs yet, + to launch a job.';
14459
- listDiv.appendChild(hint);
14460
- details.appendChild(listDiv);
14461
- } else {
14462
- details.appendChild(buildAreaGroupList(runs));
14463
- }
14464
- appendEmployeeGroupWithActions(rail, details);
14465
- }
14466
-
14467
- // #1397/#1398/#1606: shared employee rail for the Manager and Company tabs. Mirrors the
14468
- // Projects-tab layout: In Progress (active runs) → per-employee accordions (completed
14469
- // runs) → No FRAIM Employee group (completed null-persona runs). FRAIMworker is always
14470
- // present via tfAreaRailPersonas; named employees join when the area is their home or
14471
- // they have an actual run there.
14472
- function tfRenderAreaEmployeeRail(rail, area, employeeGroupOpenState) {
14473
- const allScopedConvs = tfAreaScopedConversations(area);
14474
- const activeConvs = allScopedConvs.filter((c) => conversationUiState(c) !== 'complete');
14475
- const doneNullPersonaConvs = allScopedConvs.filter(
14476
- (c) => conversationUiState(c) === 'complete' && !c.personaKey
14477
- );
14478
- // Section 1: In Progress (active runs from all personas)
14479
- const areaOpenFn = (key, defaultOpen) =>
14480
- employeeGroupOpenState && employeeGroupOpenState.has(key)
14481
- ? employeeGroupOpenState.get(key)
14482
- : defaultOpen;
14483
- const ipDetails = buildInProgressSection(activeConvs.length, areaOpenFn);
14484
- if (activeConvs.length > 0) {
14485
- ipDetails.appendChild(buildAreaGroupList(activeConvs, { showEmployeeName: true }));
14486
- } else {
14487
- const emptyWrap = document.createElement('div'); emptyWrap.className = 'conv-employee-list';
14488
- const ipEmpty = document.createElement('div'); ipEmpty.className = 'eh-empty';
14489
- ipEmpty.textContent = 'Nothing running. Use + to assign a job below.';
14490
- emptyWrap.appendChild(ipEmpty);
14491
- ipDetails.appendChild(emptyWrap);
14492
- }
14493
- rail.appendChild(ipDetails);
14494
- // Section 2: Per-employee accordions (completed runs, named personas)
14495
- const areaPersonas = tfAreaRailPersonas(area);
14496
- if (areaPersonas.length > 0) {
14497
- const head = document.createElement('div'); head.className = 'area-rail-head'; head.textContent = 'Your employees';
14498
- rail.appendChild(head);
14499
- for (const persona of areaPersonas) {
14500
- tfRenderAreaPersonaGroup(rail, persona, area, employeeGroupOpenState);
14501
- }
14502
- const pNote = document.createElement('div'); pNote.className = 'area-rail-note';
14503
- 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.";
14504
- rail.appendChild(pNote);
14505
- }
14506
- // Section 3: No FRAIM Employee group (completed null-persona runs)
14507
- if (doneNullPersonaConvs.length > 0) {
14508
- const wcDetails = document.createElement('details');
14509
- wcDetails.className = 'conv-employee-group conv-employee-group--adhoc';
14510
- wcDetails.dataset.employeeKey = NO_FRAIM_EMPLOYEE_GROUP_KEY;
14511
- wcDetails.open = employeeGroupOpenState && employeeGroupOpenState.has(NO_FRAIM_EMPLOYEE_GROUP_KEY)
14512
- ? employeeGroupOpenState.get(NO_FRAIM_EMPLOYEE_GROUP_KEY)
14513
- : true;
14514
- const wcSummary = document.createElement('summary');
14515
- wcSummary.className = 'conv-employee-tab';
14516
- const wcAvatar = buildNoFraimEmployeeAvatar('conv-employee-avatar');
14517
- const wcCopy = document.createElement('span'); wcCopy.className = 'conv-employee-tab-copy';
14518
- const wcLabel = document.createElement('strong'); wcLabel.className = 'conv-employee-tab-label';
14519
- wcLabel.textContent = NO_FRAIM_EMPLOYEE_LABEL;
14520
- wcCopy.appendChild(wcLabel);
14521
- const wcCount = document.createElement('span'); wcCount.className = 'conv-employee-tab-count';
14522
- wcCount.textContent = String(doneNullPersonaConvs.length);
14523
- wcSummary.appendChild(wcAvatar); wcSummary.appendChild(wcCopy); wcSummary.appendChild(wcCount);
14524
- wcDetails.appendChild(wcSummary);
14525
- wcDetails.appendChild(buildAreaGroupList(doneNullPersonaConvs));
14526
- rail.appendChild(wcDetails);
14527
- }
14528
- }
14529
-
14530
14448
  // Move the shared .page conversation panel into the specified area's workspace-conv host.
14531
14449
  // All .workspace-conv CSS rules (header/rail hidden, conv fills space) apply automatically
14532
14450
  // because the area-conv-host elements carry the workspace-conv class.
@@ -14543,22 +14461,19 @@ function tfEnsurePageInArea(area) {
14543
14461
 
14544
14462
  // Toggle a Company/Manager area between its info view (accordions) and conversation panel.
14545
14463
  function tfToggleAreaView(area, view) {
14546
- const host = document.getElementById(area + '-conv-host');
14547
- const info = document.getElementById(area + '-info-view');
14548
- if (view === 'info') {
14549
- if (host) host.hidden = true;
14550
- if (info) info.hidden = false;
14551
- tfEnsurePageInArea('projects');
14552
- 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();
14553
14471
  renderActive();
14554
- // #1397: a manual switch to the info view is a deliberate navigation choice, exactly
14555
- // like picking a specific conversation — record it as this area's explicit selection so
14556
- // the next poll-driven tfRenderManager()/tfRenderCompany() doesn't silently flip back to
14557
- // the run view just because a conversation is still running. Overwritten by
14558
- // tfStartOnboardingJob() when a genuinely new run starts in this area, or by the user
14559
- // opening a run directly (switchToConversation).
14560
- if (tf.areaSelection && (area === 'manager' || area === 'company')) tf.areaSelection[area] = 'info';
14472
+ tfRenderProjectContextTop();
14473
+ } else {
14474
+ tfRenderScopeView(area);
14561
14475
  }
14476
+ tfApplyAreaView(area, tfResolveAreaView(area, null));
14562
14477
  }
14563
14478
 
14564
14479
  function tfRenderCompany() {
@@ -14571,32 +14486,7 @@ function tfRenderCompany() {
14571
14486
  // closure-owned unsaved values. Save/Reset clears the guard below; the status
14572
14487
  // state keeps their feedback stable if the next poll then performs a render.
14573
14488
  if (!state.brandEditorDraftActive && typeof tfRenderBrandEditor === 'function') tfRenderBrandEditor();
14574
- const rail = document.getElementById('company-rail');
14575
- if (rail) {
14576
- // #1416: capture the current open/closed state before wiping the rail so the
14577
- // ~1.5s poll-driven rebuild below doesn't snap a manually collapsed group back open.
14578
- const employeeGroupOpenState = captureAreaEmployeeGroupOpenState(rail);
14579
- rail.innerHTML = '';
14580
- const orgConv = tfActiveOrgConv();
14581
- const infoBtn = document.createElement('button');
14582
- infoBtn.type = 'button';
14583
- infoBtn.className = 'area-rail-info-btn' + (tfResolveAreaView('company', orgConv).info ? ' info-active' : '');
14584
- infoBtn.textContent = '📋 Company Info';
14585
- infoBtn.addEventListener('click', () => tfToggleAreaView('company', 'info'));
14586
- rail.appendChild(infoBtn);
14587
- appendManagementActions(rail, 'company');
14588
- // Issue #1124: when a company-scoped org conv exists, add a visible rail entry so
14589
- // the user can see and navigate to it. Previously the rail had only the static info
14590
- // button and the conversation was unreachable from the left nav.
14591
- // #693 R1 (PR round 2): the "Company jobs" launcher list is retired. Its jobs
14592
- // now run from the section they populate — Run Organization Onboarding in
14593
- // "Context & rules", Synthesize company learnings in "Company learnings".
14594
- // #1398: FRAIMworker (and any hired employee with a company-scoped run, e.g. Aida)
14595
- // get the same employee-group presentation as the Manager rail — not just an orphan
14596
- // entry for the single "most active" org conv.
14597
- tfRenderAreaEmployeeRail(rail, 'company', employeeGroupOpenState);
14598
- tfReopenPendingRunDelete(rail);
14599
- }
14489
+ renderScopeNavigation(document.getElementById('company-rail'), 'company');
14600
14490
  const profile = document.getElementById('company-profile');
14601
14491
  const learn = document.getElementById('company-learnings');
14602
14492
  if (profile) {
@@ -14657,27 +14547,7 @@ function tfRenderCompany() {
14657
14547
  // #693 R1 (PR round 2): Company's learnings job is organizational-learning-synthesis, not sleep-on-learnings.
14658
14548
  tfEnsureAccAction('company-learn-acc', '🧠', 'Synthesize company learnings', () => tfStartOrgLearningSynthesis());
14659
14549
  tfMaybeRenderOrgPushBanner();
14660
- // Show the full coaching conversation panel when there is an active org conv;
14661
- // otherwise show the info view (accordions). This uses the shared .page element
14662
- // moved via tfEnsurePageInArea() so renderActive() renders the full panel.
14663
- const orgConv = tfActiveOrgConv();
14664
- const host = document.getElementById('company-conv-host');
14665
- const info = document.getElementById('company-info-view');
14666
- // #1397: resolve the area's one stable destination — an explicit conversation choice,
14667
- // an explicit "Company Info" choice, or (absent either) the org conv auto-home. Replaces
14668
- // the former Info-only tf.viewPinned special case with one navigation-state contract.
14669
- const companyView = tfResolveAreaView('company', orgConv);
14670
- if (tf.area === 'company' && !companyView.info && (companyView.conversation || companyView.delegate)) {
14671
- state.activeId = companyView.conversation ? companyView.conversation.id : null;
14672
- tfEnsurePageInArea('company');
14673
- if (host) host.hidden = false;
14674
- if (info) info.hidden = true;
14675
- renderActive();
14676
- } else {
14677
- if (host) host.hidden = true;
14678
- if (info) info.hidden = false;
14679
- if (tf.area === 'company') tfEnsurePageInArea('projects');
14680
- }
14550
+ tfRenderScopeView('company');
14681
14551
  }
14682
14552
 
14683
14553
  // #594 R3: show push banner in Company area after an org onboarding run completes
@@ -14739,32 +14609,7 @@ function tfMaybeRenderOrgPushBanner() {
14739
14609
 
14740
14610
  function tfRenderManager() {
14741
14611
  // #521 R6: manager-level job (manager-agreements) lives ONLY in this left rail.
14742
- const rail = document.getElementById('manager-rail');
14743
- if (rail) {
14744
- // #1416: capture the current open/closed state before wiping the rail so the
14745
- // ~1s poll-driven rebuild below doesn't snap a manually collapsed group back open.
14746
- const employeeGroupOpenState = captureAreaEmployeeGroupOpenState(rail);
14747
- rail.innerHTML = '';
14748
- const mgrConv = tfActiveMgrConv();
14749
- const infoBtn = document.createElement('button');
14750
- infoBtn.type = 'button';
14751
- infoBtn.className = 'area-rail-info-btn' + (tfResolveAreaView('manager', mgrConv).info ? ' info-active' : '');
14752
- infoBtn.textContent = '📋 Manager Info';
14753
- infoBtn.addEventListener('click', () => tfToggleAreaView('manager', 'info'));
14754
- rail.appendChild(infoBtn);
14755
- appendManagementActions(rail, 'manager');
14756
- // #693 R1 (PR round 2): the "Manager jobs" launcher list is retired.
14757
- // Manager agreements now runs from the "Context & rules" section it populates.
14758
- // Issue #702/#1398: render an EMPLOYEE GROUP for FRAIMworker plus every hired persona
14759
- // that is either home to this area (Ashley on Manager) or has an actual run here (e.g.
14760
- // Aida's manager-scoped runs) — the same avatar + name + runs + "+" presentation as the
14761
- // Projects rail. `mgrConv` (the area's own top-level run, e.g. manager-agreements) gets
14762
- // its own rail entry unless a group above already lists it.
14763
- tfRenderAreaEmployeeRail(rail, 'manager', employeeGroupOpenState);
14764
- // #824: the manager rail also attaches run-delete controls; re-attach an open
14765
- // confirm this innerHTML='' rebuild would otherwise drop.
14766
- tfReopenPendingRunDelete(rail);
14767
- }
14612
+ renderScopeNavigation(document.getElementById('manager-rail'), 'manager');
14768
14613
  const profile = document.getElementById('manager-profile');
14769
14614
  const learn = document.getElementById('manager-learnings');
14770
14615
  const reverse = document.getElementById('reverse-mentoring');
@@ -14856,25 +14701,7 @@ function tfRenderManager() {
14856
14701
  // Cancel/Save/Check button handlers) call renderConfiguredAgentsPanel() itself,
14857
14702
  // not this function, so they are unaffected and still render immediately.
14858
14703
  if (!state.configuredAgentEditingId) renderConfiguredAgentsPanel();
14859
- // Show the full coaching conversation panel when there is an active manager conv;
14860
- // otherwise show the info view (accordions). Uses the shared .page element.
14861
- const mgrConv = tfActiveMgrConv();
14862
- const mgrHost = document.getElementById('manager-conv-host');
14863
- const mgrInfo = document.getElementById('manager-info-view');
14864
- // #1397: resolve the area's one stable destination — see tfRenderCompany's companyView
14865
- // for the full rationale. Replaces the former Info-only tf.viewPinned special case.
14866
- const managerView = tfResolveAreaView('manager', mgrConv);
14867
- if (tf.area === 'manager' && !managerView.info && (managerView.conversation || managerView.delegate)) {
14868
- state.activeId = managerView.conversation ? managerView.conversation.id : null;
14869
- tfEnsurePageInArea('manager');
14870
- if (mgrHost) mgrHost.hidden = false;
14871
- if (mgrInfo) mgrInfo.hidden = true;
14872
- renderActive();
14873
- } else {
14874
- if (mgrHost) mgrHost.hidden = true;
14875
- if (mgrInfo) mgrInfo.hidden = false;
14876
- if (tf.area === 'manager') tfEnsurePageInArea('projects');
14877
- }
14704
+ tfRenderScopeView('manager');
14878
14705
  }
14879
14706
  // #533: Reverse mentoring now renders the real manager-coaching learnings via
14880
14707
  // tfRenderLearningsList(scope='reverse') — consistent with the other learning
@@ -16131,10 +15958,10 @@ function tfApplyOrgBrand(brand) {
16131
15958
  coEl.hidden = !show;
16132
15959
  }
16133
15960
  document.title = (state.orgBrand && state.orgBrand.name) ? state.orgBrand.name + ' Hub' : 'FRAIM AI Hub';
16134
- // R4.1: Company/Manager area eyebrows reflect the company name (the FRAIM
15961
+ // Scope Info eyebrows reflect the company name (the FRAIM
16135
15962
  // co-mark stays in the nav — co-branding, not white-label).
16136
15963
  var eyebrow = (state.orgBrand && state.orgBrand.name) ? state.orgBrand.name : 'FRAIM Hub';
16137
- ['company-info-view', 'manager-info-view'].forEach(function (id) {
15964
+ ['company-info-view', 'manager-info-view', 'proj-context-top'].forEach(function (id) {
16138
15965
  var hostEl = document.getElementById(id);
16139
15966
  var el = hostEl && hostEl.querySelector('.eyebrow');
16140
15967
  if (el) el.textContent = eyebrow;
@@ -16414,8 +16241,8 @@ async function tfStartOnboardingJob(jobId, message, targetArea, userContext) {
16414
16241
  // win over an earlier selection (Info view or a different conversation), otherwise the
16415
16242
  // just-started run (e.g. create-hub-configured-agent) would stay invisible behind a
16416
16243
  // stale manual choice. startRun() already set state.activeId to the new conversation.
16417
- if (tf.areaSelection && (targetArea === 'manager' || targetArea === 'company') && state.activeId) {
16418
- tf.areaSelection[targetArea] = state.activeId;
16244
+ if (tf.areaSelection && state.activeId) {
16245
+ tf.areaSelection[tfAreaSelectionKey(targetArea)] = state.activeId;
16419
16246
  }
16420
16247
  // After run creation refresh the area panel so the conversation becomes visible.
16421
16248
  if (targetArea === 'company') tfRenderCompany();