fraim-framework 2.0.193 → 2.0.194

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.
@@ -23,6 +23,7 @@ const child_process_1 = require("child_process");
23
23
  const fs_1 = __importDefault(require("fs"));
24
24
  const os_1 = __importDefault(require("os"));
25
25
  const path_1 = __importDefault(require("path"));
26
+ const manager_turns_1 = require("./manager-turns");
26
27
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
27
28
  const mcp_config_generator_1 = require("../cli/setup/mcp-config-generator");
28
29
  const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
@@ -702,11 +703,11 @@ function transformHeadlessFraimMessage(message, kind) {
702
703
  if (!parsed)
703
704
  return message;
704
705
  if (!parsed.jobId) {
706
+ // Issue #732: single-source the same-job continue wording via
707
+ // buildSameJobContinueMessage so the server (prepareContinueMessage) and
708
+ // this headless transform stay in lock-step.
705
709
  if (kind === 'continue') {
706
- if (parsed.remainder) {
707
- return `Continue the active FRAIM job with this manager coaching:\n\n${parsed.remainder}`;
708
- }
709
- return 'Continue the active FRAIM job using the current session context.';
710
+ return (0, manager_turns_1.buildSameJobContinueMessage)(parsed.remainder);
710
711
  }
711
712
  return message;
712
713
  }
@@ -1351,6 +1352,10 @@ class FakeHostRuntime {
1351
1352
  { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Test double employee.', supportsRaw: true },
1352
1353
  { id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Test double agent tool.', supportsRaw: true },
1353
1354
  ];
1355
+ // Remembered across turns like a resumed agent session: the job label from the
1356
+ // start turn. Issue #732 — a same-job continue no longer carries a /fraim <job>
1357
+ // invocation, so the fake falls back to this to stay job-aware on continue.
1358
+ this.lastJobLabel = null;
1354
1359
  }
1355
1360
  detectEmployees() {
1356
1361
  return this.employees;
@@ -1407,9 +1412,12 @@ class FakeHostRuntime {
1407
1412
  }
1408
1413
  fakeEmployeeReply(kind, message) {
1409
1414
  const parsed = parseFraimInvocation(message);
1410
- const label = parsed?.jobId
1415
+ const parsedLabel = parsed?.jobId
1411
1416
  ? parsed.jobId.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' ')
1412
1417
  : null;
1418
+ if (parsedLabel)
1419
+ this.lastJobLabel = parsedLabel;
1420
+ const label = parsedLabel || (kind === 'continue' ? this.lastJobLabel : null);
1413
1421
  if (kind === 'continue') {
1414
1422
  return label
1415
1423
  ? `Understood. I'm taking another pass with ${label}.`
@@ -1442,6 +1450,9 @@ class ScriptedHostRuntime {
1442
1450
  // Tracks runDiscriminant per sessionId so consecutive emitPhase calls
1443
1451
  // can resolve onSuccess routing without each test having to repeat it.
1444
1452
  this.discriminantBySession = new Map();
1453
+ // Issue #732: capture the exact message the Hub sent on the most recent
1454
+ // continue turn so tests can assert whether it would re-trigger get_fraim_job.
1455
+ this.lastContinueMessage = null;
1445
1456
  }
1446
1457
  detectEmployees() {
1447
1458
  return this.employees;
@@ -1452,7 +1463,8 @@ class ScriptedHostRuntime {
1452
1463
  handlers.onEvent({ sessionId, raw: 'scripted-session-start' }, 'system');
1453
1464
  return this.spawnDouble();
1454
1465
  }
1455
- continueRun(_hostId, _projectPath, sessionId, _message, handlers) {
1466
+ continueRun(_hostId, _projectPath, sessionId, message, handlers) {
1467
+ this.lastContinueMessage = message;
1456
1468
  this.handlersBySession.set(sessionId, handlers);
1457
1469
  handlers.onEvent({ sessionId, raw: 'scripted-session-resume' }, 'system');
1458
1470
  return this.spawnDouble();
@@ -1517,6 +1529,20 @@ class ScriptedHostRuntime {
1517
1529
  },
1518
1530
  }, 'stdout');
1519
1531
  }
1532
+ // Test API — emit a same-job seekMentoring signal that carries BOTH the
1533
+ // stable jobName slug and a per-invocation UUID jobId, exactly as a real
1534
+ // agent does after get_fraim_job (which mints a fresh randomUUID each call).
1535
+ // Issue #732: the tracker must key off jobName, not the UUID jobId.
1536
+ emitPhaseWithJobIds(runId, phaseId, status, jobName, jobId, findingsText) {
1537
+ const target = this.resolveSession(runId);
1538
+ if (!target)
1539
+ return;
1540
+ target.handlers.onEvent({
1541
+ sessionId: target.sessionId,
1542
+ raw: `scripted-seekMentoring-ids:${phaseId}:${status}`,
1543
+ seekMentoring: { phaseId, phaseStatus: status, jobName, jobId, findingsText },
1544
+ }, 'stdout');
1545
+ }
1520
1546
  // Test API — emit the structured job-load signal that get_fraim_job
1521
1547
  // produces before the first seekMentoring phase call.
1522
1548
  emitFraimJobLoad(runId, jobId) {
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.extractExplicitFraimInvocation = extractExplicitFraimInvocation;
4
4
  exports.fraimInvocationFor = fraimInvocationFor;
5
5
  exports.buildCommunicationStyleNote = buildCommunicationStyleNote;
6
+ exports.buildSameJobContinueMessage = buildSameJobContinueMessage;
6
7
  exports.buildManagerMessage = buildManagerMessage;
7
8
  function extractExplicitFraimInvocation(text) {
8
9
  const raw = String(text || '');
@@ -36,6 +37,18 @@ function buildCommunicationStyleNote() {
36
37
  '[How to talk to me] In your messages to me, report ONLY on the job and its outcome — what you found, what you changed, the decisions you made, blockers, and what you need from me. Do NOT narrate the FRAIM machinery: don\'t announce that you are talking to FRAIM, asking your mentor, following the process, moving between phases, or calling tools (git, playwright, etc.). I can see the raw tool activity separately if I want it. Keep your updates short and about the work, not the process.',
37
38
  ].join('\n');
38
39
  }
40
+ // Issue #732: a plain continue of the SAME active job must not re-load the job.
41
+ // Continue turns resume the existing agent session, which already has the job
42
+ // loaded, so this message carries NO `/fraim <job>` invocation — the headless
43
+ // transform therefore does not re-instruct get_fraim_job (which is slow and
44
+ // mints a fresh UUID job id every call). The manager coaching still reaches the
45
+ // agent, which advances the workflow via seekMentoring using its resumed context.
46
+ function buildSameJobContinueMessage(coaching) {
47
+ const remainder = String(coaching || '').trim();
48
+ return remainder
49
+ ? `Continue the active FRAIM job with this manager coaching:\n\n${remainder}`
50
+ : 'Continue the active FRAIM job using the current session context.';
51
+ }
39
52
  function buildManagerMessage(employeeId, jobId, kind, instructions, stubPath) {
40
53
  const trimmed = String(instructions || '').trim();
41
54
  const explicit = extractExplicitFraimInvocation(trimmed);
@@ -726,12 +726,17 @@ function applySeekMentoringSignal(run, signal) {
726
726
  // Issue #347: filter cross-job pollution. The agent may call
727
727
  // seekMentoring for jobs OTHER than the one this run is tracking
728
728
  // (e.g., consulting `organizational-learning-synthesis` mid-run).
729
- // Only apply signals whose jobId/jobName matches this run's jobId.
730
- // Match against either field because the agent passes both and they
731
- // typically agree, but defenses are cheap.
729
+ // Only apply signals whose job identity matches this run's.
730
+ // Issue #732: match on `jobName` the stable job-name slug that equals
731
+ // run.jobId NOT `jobId`. get_fraim_job mints a fresh randomUUID as the
732
+ // job id on every call and the agent echoes that UUID into
733
+ // seekMentoring.jobId, so a UUID never equals run.jobId. Comparing the UUID
734
+ // discarded every real phase signal as if it were foreign-job pollution and
735
+ // froze the tracker at "no phases done". The UUID cannot positively identify
736
+ // this run's job, so it must not participate in the match.
732
737
  const targetJobId = run.jobId;
733
- const callJobId = signal.jobId || signal.jobName;
734
- if (callJobId && targetJobId && callJobId !== targetJobId)
738
+ const callJobName = signal.jobName;
739
+ if (callJobName && targetJobId && callJobName !== targetJobId)
735
740
  return;
736
741
  if (signal.reviewHandoff) {
737
742
  const normalizedReviewHandoff = normalizeReviewHandoff(signal.reviewHandoff);
@@ -1862,7 +1867,10 @@ class AiHubServer {
1862
1867
  return null;
1863
1868
  }
1864
1869
  applySeekMentoringSignalToRun(run, signal) {
1865
- this.maybePromoteFreeformRunToJob(run, signal.jobId || signal.jobName);
1870
+ // Issue #732: promote using the stable jobName slug, not the per-call UUID
1871
+ // jobId (resolveHubJob would never match a UUID, leaving a freeform run
1872
+ // unpromoted and then dropping its phase signals).
1873
+ this.maybePromoteFreeformRunToJob(run, signal.jobName || signal.jobId);
1866
1874
  applySeekMentoringSignal(run, signal);
1867
1875
  }
1868
1876
  applyFraimJobSignalToRun(run, signal) {
@@ -1890,15 +1898,17 @@ class AiHubServer {
1890
1898
  if (!resolvedJobId) {
1891
1899
  throw new Error('Choose a FRAIM job before starting a run, or start with /fraim <job-id>.');
1892
1900
  }
1893
- // #696: `display` is the manager conversation bubble. Keep the full generated
1894
- // turn in `message`, but show only the host-specific FRAIM invocation.
1895
- const display = (0, manager_turns_1.fraimInvocationFor)(hostId, resolvedJobId) || (explicit?.remainder || instructions || '').trim();
1901
+ // `display` is the manager conversation bubble. It keeps the user's full
1902
+ // coaching text with the host-specific FRAIM invocation. The agent payload
1903
+ // still receives Hub-only helper blocks such as `[FRAIM shared browser]`
1904
+ // and `[How to talk to me]`; those are omitted from the visible bubble.
1896
1905
  // #521: the shared-browser guidance is injected HERE, at the Hub layer — never
1897
1906
  // baked into the registry job/skill. It only appears when a shared browser is
1898
1907
  // available (env published at boot).
1899
1908
  const browserNote = (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL);
1900
1909
  const styleNote = (0, manager_turns_1.buildCommunicationStyleNote)();
1901
1910
  if (resolvedJobId === '__freeform__') {
1911
+ const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions);
1902
1912
  return {
1903
1913
  jobId: resolvedJobId,
1904
1914
  message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions) + browserNote + styleNote,
@@ -1909,6 +1919,7 @@ class AiHubServer {
1909
1919
  const absoluteStubPath = resolvedJob?.stubPath
1910
1920
  ? [projectPath, resolvedJob.stubPath].join('/').replace(/\\/g, '/').replace(/\/+/g, '/')
1911
1921
  : undefined;
1922
+ const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions);
1912
1923
  return {
1913
1924
  jobId: resolvedJobId,
1914
1925
  message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, absoluteStubPath) + browserNote + styleNote,
@@ -1923,14 +1934,27 @@ class AiHubServer {
1923
1934
  // passes raw invocation syntax.
1924
1935
  const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
1925
1936
  const effectiveJobId = explicit?.jobId || coachingJobId || run.jobId;
1926
- // #696: the thread shows the invocation that the Hub sent, while the employee
1927
- // still receives the full manager turn plus the style note.
1937
+ // #730: the manager thread shows the coaching turn with high fidelity the
1938
+ // correct FRAIM command in front plus what the manager actually said — built
1939
+ // from the shared normalizer, exactly like the start path. With no
1940
+ // instructions (e.g. a plain coaching-template click) buildManagerMessage
1941
+ // collapses to just the invocation. The employee additionally receives the
1942
+ // communication-style note (and, per #732 below, a lighter same-job payload);
1943
+ // those operational details stay out of the visible bubble.
1928
1944
  const userText = (explicit?.remainder || instructions || '').trim();
1929
- const display = (0, manager_turns_1.fraimInvocationFor)(run.hostId, effectiveJobId) || userText || (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', '');
1930
- return {
1931
- message: (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions) + (0, manager_turns_1.buildCommunicationStyleNote)(),
1932
- display,
1933
- };
1945
+ const display = (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions);
1946
+ // Issue #732: a plain continue of the SAME active real job must not re-load
1947
+ // the job via get_fraim_job on every coaching turn — the host session is
1948
+ // resumed with the job already in context, so re-fetching is wasted latency
1949
+ // and tokens (the reported hub slowness) and mints a fresh UUID job id each
1950
+ // time. Only load the job when the continue SWITCHES jobs (a coaching
1951
+ // template or an explicit /fraim <other>). Freeform/adhoc runs keep the
1952
+ // existing buildManagerMessage path (which already emits no invocation).
1953
+ const switchesJob = effectiveJobId !== run.jobId;
1954
+ const message = (switchesJob || run.jobId === '__freeform__')
1955
+ ? (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions) + (0, manager_turns_1.buildCommunicationStyleNote)()
1956
+ : (0, manager_turns_1.buildSameJobContinueMessage)(userText) + (0, manager_turns_1.buildCommunicationStyleNote)();
1957
+ return { message, display };
1934
1958
  }
1935
1959
  async computePersonas(apiKey) {
1936
1960
  const allBundles = listHubPersonaBundles();
@@ -1052,6 +1052,27 @@ class FraimLocalMCPServer {
1052
1052
  this.collectStrings(response.result, strings);
1053
1053
  return strings.some(s => /\{\{proxy\.delivery\.[^}]+\}\}/.test(s));
1054
1054
  }
1055
+ getDeliveryPlaceholderKeys(response) {
1056
+ const keys = new Set();
1057
+ if (!response.result)
1058
+ return keys;
1059
+ const strings = [];
1060
+ this.collectStrings(response.result, strings);
1061
+ for (const text of strings) {
1062
+ for (const match of text.matchAll(/\{\{proxy\.delivery\.([^}]+)\}\}/g)) {
1063
+ keys.add(`proxy.delivery.${match[1].trim()}`);
1064
+ }
1065
+ }
1066
+ return keys;
1067
+ }
1068
+ deliveryTemplatesCoverPlaceholders(templates, response) {
1069
+ const requiredKeys = this.getDeliveryPlaceholderKeys(response);
1070
+ for (const key of requiredKeys) {
1071
+ if (!(key in templates))
1072
+ return false;
1073
+ }
1074
+ return true;
1075
+ }
1055
1076
  responseHasPotentialProviderPlaceholders(response) {
1056
1077
  if (!response.result)
1057
1078
  return false;
@@ -1123,15 +1144,17 @@ class FraimLocalMCPServer {
1123
1144
  if (!this.responseContainsDeliveryPlaceholders(response))
1124
1145
  return;
1125
1146
  const engine = this.ensureEngine();
1126
- if (engine.hasDeliveryTemplates())
1127
- return;
1128
- if (engine.loadDeliveryTemplates())
1147
+ const currentTemplates = engine.loadDeliveryTemplates();
1148
+ // Normal proxy.delivery substitution is correct once the provider contains the
1149
+ // key. Older local registry caches can predate newly added delivery keys, so
1150
+ // treat missing required keys as stale cache and refresh from the server.
1151
+ if (currentTemplates && this.deliveryTemplatesCoverPlaceholders(currentTemplates, response))
1129
1152
  return;
1130
1153
  const filename = this.getWorkingStyle() === 'Conversation'
1131
1154
  ? 'delivery-conversation.json'
1132
1155
  : 'delivery-pr.json';
1133
1156
  const cached = this.readCachedTemplateFile(filename);
1134
- if (cached) {
1157
+ if (cached && this.deliveryTemplatesCoverPlaceholders(cached, response)) {
1135
1158
  engine.setDeliveryTemplates(cached);
1136
1159
  return;
1137
1160
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-framework",
3
- "version": "2.0.193",
3
+ "version": "2.0.194",
4
4
  "description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -2937,11 +2937,19 @@ function humanizeSlug(slug) {
2937
2937
  .join(' ');
2938
2938
  }
2939
2939
 
2940
- function stripStubReference(text) {
2941
- return String(text || '')
2940
+ function stripStubReference(text, options = {}) {
2941
+ const cleaned = String(text || '')
2942
2942
  .replace(/\n?\[Job stub:[^\]]+\]/gi, '')
2943
- .replace(/\[Thought:\s*true\]\s*/gi, '')
2944
- .replace(/\s+/g, ' ')
2943
+ .replace(/\[Thought:\s*true\]\s*/gi, '');
2944
+ if (options.preserveWhitespace) {
2945
+ return cleaned.replace(/\n{3,}/g, '\n\n').trim();
2946
+ }
2947
+ return cleaned.replace(/\s+/g, ' ').trim();
2948
+ }
2949
+
2950
+ function managerBubbleText(text) {
2951
+ return stripHubInjectedNotes(stripStubReference(text, { preserveWhitespace: true }))
2952
+ .replace(/\n{3,}/g, '\n\n')
2945
2953
  .trim();
2946
2954
  }
2947
2955
 
@@ -3048,8 +3056,7 @@ function appendMessageDom(role, text, conv) {
3048
3056
  const content = useSurfaced ? surfaced : raw;
3049
3057
  bubble.innerHTML = formatEmployeeText(content || (conv.status === 'completed' ? 'Done — please review.' : 'Working on it…'));
3050
3058
  } else {
3051
- const surfaced = surfaceText(role, text, conv);
3052
- bubble.textContent = surfaced || text;
3059
+ bubble.textContent = role === 'manager' ? managerBubbleText(text) : (surfaceText(role, text, conv) || text);
3053
3060
  }
3054
3061
  if (role === 'manager') {
3055
3062
  const raw = document.createElement('span');
@@ -1503,6 +1503,7 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
1503
1503
  .message.manager .bubble {
1504
1504
  background: var(--accent);
1505
1505
  color: #fff;
1506
+ white-space: pre-wrap;
1506
1507
  }
1507
1508
  .message.employee .bubble {
1508
1509
  background: var(--surface);
@@ -2752,7 +2753,7 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
2752
2753
  .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
2754
  .am-name { font-size: 13px; font-weight: 700; }
2754
2755
  .am-email { font-size: 11px; color: var(--muted); }
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
+ .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
2757
  .am-item:hover { background: var(--bg); }
2757
2758
  .am-ico { font-size: 14px; width: 18px; text-align: center; flex-shrink: 0; margin-top: 1px; }
2758
2759
  .am-label { font-weight: 600; }
@@ -2768,75 +2769,75 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
2768
2769
  .am-theme[aria-checked="true"] .am-switch-knob { transform: translateX(16px); }
2769
2770
 
2770
2771
  /* 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
- .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; }
2772
+ .hub-area { display: none; flex: 1; overflow: hidden; }
2773
+ .hub-area.on { display: flex; flex-direction: column; }
2774
+ .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 */ }
2775
+
2776
+ .connected-shell {
2777
+ display: flex;
2778
+ flex: 1;
2779
+ min-height: 0;
2780
+ flex-direction: column;
2781
+ background: var(--bg);
2782
+ }
2783
+ .connected-toolbar {
2784
+ display: flex;
2785
+ align-items: center;
2786
+ gap: 14px;
2787
+ min-height: 52px;
2788
+ padding: 8px 16px;
2789
+ background: var(--surface);
2790
+ border-bottom: 1px solid var(--line);
2791
+ flex-shrink: 0;
2792
+ }
2793
+ .connected-title-block {
2794
+ min-width: 0;
2795
+ display: flex;
2796
+ flex-direction: column;
2797
+ gap: 1px;
2798
+ }
2799
+ .connected-kicker {
2800
+ font-size: 10px;
2801
+ font-weight: 700;
2802
+ letter-spacing: .04em;
2803
+ text-transform: uppercase;
2804
+ color: var(--muted);
2805
+ }
2806
+ .connected-title {
2807
+ font-size: 14px;
2808
+ font-weight: 700;
2809
+ color: var(--text);
2810
+ }
2811
+ .connected-origin {
2812
+ min-width: 0;
2813
+ flex: 1;
2814
+ color: var(--muted);
2815
+ font-size: 12px;
2816
+ overflow: hidden;
2817
+ text-overflow: ellipsis;
2818
+ white-space: nowrap;
2819
+ }
2820
+ .connected-close {
2821
+ border: 1px solid var(--line);
2822
+ background: var(--surface);
2823
+ color: var(--text);
2824
+ border-radius: 8px;
2825
+ padding: 6px 12px;
2826
+ font-size: 13px;
2827
+ font-weight: 600;
2828
+ flex-shrink: 0;
2829
+ }
2830
+ .connected-close:hover { background: var(--bg); }
2831
+ .connected-frame {
2832
+ flex: 1;
2833
+ min-height: 0;
2834
+ width: 100%;
2835
+ border: 0;
2836
+ background: var(--surface);
2837
+ }
2838
+
2839
+ /* #521 R6: Company/Manager use the same rail + main shell as the project workspace. */
2840
+ .area-shell { display: flex; flex: 1; min-height: 0; }
2840
2841
  .area-rail { width: 244px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--line); overflow-y: auto; padding: 6px 0; }
2841
2842
  .area-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
2842
2843
  .area-rail-head { font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 14px 16px 6px; }