fraim 2.0.187 → 2.0.189

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.
@@ -10,6 +10,7 @@ const fs_1 = __importDefault(require("fs"));
10
10
  const server_1 = require("./server");
11
11
  const cert_store_1 = require("./cert-store");
12
12
  const office_sideload_1 = require("./office-sideload");
13
+ const remote_hub_gateway_1 = require("./remote-hub-gateway");
13
14
  // ---------------------------------------------------------------------------
14
15
  // State
15
16
  // ---------------------------------------------------------------------------
@@ -50,6 +51,17 @@ function applyUserDataOverride() {
50
51
  fs_1.default.mkdirSync(userDataDir, { recursive: true });
51
52
  electron_1.app.setPath('userData', userDataDir);
52
53
  }
54
+ function isTrustedInAppNavigation(targetUrl, hubUrl) {
55
+ try {
56
+ const parsed = new URL(targetUrl);
57
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
58
+ return false;
59
+ return parsed.origin === new URL(hubUrl).origin || parsed.origin === new URL((0, remote_hub_gateway_1.resolveFraimRemoteUrl)()).origin;
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
53
65
  // ---------------------------------------------------------------------------
54
66
  // Tray icon resolution — prefers bundled icon, falls back to a 1×1 empty image
55
67
  // so the app never crashes if assets aren't present.
@@ -204,7 +216,9 @@ async function createWindow(url) {
204
216
  mainWindow.on('closed', () => electron_1.nativeTheme.removeListener('updated', applyOverlay));
205
217
  }
206
218
  mainWindow.webContents.setWindowOpenHandler(({ url: targetUrl }) => {
207
- void electron_1.shell.openExternal(targetUrl);
219
+ if (isTrustedInAppNavigation(targetUrl, url)) {
220
+ void mainWindow?.loadURL(targetUrl);
221
+ }
208
222
  return { action: 'deny' };
209
223
  });
210
224
  mainWindow.once('ready-to-show', () => mainWindow?.show());
@@ -128,6 +128,17 @@ function buildHubPersonaHireUrl(personaKey, hireMode = 'job') {
128
128
  const params = new URLSearchParams({ persona: personaKey, mode: hireMode });
129
129
  return `/pricing?${params.toString()}`;
130
130
  }
131
+ function buildHostedAuthUrl(surface, redirectTo) {
132
+ const target = new URL('/auth/sign-in.html', (0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
133
+ target.searchParams.set('surface', surface);
134
+ target.searchParams.set('redirect_to', redirectTo);
135
+ return target.toString();
136
+ }
137
+ function buildHostedPathUrl(pathname, queryString) {
138
+ const target = new URL(pathname, (0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
139
+ target.search = queryString;
140
+ return target.toString();
141
+ }
131
142
  function buildHubPersonaAvatarUrl(personaKey) {
132
143
  return loadPersonaHiringModule()?.buildPersonaAvatarUrl(personaKey) ?? '';
133
144
  }
@@ -1407,6 +1418,7 @@ class AiHubServer {
1407
1418
  const activeRun = latest ? this.enrichRunForResponse(latest) : undefined;
1408
1419
  return {
1409
1420
  title: 'AI Hub',
1421
+ remoteBaseUrl: (0, remote_hub_gateway_1.resolveFraimRemoteUrl)(),
1410
1422
  project,
1411
1423
  preferences,
1412
1424
  categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath, catalogOptions),
@@ -1997,26 +2009,40 @@ class AiHubServer {
1997
2009
  // Issue #512: Serve the account and analytics pages from public/.
1998
2010
  // These live outside of /ai-hub so they need explicit routes.
1999
2011
  const publicDir = resolveAiHubPublicDir().replace(/[\\/]ai-hub$/, '');
2000
- this.app.get('/account', (_req, res) => {
2001
- const filePath = path_1.default.join(publicDir, 'account', 'index.html');
2002
- if (fs_1.default.existsSync(filePath)) {
2003
- res.sendFile(filePath);
2004
- }
2005
- else {
2006
- res.status(404).send('Account page not found.');
2007
- }
2008
- });
2009
- this.app.use('/account', express_1.default.static(path_1.default.join(publicDir, 'account')));
2010
- this.app.get('/analytics', (_req, res) => {
2011
- const filePath = path_1.default.join(publicDir, 'analytics', 'index.html');
2012
- if (fs_1.default.existsSync(filePath)) {
2013
- res.sendFile(filePath);
2014
- }
2015
- else {
2016
- res.status(404).send('Analytics page not found.');
2017
- }
2018
- });
2019
- this.app.use('/analytics', express_1.default.static(path_1.default.join(publicDir, 'analytics')));
2012
+ if (!this.dbService) {
2013
+ this.app.get(/^\/auth(\/.*)?$/, (req, res) => {
2014
+ const parsed = new URL(req.originalUrl, 'http://127.0.0.1');
2015
+ res.redirect(buildHostedPathUrl(parsed.pathname, parsed.search));
2016
+ });
2017
+ this.app.get(['/account', '/account/'], (_req, res) => {
2018
+ res.redirect(buildHostedAuthUrl('account', '/account/'));
2019
+ });
2020
+ this.app.get(['/analytics', '/analytics/'], (_req, res) => {
2021
+ res.redirect(buildHostedAuthUrl('analytics', '/analytics/'));
2022
+ });
2023
+ }
2024
+ else {
2025
+ this.app.get('/account', (_req, res) => {
2026
+ const filePath = path_1.default.join(publicDir, 'account', 'index.html');
2027
+ if (fs_1.default.existsSync(filePath)) {
2028
+ res.sendFile(filePath);
2029
+ }
2030
+ else {
2031
+ res.status(404).send('Account page not found.');
2032
+ }
2033
+ });
2034
+ this.app.use('/account', express_1.default.static(path_1.default.join(publicDir, 'account')));
2035
+ this.app.get('/analytics', (_req, res) => {
2036
+ const filePath = path_1.default.join(publicDir, 'analytics', 'index.html');
2037
+ if (fs_1.default.existsSync(filePath)) {
2038
+ res.sendFile(filePath);
2039
+ }
2040
+ else {
2041
+ res.status(404).send('Analytics page not found.');
2042
+ }
2043
+ });
2044
+ this.app.use('/analytics', express_1.default.static(path_1.default.join(publicDir, 'analytics')));
2045
+ }
2020
2046
  // Issue #478: Serve the PowerPoint task pane HTML and manifest.
2021
2047
  // Office JS appends query strings (?_host_Info=PowerPoint$Win32$...) to every
2022
2048
  // request, so we must strip them before resolving the file path. Use a custom
@@ -96,7 +96,7 @@ class AIMentor {
96
96
  const metadataPhases = Object.keys(workflow.metadata.phases || {});
97
97
  const initialPhase = workflow.metadata.initialPhase || metadataPhases[0] || Array.from(workflow.phases.keys())[0] || 'starting';
98
98
  const overview = workflow.overview.trim();
99
- return [
99
+ const lines = [
100
100
  overview,
101
101
  '',
102
102
  '---',
@@ -104,7 +104,16 @@ class AIMentor {
104
104
  '## Start Here',
105
105
  `- Initial phase: \`${initialPhase}\``,
106
106
  '- Call `seekMentoring` to load the instructions for this phase.'
107
- ].join('\n');
107
+ ];
108
+ // A job that produces reviewable repository changes must resolve its workspace
109
+ // before starting. The mode conditional (branch/worktree vs in place) lives in
110
+ // the skill and is evaluated by the agent from its own local settings.
111
+ if (workflow.metadata.producesRepoChanges === true) {
112
+ lines.push('', '## Set Up Your Workspace First', '- This job changes the repository. Before phase work, read and follow'
113
+ + ' `get_fraim_file({ path: "skills/engineering/set-up-workspace.md" })`'
114
+ + ' to work on the issue\'s branch when a repository is configured, or in place otherwise.');
115
+ }
116
+ return lines.join('\n');
108
117
  }
109
118
  async generateStartingMessage(workflow, phaseId, skipIncludes) {
110
119
  const entityType = 'Job';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.187",
3
+ "version": "2.0.189",
4
4
  "description": "FRAIM CLI - Framework for Rigor-based AI Management (alias for fraim-framework)",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -33,18 +33,18 @@
33
33
  <div class="am-email" id="am-email"></div>
34
34
  </div>
35
35
  </div>
36
- <a class="am-item" id="am-account" href="/account">
36
+ <button class="am-item" id="am-account" type="button">
37
37
  <span class="am-ico">👤</span>
38
38
  <div><div class="am-label">Account</div><div class="am-sub">API key, sessions, billing</div></div>
39
- </a>
39
+ </button>
40
40
  <button class="am-item" id="am-brain" type="button">
41
41
  <span class="am-ico">🧠</span>
42
42
  <div><div class="am-label">Brain</div><div class="am-sub">Everything your team knows</div></div>
43
43
  </button>
44
- <a class="am-item" id="am-analytics" href="/analytics">
44
+ <button class="am-item" id="am-analytics" type="button">
45
45
  <span class="am-ico">📊</span>
46
46
  <div><div class="am-label">Analytics</div><div class="am-sub">Usage, cost, and ROI</div></div>
47
- </a>
47
+ </button>
48
48
  <button class="am-item am-theme" id="am-theme-toggle" type="button" role="switch" aria-checked="false">
49
49
  <span class="am-ico" id="am-theme-ico">🌙</span>
50
50
  <div><div class="am-label">Dark mode</div><div class="am-sub" id="am-theme-sub">Off</div></div>
@@ -141,7 +141,21 @@
141
141
  </div>
142
142
  </section>
143
143
 
144
- <!-- Issue #512: Projects area wraps the existing single-surface Hub. -->
144
+ <section class="hub-area" id="area-connected" hidden>
145
+ <div class="connected-shell">
146
+ <div class="connected-toolbar">
147
+ <div class="connected-title-block">
148
+ <div class="connected-kicker" id="connected-kicker">Connected FRAIM</div>
149
+ <div class="connected-title" id="connected-title">Account</div>
150
+ </div>
151
+ <div class="connected-origin" id="connected-origin"></div>
152
+ <button class="connected-close" id="connected-close" type="button">Back</button>
153
+ </div>
154
+ <iframe id="connected-frame" class="connected-frame" title="FRAIM connected surface" allow="clipboard-read; clipboard-write"></iframe>
155
+ </div>
156
+ </section>
157
+
158
+ <!-- Issue #512: Projects area wraps the existing single-surface Hub. -->
145
159
  <section class="hub-area on" id="area-projects">
146
160
  <div class="proj-tabs" id="proj-tabs">
147
161
  <button class="ptab on" id="ptab-overview" type="button" data-view="overview">Overview</button>
@@ -45,9 +45,11 @@ const state = {
45
45
  selectedJob: null, // chosen in modal step 1
46
46
  selectedEmployeeId: null,
47
47
  selectedPersonaKey: null, // R4: null = "All employees"
48
- modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
49
- storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
50
- panelState: {}, // { [convId]: { coach?: boolean } }
48
+ modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
49
+ storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
50
+ remoteBaseUrl: null,
51
+ connectedReturnArea: 'projects',
52
+ panelState: {}, // { [convId]: { coach?: boolean } }
51
53
  wordContext: null, // WordContext pushed from taskpane.html via postMessage
52
54
  // Pending coaching job selected via a quick-coach button or template picker.
53
55
  // Sent as coachingJobId in the next continueRun call and then cleared.
@@ -153,10 +155,14 @@ function cacheBootstrapPayload(bootstrap, docUrl) {
153
155
  bootstrapCache.set(bootstrapCacheKey(bootstrap.project.path, docUrl), bootstrap);
154
156
  }
155
157
 
156
- function applyBootstrap(bootstrap, docUrl) {
157
- state.bootstrap = bootstrap;
158
- state.projectPath = bootstrap.project.path;
159
- state.selectedEmployeeId = state.selectedEmployeeId || bootstrap.preferences.employeeId;
158
+ function applyBootstrap(bootstrap, docUrl) {
159
+ state.bootstrap = bootstrap;
160
+ state.projectPath = bootstrap.project.path;
161
+ state.remoteBaseUrl = bootstrap.remoteBaseUrl || null;
162
+ if (bootstrap.preferences && bootstrap.preferences.apiKey) {
163
+ state.storedApiKey = bootstrap.preferences.apiKey;
164
+ }
165
+ state.selectedEmployeeId = state.selectedEmployeeId || bootstrap.preferences.employeeId;
160
166
  // Restore persona selection persisted on the server side.
161
167
  if (bootstrap.preferences && bootstrap.preferences.personaKey !== undefined) {
162
168
  state.selectedPersonaKey = bootstrap.preferences.personaKey;
@@ -1014,6 +1020,7 @@ function renderRail() {
1014
1020
  // assigned — even before they have a run, so you can assign/delegate to them.
1015
1021
  const tfProjId = (typeof tf !== 'undefined' && tf) ? tf.activeProjectId : null;
1016
1022
  const managerTeamKeys = (state.bootstrap?.managerTeam || []).map((e) => e.personaKey).filter(Boolean);
1023
+ const managerTeamKeySet = new Set(managerTeamKeys);
1017
1024
  // merge: master resolves the tree's employees via tfProjectTeamKeys() (hired personas),
1018
1025
  // not project.team; align on it so every hired employee appears in the single section.
1019
1026
  const projectEmployeeKeys = new Set([
@@ -1024,7 +1031,8 @@ function renderRail() {
1024
1031
  for (const key of projectEmployeeKeys) {
1025
1032
  if (groups.has(key)) continue;
1026
1033
  const p = typeof tfPersonaByKey === 'function' ? tfPersonaByKey(key) : null;
1027
- if (p && p.status !== 'hired') continue; // reconcile against real entitlements (#538 follow-up)
1034
+ const availableToManager = !p || p.status === 'hired' || (p.seatCount || 0) > 0 || managerTeamKeySet.has(key);
1035
+ if (!availableToManager) continue;
1028
1036
  groups.set(key, {
1029
1037
  key,
1030
1038
  label: p ? p.displayName : key,
@@ -6203,7 +6211,7 @@ const STORAGE_KEY_ASSIGNMENTS_512 = 'fraim.aiHub.assignments.v1';
6203
6211
 
6204
6212
  const tf = {
6205
6213
  enabled: false,
6206
- area: 'projects', // company | manager | projects | brain
6214
+ area: 'projects', // company | manager | projects | brain | connected
6207
6215
  projectView: 'overview', // overview | workspace
6208
6216
  activeProjectId: null,
6209
6217
  projects: [], // [{ id, name, intent, brief }] — roster derived from hired personas, not stored
@@ -9234,16 +9242,107 @@ function tfToggleAccountMenu(e) {
9234
9242
  const menu = document.getElementById('account-menu');
9235
9243
  if (menu) menu.classList.toggle('open');
9236
9244
  }
9237
- function tfCloseAccountMenu() {
9238
- const menu = document.getElementById('account-menu');
9239
- if (menu) menu.classList.remove('open');
9240
- }
9241
-
9242
- // ---------------------------------------------------------------------------
9243
- // Theme (#700): explicit light/dark with an in-app toggle. The <head> bootstrap
9245
+ function tfCloseAccountMenu() {
9246
+ const menu = document.getElementById('account-menu');
9247
+ if (menu) menu.classList.remove('open');
9248
+ }
9249
+
9250
+ const CONNECTED_SURFACES = {
9251
+ account: { title: 'Account', path: '/account/' },
9252
+ analytics: { title: 'Analytics', path: '/analytics/' },
9253
+ brain: { title: 'Brain', path: '/fraim-brain.html', redirectPath: '/fraim-brain' },
9254
+ };
9255
+
9256
+ function tfConnectedRemoteBase() {
9257
+ const fallback = window.location.origin;
9258
+ const raw = state.remoteBaseUrl || (state.bootstrap && state.bootstrap.remoteBaseUrl) || fallback;
9259
+ try {
9260
+ return new URL(raw, fallback).toString();
9261
+ } catch {
9262
+ return fallback;
9263
+ }
9264
+ }
9265
+
9266
+ function tfConnectedApiKey() {
9267
+ return state.storedApiKey || (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.apiKey) || '';
9268
+ }
9269
+
9270
+ function tfConnectedSurfaceUrl(surface) {
9271
+ const cfg = CONNECTED_SURFACES[surface];
9272
+ const remoteBase = tfConnectedRemoteBase();
9273
+ if (!cfg) return remoteBase;
9274
+ const apiKey = tfConnectedApiKey();
9275
+ if (apiKey) {
9276
+ const target = new URL(cfg.path, remoteBase);
9277
+ target.searchParams.set('api-key', apiKey);
9278
+ return target.toString();
9279
+ }
9280
+ const signIn = new URL('/auth/sign-in.html', remoteBase);
9281
+ signIn.searchParams.set('surface', surface);
9282
+ signIn.searchParams.set('redirect_to', cfg.redirectPath || cfg.path);
9283
+ return signIn.toString();
9284
+ }
9285
+
9286
+ function tfOpenConnectedSurface(surface) {
9287
+ const cfg = CONNECTED_SURFACES[surface];
9288
+ if (!cfg) return;
9289
+ tfCloseAccountMenu();
9290
+ state.connectedReturnArea = (tf.area && tf.area !== 'connected') ? tf.area : 'projects';
9291
+ const url = tfConnectedSurfaceUrl(surface);
9292
+ const title = document.getElementById('connected-title');
9293
+ const origin = document.getElementById('connected-origin');
9294
+ const frame = document.getElementById('connected-frame');
9295
+ if (title) title.textContent = cfg.title;
9296
+ if (origin) {
9297
+ try { origin.textContent = new URL(url).origin; }
9298
+ catch { origin.textContent = ''; }
9299
+ }
9300
+ if (frame) {
9301
+ frame.title = `FRAIM ${cfg.title}`;
9302
+ frame.src = url;
9303
+ }
9304
+ tfShowArea('connected');
9305
+ }
9306
+
9307
+ function tfCloseConnectedSurface() {
9308
+ const frame = document.getElementById('connected-frame');
9309
+ if (frame) frame.removeAttribute('src');
9310
+ const returnArea = state.connectedReturnArea && state.connectedReturnArea !== 'connected'
9311
+ ? state.connectedReturnArea
9312
+ : 'projects';
9313
+ tfShowArea(returnArea);
9314
+ }
9315
+
9316
+ async function tfHandleConnectedAuthMessage(event) {
9317
+ const frame = document.getElementById('connected-frame');
9318
+ if (frame && frame.contentWindow && event.source !== frame.contentWindow) return;
9319
+ let remoteOrigin = '';
9320
+ try { remoteOrigin = new URL(tfConnectedRemoteBase()).origin; }
9321
+ catch { return; }
9322
+ if (event.origin !== remoteOrigin) return;
9323
+ const msg = event.data || {};
9324
+ if (!msg || msg.type !== 'fraim-auth-api-key' || typeof msg.apiKey !== 'string' || !msg.apiKey) return;
9325
+ if (msg.apiKey === state.storedApiKey) return;
9326
+ state.storedApiKey = msg.apiKey;
9327
+ if (state.bootstrap && state.bootstrap.preferences) {
9328
+ state.bootstrap.preferences.apiKey = msg.apiKey;
9329
+ }
9330
+ try {
9331
+ await requestJson('/api/ai-hub/api-key', {
9332
+ method: 'POST',
9333
+ headers: { 'Content-Type': 'application/json' },
9334
+ body: JSON.stringify({ apiKey: msg.apiKey }),
9335
+ });
9336
+ } catch (error) {
9337
+ console.warn('[ai-hub] Could not persist connected auth key:', error);
9338
+ }
9339
+ }
9340
+
9341
+ // ---------------------------------------------------------------------------
9342
+ // Theme (#700): explicit light/dark with an in-app toggle. The <head> bootstrap
9244
9343
  // sets [data-theme] pre-paint (no flash) from localStorage['fraim-theme'] or the
9245
9344
  // OS preference; here we reflect it in the toggle control and persist changes.
9246
- // Same-origin key sharing keeps /account and /analytics in sync on their next load.
9345
+ // Embedded connected surfaces receive live theme updates via postMessage.
9247
9346
  // ---------------------------------------------------------------------------
9248
9347
  function tfCurrentTheme() {
9249
9348
  return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
@@ -9263,13 +9362,18 @@ function tfApplyTheme(theme, persist) {
9263
9362
  document.documentElement.setAttribute('data-theme', t);
9264
9363
  if (persist) { try { localStorage.setItem('fraim-theme', t); } catch (e) {} }
9265
9364
  tfSyncThemeToggle(t);
9266
- // Keep any same-origin embedded surface (e.g. an analytics iframe) in sync live.
9267
- try {
9268
- document.querySelectorAll('iframe').forEach((f) => {
9269
- if (f.contentWindow) f.contentWindow.postMessage({ type: 'fraim-theme-change', theme: t }, location.origin);
9270
- });
9271
- } catch (e) {}
9272
- }
9365
+ // Keep embedded surfaces in sync live, including the connected main-server frame.
9366
+ try {
9367
+ document.querySelectorAll('iframe').forEach((f) => {
9368
+ if (!f.contentWindow) return;
9369
+ let targetOrigin = location.origin;
9370
+ try {
9371
+ if (f.src) targetOrigin = new URL(f.src, location.href).origin;
9372
+ } catch (e) {}
9373
+ f.contentWindow.postMessage({ type: 'fraim-theme-change', theme: t }, targetOrigin);
9374
+ });
9375
+ } catch (e) {}
9376
+ }
9273
9377
  function tfToggleTheme(e) {
9274
9378
  if (e) e.stopPropagation();
9275
9379
  tfApplyTheme(tfCurrentTheme() === 'dark' ? 'light' : 'dark', true);
@@ -9995,13 +10099,31 @@ function tfWireShell() {
9995
10099
  for (const tab of document.querySelectorAll('.hub-tab')) {
9996
10100
  tab.addEventListener('click', () => tfShowArea(tab.dataset.area));
9997
10101
  }
9998
- const avatar = document.getElementById('avatar-btn');
9999
- if (avatar) avatar.addEventListener('click', tfToggleAccountMenu);
10000
- tfWireThemeToggle();
10001
- const brainItem = document.getElementById('am-brain');
10002
- if (brainItem) brainItem.addEventListener('click', () => { tfCloseAccountMenu(); tfShowArea('brain'); });
10003
- const signout = document.getElementById('am-signout');
10004
- if (signout) signout.addEventListener('click', () => { window.location.href = '/auth/sign-in.html'; });
10102
+ const avatar = document.getElementById('avatar-btn');
10103
+ if (avatar) avatar.addEventListener('click', tfToggleAccountMenu);
10104
+ tfWireThemeToggle();
10105
+ const accountItem = document.getElementById('am-account');
10106
+ if (accountItem) accountItem.addEventListener('click', (e) => {
10107
+ e.preventDefault();
10108
+ tfOpenConnectedSurface('account');
10109
+ });
10110
+ const brainItem = document.getElementById('am-brain');
10111
+ if (brainItem) brainItem.addEventListener('click', () => {
10112
+ tfOpenConnectedSurface('brain');
10113
+ });
10114
+ const analyticsItem = document.getElementById('am-analytics');
10115
+ if (analyticsItem) analyticsItem.addEventListener('click', (e) => {
10116
+ e.preventDefault();
10117
+ tfOpenConnectedSurface('analytics');
10118
+ });
10119
+ const connectedClose = document.getElementById('connected-close');
10120
+ if (connectedClose) connectedClose.addEventListener('click', tfCloseConnectedSurface);
10121
+ if (!tf.connectedAuthMessageWired) {
10122
+ window.addEventListener('message', tfHandleConnectedAuthMessage);
10123
+ tf.connectedAuthMessageWired = true;
10124
+ }
10125
+ const signout = document.getElementById('am-signout');
10126
+ if (signout) signout.addEventListener('click', () => { window.location.href = '/auth/sign-in.html'; });
10005
10127
  document.addEventListener('click', (e) => {
10006
10128
  const menu = document.getElementById('account-menu');
10007
10129
  if (menu && menu.classList.contains('open') && !(e.target.closest && e.target.closest('.nav-right'))) {
@@ -10109,10 +10231,15 @@ function tfWireShell() {
10109
10231
  ['assign-job-modal', tfCloseAssignJob],
10110
10232
  ['np-modal', tfCloseNewProject],
10111
10233
  ];
10112
- document.addEventListener('keydown', (e) => {
10113
- if (e.key !== 'Escape') return;
10114
- for (const [id, fn] of escClosers) {
10115
- const el = document.getElementById(id);
10234
+ document.addEventListener('keydown', (e) => {
10235
+ if (e.key !== 'Escape') return;
10236
+ if (tf.area === 'connected') {
10237
+ e.stopPropagation();
10238
+ tfCloseConnectedSurface();
10239
+ return;
10240
+ }
10241
+ for (const [id, fn] of escClosers) {
10242
+ const el = document.getElementById(id);
10116
10243
  if (el && !el.hidden) { e.stopPropagation(); fn(); return; }
10117
10244
  }
10118
10245
  });
@@ -2752,7 +2752,7 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
2752
2752
  .am-av { width: 32px; height: 32px; border-radius: 50%; background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; flex-shrink: 0; }
2753
2753
  .am-name { font-size: 13px; font-weight: 700; }
2754
2754
  .am-email { font-size: 11px; color: var(--muted); }
2755
- .am-item { display: flex; align-items: flex-start; gap: 10px; padding: 9px 16px; cursor: pointer; font-size: 13px; }
2755
+ .am-item { display: flex; align-items: flex-start; gap: 10px; width: 100%; padding: 9px 16px; cursor: pointer; font-size: 13px; color: var(--text); text-align: left; text-decoration: none; border: 0; background: none; }
2756
2756
  .am-item:hover { background: var(--bg); }
2757
2757
  .am-ico { font-size: 14px; width: 18px; text-align: center; flex-shrink: 0; margin-top: 1px; }
2758
2758
  .am-label { font-weight: 600; }
@@ -2768,12 +2768,75 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
2768
2768
  .am-theme[aria-checked="true"] .am-switch-knob { transform: translateX(16px); }
2769
2769
 
2770
2770
  /* Hub areas */
2771
- .hub-area { display: none; flex: 1; overflow: hidden; }
2772
- .hub-area.on { display: flex; flex-direction: column; }
2773
- .hub-area-page { max-width: 860px; margin: 0 auto; padding: 28px 24px 48px; overflow-y: auto; flex: 1; /* authoritative rule — see also Round-2 override below which is now removed */ }
2774
-
2775
- /* #521 R6: Company/Manager use the same rail + main shell as the project workspace. */
2776
- .area-shell { display: flex; flex: 1; min-height: 0; }
2771
+ .hub-area { display: none; flex: 1; overflow: hidden; }
2772
+ .hub-area.on { display: flex; flex-direction: column; }
2773
+ .hub-area-page { max-width: 860px; margin: 0 auto; padding: 28px 24px 48px; overflow-y: auto; flex: 1; /* authoritative rule — see also Round-2 override below which is now removed */ }
2774
+
2775
+ .connected-shell {
2776
+ display: flex;
2777
+ flex: 1;
2778
+ min-height: 0;
2779
+ flex-direction: column;
2780
+ background: var(--bg);
2781
+ }
2782
+ .connected-toolbar {
2783
+ display: flex;
2784
+ align-items: center;
2785
+ gap: 14px;
2786
+ min-height: 52px;
2787
+ padding: 8px 16px;
2788
+ background: var(--surface);
2789
+ border-bottom: 1px solid var(--line);
2790
+ flex-shrink: 0;
2791
+ }
2792
+ .connected-title-block {
2793
+ min-width: 0;
2794
+ display: flex;
2795
+ flex-direction: column;
2796
+ gap: 1px;
2797
+ }
2798
+ .connected-kicker {
2799
+ font-size: 10px;
2800
+ font-weight: 700;
2801
+ letter-spacing: .04em;
2802
+ text-transform: uppercase;
2803
+ color: var(--muted);
2804
+ }
2805
+ .connected-title {
2806
+ font-size: 14px;
2807
+ font-weight: 700;
2808
+ color: var(--text);
2809
+ }
2810
+ .connected-origin {
2811
+ min-width: 0;
2812
+ flex: 1;
2813
+ color: var(--muted);
2814
+ font-size: 12px;
2815
+ overflow: hidden;
2816
+ text-overflow: ellipsis;
2817
+ white-space: nowrap;
2818
+ }
2819
+ .connected-close {
2820
+ border: 1px solid var(--line);
2821
+ background: var(--surface);
2822
+ color: var(--text);
2823
+ border-radius: 8px;
2824
+ padding: 6px 12px;
2825
+ font-size: 13px;
2826
+ font-weight: 600;
2827
+ flex-shrink: 0;
2828
+ }
2829
+ .connected-close:hover { background: var(--bg); }
2830
+ .connected-frame {
2831
+ flex: 1;
2832
+ min-height: 0;
2833
+ width: 100%;
2834
+ border: 0;
2835
+ background: var(--surface);
2836
+ }
2837
+
2838
+ /* #521 R6: Company/Manager use the same rail + main shell as the project workspace. */
2839
+ .area-shell { display: flex; flex: 1; min-height: 0; }
2777
2840
  .area-rail { width: 244px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--line); overflow-y: auto; padding: 6px 0; }
2778
2841
  .area-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
2779
2842
  .area-rail-head { font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 14px 16px 6px; }