fraim-hub 2.0.322 → 2.0.323

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.
@@ -11,6 +11,7 @@ exports.searchProjectionDirPath = searchProjectionDirPath;
11
11
  exports.searchProjectionFileName = searchProjectionFileName;
12
12
  exports.searchProjectionEntryPath = searchProjectionEntryPath;
13
13
  exports.writeConversationSearchEntry = writeConversationSearchEntry;
14
+ exports.deleteConversationSearchEntry = deleteConversationSearchEntry;
14
15
  exports.readConversationSearchEntries = readConversationSearchEntries;
15
16
  exports.pruneConversationSearchEntries = pruneConversationSearchEntries;
16
17
  const crypto_1 = __importDefault(require("crypto"));
@@ -169,6 +170,14 @@ function writeConversationSearchEntry(bucketDir, conv) {
169
170
  /* the projection self-heals on the next read */
170
171
  }
171
172
  }
173
+ /**
174
+ * Remove one derived search copy for an explicit conversation deletion.
175
+ * Unlike cache writes, this is deliberately not best-effort: a successful user-facing delete
176
+ * must not leave a searchable copy behind.
177
+ */
178
+ function deleteConversationSearchEntry(bucketDir, conversationId) {
179
+ fs_1.default.rmSync(searchProjectionEntryPath(bucketDir, conversationId), { force: true });
180
+ }
172
181
  function readConversationSearchEntries(bucketDir) {
173
182
  const dir = searchProjectionDirPath(bucketDir);
174
183
  let files;
@@ -917,6 +917,38 @@ class AiHubConversationStore {
917
917
  this.invalidateProjectPathCache();
918
918
  return state;
919
919
  }
920
+ /** Delete one authoritative conversation and every persistence-owned projection. */
921
+ deleteConversation(projectPath, conversationId) {
922
+ this.ensureMigrated();
923
+ const key = normalizeConversationKey(projectPath);
924
+ const bucketDir = this.bucketDir(key);
925
+ if (!fs_1.default.existsSync(bucketDir))
926
+ return { removed: false, activeId: null };
927
+ return (0, conversation_store_lock_1.withBucketLock)(this.lockPath(bucketDir), () => {
928
+ const index = this.loadIndex(bucketDir, key);
929
+ const conversationPath = this.convFilePath(bucketDir, conversationId);
930
+ const conversationExists = fs_1.default.existsSync(conversationPath);
931
+ const headers = index.headers.filter((header) => header.id !== conversationId);
932
+ const activeId = index.activeId === conversationId ? null : index.activeId;
933
+ // A stale projection/header can survive a prior interrupted whole-bucket write. Heal those
934
+ // derived copies even though the authoritative shard is already absent.
935
+ if (!conversationExists) {
936
+ (0, conversation_search_projection_1.deleteConversationSearchEntry)(bucketDir, conversationId);
937
+ if (headers.length !== index.headers.length || activeId !== index.activeId) {
938
+ (0, atomic_json_file_1.writeJsonAtomic)(this.indexPath(bucketDir), { version: 2, bucketKey: key, activeId, headers });
939
+ }
940
+ return { removed: false, activeId };
941
+ }
942
+ // Search is a derived copy, but explicit deletion requires its removal to succeed before
943
+ // the authoritative shard is touched. Invalidate the derived index before shard removal so
944
+ // every crash point self-heals from the authoritative files.
945
+ (0, conversation_search_projection_1.deleteConversationSearchEntry)(bucketDir, conversationId);
946
+ fs_1.default.rmSync(this.indexPath(bucketDir), { force: true });
947
+ fs_1.default.rmSync(conversationPath, { force: true });
948
+ (0, atomic_json_file_1.writeJsonAtomic)(this.indexPath(bucketDir), { version: 2, bucketKey: key, activeId, headers });
949
+ return { removed: true, activeId };
950
+ });
951
+ }
920
952
  // Update the index header for one conversation and return the (header-shaped) project state.
921
953
  reindexAfterUpsert(bucketDir, bucketKey, conv, activeId) {
922
954
  const idx = this.loadIndex(bucketDir, bucketKey);
@@ -951,13 +983,13 @@ class AiHubConversationStore {
951
983
  indexConversationForSearch(bucketKey, conversationId) {
952
984
  const key = normalizeConversationKey(bucketKey);
953
985
  const bucketDir = this.bucketDir(key);
954
- const conversation = this.loadConversation(key, conversationId);
955
- if (!conversation)
956
- return false;
957
- (0, conversation_store_lock_1.withBucketLock)(this.lockPath(bucketDir), () => {
986
+ return (0, conversation_store_lock_1.withBucketLock)(this.lockPath(bucketDir), () => {
987
+ const conversation = this.loadConversation(key, conversationId);
988
+ if (!conversation)
989
+ return false;
958
990
  (0, conversation_search_projection_1.writeConversationSearchEntry)(bucketDir, conversation);
991
+ return true;
959
992
  });
960
- return true;
961
993
  }
962
994
  }
963
995
  exports.AiHubConversationStore = AiHubConversationStore;
@@ -5864,6 +5864,26 @@ class AiHubServer {
5864
5864
  return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist conversation.' });
5865
5865
  }
5866
5866
  });
5867
+ // Issue #1740: interactive run deletion is a bounded one-record mutation. It requires an
5868
+ // explicit record-owned partition and never falls back to the currently selected project.
5869
+ this.app.delete('/api/ai-hub/conversations/:conversationId', (req, res) => {
5870
+ try {
5871
+ const scope = scopeParam(req.query.scope);
5872
+ if (!scope && (typeof req.query.projectPath !== 'string' || req.query.projectPath.trim().length === 0)) {
5873
+ return res.status(400).json({ error: 'projectPath required for project conversations' });
5874
+ }
5875
+ const key = scope
5876
+ ? (0, conversation_store_1.conversationScopeKey)(scope, '')
5877
+ : ensureDirectoryPath(req.query.projectPath);
5878
+ const result = this.conversationStore.deleteConversation(key, req.params.conversationId);
5879
+ if (!result.removed)
5880
+ return res.status(404).json({ error: 'Conversation not found.' });
5881
+ return res.json(result);
5882
+ }
5883
+ catch (error) {
5884
+ return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not delete conversation.' });
5885
+ }
5886
+ });
5867
5887
  this.app.post('/api/ai-hub/conversations/:conversationId/switch-agent', (req, res) => {
5868
5888
  try {
5869
5889
  const body = (req.body ?? {});
@@ -6598,9 +6618,6 @@ class AiHubServer {
6598
6618
  });
6599
6619
  }
6600
6620
  const outcome = await installAgentAndRefreshDetection({ label: option.label, installPackage: option.installPackage, launchCommand: option.launchCommand }, systemPath, { runProcess: hubRunProcess, commandVersion: hubCommandVersion });
6601
- if (outcome.outcome === 'standard' && outcome.npmGlobalBinDirs.length > 0) {
6602
- process.env.PATH = (0, managed_agent_paths_1.appendBinDirsToPath)(systemPath, outcome.npmGlobalBinDirs);
6603
- }
6604
6621
  const mcp = await configureFraimForHubAgent(hubId);
6605
6622
  if (!mcp.configured) {
6606
6623
  console.warn(`[ai-hub] install-agent: FRAIM add-ide did not run for ${option.label} (${outcome.outcome} install): ${mcp.error || 'unknown reason'}`);
@@ -14,6 +14,7 @@ exports.checkAgentCliHealthByCommand = checkAgentCliHealthByCommand;
14
14
  const child_process_1 = require("child_process");
15
15
  const path_1 = __importDefault(require("path"));
16
16
  const managed_agent_paths_1 = require("../../utils/managed-agent-paths");
17
+ const machine_path_1 = require("../../utils/machine-path");
17
18
  const command_resolution_1 = require("../../mcp/command-resolution");
18
19
  // CLIs with a managed-install fallback (npm install -g into FRAIM's portable
19
20
  // Node when no system install is found). Add gemini/copilot here when their
@@ -57,18 +58,14 @@ function probeVersion(commandPath) {
57
58
  // shell already open before the fix landed still uses) — issue #1285's
58
59
  // exact "update said success but --version looked wrong" ambiguity.
59
60
  // Windows-only; there is no equivalent persisted-PATH registry on macOS/Linux.
61
+ // Issue #1748: this used to shell out to PowerShell for HKCU Environment Path on its own,
62
+ // a second independently-written Windows registry PATH reader. Two implementations of the
63
+ // same read drift apart, and a parsing fix applied to one is easy to miss on the other,
64
+ // which is the same PATH-resolution-drift bug class this check exists to report. Delegates
65
+ // to the shared reader instead, which also covers HKLM rather than only the user hive and
66
+ // expands REG_EXPAND_SZ references.
60
67
  function readPersistedUserPath() {
61
- if (process.platform !== 'win32')
62
- return null;
63
- try {
64
- const result = (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', "[Environment]::GetEnvironmentVariable('PATH','User')"], { encoding: 'utf8', timeout: 5000 });
65
- if (result.status !== 0 || result.error)
66
- return null;
67
- return (result.stdout || '').trim() || null;
68
- }
69
- catch {
70
- return null;
71
- }
68
+ return (0, machine_path_1.resolveMachinePathCached)();
72
69
  }
73
70
  async function runAgentCliHealthCheck(cli) {
74
71
  // "Ambient" is this process's own raw, unmodified PATH — exactly what a
@@ -83,7 +80,16 @@ async function runAgentCliHealthCheck(cli) {
83
80
  // is system-PATH-first for launch purposes and would silently agree with
84
81
  // a stale ambient entry instead of surfacing the drift this check exists
85
82
  // to catch.
86
- const ambientPath = (0, command_resolution_1.getSystemCommandPath)(cli.command);
83
+ // Issue #1748: process.env.PATH no longer carries FRAIM's managed bin dirs (the
84
+ // module-load mutation that put them there was the defect). Without a fallback,
85
+ // `ambientPath` is null whenever the only install is FRAIM-managed, and both clauses
86
+ // of `versionMismatch` below short-circuit on it, so this check would return
87
+ // "consistent" while the #1285 stale-shim drift it exists to catch went unreported.
88
+ // The machine PATH is what a newly opened shell resolves, which is the notion of
89
+ // "ambient" this check actually wants once the process is no longer self-mutating.
90
+ const machinePathForAmbient = (0, machine_path_1.resolveMachinePathCached)();
91
+ const ambientPath = (0, command_resolution_1.getSystemCommandPath)(cli.command)
92
+ || (machinePathForAmbient ? (0, command_resolution_1.getSystemCommandPath)(cli.command, machinePathForAmbient) : null);
87
93
  const managedSearchPath = (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH);
88
94
  const managedPath = (0, command_resolution_1.getSystemCommandPath)(cli.command, managedSearchPath);
89
95
  if (!ambientPath && !managedPath) {
@@ -87,8 +87,13 @@ const resolveManagedCommand = (command) => {
87
87
  // path and the status-check path. Fall back to the FRAIM-managed portable
88
88
  // copy only when no system install is found by either check. Last resort:
89
89
  // bare command name.
90
- return (0, exports.getSystemCommandPath)(command)
91
- || (0, exports.getSystemCommandPath)(command, (0, managed_agent_paths_1.buildRecoveredSystemPath)(process.env.PATH))
90
+ // Issue #1748: resolve ONLY against the recovered system PATH. The previous first tier
91
+ // scanned this process's ambient PATH, which carries FRAIM's own managed dirs (registered
92
+ // on the machine PATH at install, and formerly appended again at module load), so a
93
+ // FRAIM-managed binary was returned as if it were a system install and the tiers below
94
+ // never ran. buildRecoveredSystemPath() re-reads the machine PATH and excludes the managed
95
+ // dirs, so FRAIM's bundled copy is reachable only through the explicit tier below.
96
+ return (0, exports.getSystemCommandPath)(command, (0, managed_agent_paths_1.buildRecoveredSystemPath)(process.env.PATH))
92
97
  || (0, exports.getPortableManagedCommandPath)(command)
93
98
  || command;
94
99
  };
@@ -72,7 +72,7 @@ ${buildDeferredToolBootstrapSection(profile)}1. **Confirm FRAIM activation**:
72
72
  If local FRAIM job stubs are present in the workspace, inspect those first and match the request locally. Also inspect \`fraim/personalized-employee/jobs/\` for local overrides or repo-specific jobs. If local files are missing or you cannot inspect workspace files, call \`list_fraim_jobs()\` to view the full catalog, including any proxy-discoverable personalized jobs.
73
73
 
74
74
  3. **Find the match**:
75
- If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly. Otherwise, match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no exact or high-confidence job match exists, ask once: "No catalog job matches. Would you like to run this as an ad-hoc task?" On confirmation, call \`get_fraim_job({ job: "adhoc-prompt" })\` and execute it with the user's instructions as the task input — do not pick the nearest catalog job. Do not ask again if the user already provided instructions.
75
+ If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly. Otherwise, match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no exact or high-confidence job match exists, call \`get_fraim_job({ job: "adhoc-prompt" })\` directly and execute it with the user's instructions as the task input — no confirmation question, and do not pick the nearest catalog job.
76
76
 
77
77
  4. **Load the full content**:
78
78
  - For jobs, call \`get_fraim_job({ job: "<matched-job-name>" })\`.
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseRegQueryPathValue = parseRegQueryPathValue;
7
+ exports.expandWindowsEnvRefs = expandWindowsEnvRefs;
8
+ exports.composeMachinePath = composeMachinePath;
9
+ exports.resolveMachinePathCached = resolveMachinePathCached;
10
+ exports.clearMachinePathCache = clearMachinePathCache;
11
+ exports.setMachinePathResolver = setMachinePathResolver;
12
+ const path_1 = __importDefault(require("path"));
13
+ const child_process_1 = require("child_process");
14
+ // ─── Issue #1748: Windows machine-PATH recovery ─────────────────────────────
15
+ //
16
+ // A Windows process inherits its environment at creation and never observes later
17
+ // registry changes (Node/Electron do not handle WM_SETTINGCHANGE). A Hub running for
18
+ // hours therefore holds the PATH as of its launch. When a user installs an agent CLI
19
+ // after that (Claude Code's native installer adding ~/.local/bin, say), the Hub cannot
20
+ // see it while a freshly opened terminal can, so the Hub falls through to FRAIM's own
21
+ // managed copy and runs a different CLI than the user installed and signed into.
22
+ //
23
+ // `defaultResolveLoginShellPath()` in managed-agent-paths.ts returns null on win32 (there
24
+ // is no login-shell PATH concept there), so before this the only recovery on Windows was
25
+ // the npm-prefix lookup: nothing re-read HKLM/HKCU. This module is the win32 counterpart
26
+ // to that POSIX recovery. Ask the OS what the PATH is now, rather than trusting the
27
+ // snapshot this process was handed.
28
+ //
29
+ // Lives in its own module rather than inside managed-agent-paths.ts so that file stays
30
+ // under the 500-line architecture threshold, and because "read the OS environment" is a
31
+ // distinct concern from "compose the PATH FRAIM resolves agents against". The dependency
32
+ // runs one way only (managed-agent-paths imports this), so there is no import cycle.
33
+ // Per-read ceiling. Both registry reads run sequentially (spawnSync is synchronous by
34
+ // definition, so they cannot overlap without making the whole resolution path async,
35
+ // which every caller is sync today), so the worst case is twice this. Measured cost is
36
+ // ~25ms per read, so 2s is already ~80x headroom while bounding a pathological stall at
37
+ // 4s rather than 10s.
38
+ const MACHINE_PATH_TIMEOUT_MS = 2000;
39
+ // The machine PATH changes when the user installs or moves a CLI, which is exactly the
40
+ // case this issue exists to handle, so this cache must expire rather than live for the
41
+ // life of the process. A Hub can run for days; a permanent cache would mean "install a
42
+ // CLI, then use it" still required a restart. Bounded by a TTL rather than an explicit
43
+ // invalidation call because the install that matters most is the one made OUTSIDE FRAIM
44
+ // (a vendor installer adding ~/.local/bin), which FRAIM never observes and so cannot
45
+ // invalidate on. Measured cost of a refresh on Windows is ~50ms for both registry reads,
46
+ // far below the blocking-spawn budget issue #1010 set (the POSIX login-shell read it sits
47
+ // beside costs 1-3s and is cached for the whole process).
48
+ const MACHINE_PATH_CACHE_TTL_MS = 60_000;
49
+ let machinePathResolverOverride = null;
50
+ let cachedMachinePath;
51
+ let cachedMachinePathAtMs = 0;
52
+ // The two registry values Windows itself composes a process PATH from, in the order it
53
+ // composes them: Machine first, then User.
54
+ const WINDOWS_PATH_REGISTRY_KEYS = [
55
+ [String.raw `HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment`, 'Path'],
56
+ [String.raw `HKCU\Environment`, 'Path'],
57
+ ];
58
+ // Resolve reg.exe by absolute path rather than letting the OS search PATH for it. This
59
+ // module's whole purpose is to stop a PATH entry from deciding which binary FRAIM runs,
60
+ // so invoking its own helper by bare name through that same untrusted PATH would undercut
61
+ // the control it implements. Falls back to the bare name only if SystemRoot is unset,
62
+ // which on a real Windows install it is not.
63
+ function resolveRegExePath() {
64
+ const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT;
65
+ return systemRoot ? path_1.default.join(systemRoot, 'System32', 'reg.exe') : 'reg';
66
+ }
67
+ // `reg query` prints a line shaped like " Path REG_EXPAND_SZ C:\foo;C:\bar".
68
+ // Split on the value type rather than on whitespace, because a PATH entry may itself
69
+ // contain spaces (C:\Program Files\...).
70
+ function parseRegQueryPathValue(stdout) {
71
+ for (const line of (stdout || '').split(/\r?\n/)) {
72
+ const match = line.match(/\s+(?:REG_EXPAND_SZ|REG_SZ)\s+(.*)$/);
73
+ if (match && match[1] !== undefined) {
74
+ const value = match[1].trim();
75
+ if (value)
76
+ return value;
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+ // REG_EXPAND_SZ keeps %VAR% references verbatim; Windows expands them when it builds a
82
+ // process environment. Reading the raw value means expanding them here too, or an entry
83
+ // like %USERPROFILE%\.local\bin never resolves. Unknown variables are left as written
84
+ // rather than blanked, so a failed expansion cannot silently collapse an entry into a
85
+ // different directory.
86
+ function expandWindowsEnvRefs(value, env = process.env) {
87
+ return value.replace(/%([^%]+)%/g, (whole, name) => {
88
+ const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase());
89
+ const resolved = key === undefined ? undefined : env[key];
90
+ return resolved === undefined ? whole : resolved;
91
+ });
92
+ }
93
+ // Composition is separated from the registry I/O so the ordering contract can be tested
94
+ // without spawning reg.exe: Machine entries precede User entries, because that is the
95
+ // order Windows itself composes a process PATH in, and %VAR% references are expanded
96
+ // before splitting so an expanded value containing a delimiter still splits correctly.
97
+ function composeMachinePath(rawValues, env = process.env) {
98
+ const parts = [];
99
+ for (const raw of rawValues) {
100
+ if (!raw)
101
+ continue;
102
+ for (const entry of expandWindowsEnvRefs(raw, env).split(path_1.default.delimiter)) {
103
+ const trimmed = entry.trim();
104
+ if (trimmed)
105
+ parts.push(trimmed);
106
+ }
107
+ }
108
+ // Not de-duplicated here: every consumer composes this through appendBinDirsToPath,
109
+ // which already collapses repeats. A second dedupe would be duplicated logic.
110
+ return parts.length > 0 ? parts.join(path_1.default.delimiter) : null;
111
+ }
112
+ function defaultResolveMachinePath() {
113
+ if (process.platform !== 'win32')
114
+ return null;
115
+ // Issue #1692 established that a sandboxed test run must never WRITE the real,
116
+ // registry-backed Windows User PATH. Reading it is the same isolation break in the
117
+ // other direction: it lets whatever agent CLIs happen to be installed on the build
118
+ // machine shadow a test's own isolated fixtures, which is the cross-contamination
119
+ // hazard already documented on resolveNpmGlobalBinDirsCached. Tests that need
120
+ // machine-PATH behavior inject it through __setMachinePathResolverForTests.
121
+ if (process.env.FRAIM_TEST_SANDBOX === '1')
122
+ return null;
123
+ const rawValues = WINDOWS_PATH_REGISTRY_KEYS.map(([key, valueName]) => {
124
+ const result = (0, child_process_1.spawnSync)(resolveRegExePath(), ['query', key, '/v', valueName], {
125
+ encoding: 'utf8',
126
+ timeout: MACHINE_PATH_TIMEOUT_MS,
127
+ windowsHide: true,
128
+ });
129
+ if (result.status !== 0 || result.error)
130
+ return null;
131
+ return parseRegQueryPathValue(result.stdout || '');
132
+ });
133
+ return composeMachinePath(rawValues);
134
+ }
135
+ function resolveMachinePathUncached() {
136
+ if (machinePathResolverOverride)
137
+ return machinePathResolverOverride();
138
+ return defaultResolveMachinePath();
139
+ }
140
+ /**
141
+ * The PATH the machine reports, read fresh instead of inherited, memoized for
142
+ * `MACHINE_PATH_CACHE_TTL_MS`. `nowMs` is injectable so a test can prove the entry expires
143
+ * without sleeping.
144
+ */
145
+ function resolveMachinePathCached(nowMs = Date.now()) {
146
+ if (cachedMachinePath !== undefined && nowMs - cachedMachinePathAtMs < MACHINE_PATH_CACHE_TTL_MS) {
147
+ return cachedMachinePath;
148
+ }
149
+ try {
150
+ cachedMachinePath = resolveMachinePathUncached();
151
+ }
152
+ catch {
153
+ cachedMachinePath = null;
154
+ }
155
+ cachedMachinePathAtMs = nowMs;
156
+ return cachedMachinePath;
157
+ }
158
+ function clearMachinePathCache() {
159
+ cachedMachinePath = undefined;
160
+ cachedMachinePathAtMs = 0;
161
+ }
162
+ function setMachinePathResolver(resolver) {
163
+ machinePathResolverOverride = resolver;
164
+ clearMachinePathCache();
165
+ }
@@ -112,6 +112,11 @@ async function installManagedAgent(option, systemPath, deps) {
112
112
  NPM_CONFIG_PREFIX: undefined,
113
113
  });
114
114
  const standardVersion = deps.commandVersion(option.launchCommand, undefined, systemPath);
115
+ // Only look up npm global bin dirs when the CLI was NOT already runnable: that lookup
116
+ // is a blocking npm-prefix subprocess, and on the success path there is nothing to find.
117
+ // (Commit 16cea56ac made this unconditional so the caller could persist the directory onto
118
+ // process.env.PATH; issue #1748 removed that in-process PATH write, so the unconditional
119
+ // lookup has no consumer and only costs the happy path a blocking subprocess.)
115
120
  const npmGlobalBinDirs = standardVersion
116
121
  ? []
117
122
  : (0, managed_agent_paths_1.resolveNpmGlobalBinDirs)(systemPath, {
@@ -123,7 +128,7 @@ async function installManagedAgent(option, systemPath, deps) {
123
128
  ? deps.commandVersion(option.launchCommand, npmGlobalBinDirs, systemPath)
124
129
  : null);
125
130
  if (standardVersionWithNpmBin) {
126
- return { outcome: 'standard', npmGlobalBinDirs };
131
+ return { outcome: 'standard' };
127
132
  }
128
133
  standardInstallError = `${option.label} standard install completed, but the CLI is not runnable from the user PATH.`;
129
134
  }
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.resolveMachinePathCached = exports.expandWindowsEnvRefs = exports.parseRegQueryPathValue = void 0;
6
7
  exports.getManagedNodeRoot = getManagedNodeRoot;
7
8
  exports.getPortableNodeBinPath = getPortableNodeBinPath;
8
9
  exports.getManagedAgentBinDirs = getManagedAgentBinDirs;
@@ -13,10 +14,11 @@ exports.appendBinDirsToPath = appendBinDirsToPath;
13
14
  exports.getNpmGlobalBinDirsFromPrefix = getNpmGlobalBinDirsFromPrefix;
14
15
  exports.resolveNpmGlobalBinDirs = resolveNpmGlobalBinDirs;
15
16
  exports.buildPathWithManagedAgentBins = buildPathWithManagedAgentBins;
16
- exports.appendManagedAgentBinDirsToProcessPath = appendManagedAgentBinDirsToProcessPath;
17
17
  exports.__setLoginShellPathResolverForTests = __setLoginShellPathResolverForTests;
18
18
  exports.resolveLoginShellPathCached = resolveLoginShellPathCached;
19
19
  exports.__clearLoginShellPathCacheForTests = __clearLoginShellPathCacheForTests;
20
+ exports.invalidateMachinePathCache = invalidateMachinePathCache;
21
+ exports.__setMachinePathResolverForTests = __setMachinePathResolverForTests;
20
22
  exports.buildRecoveredSystemPath = buildRecoveredSystemPath;
21
23
  exports.buildRecoveredAgentPath = buildRecoveredAgentPath;
22
24
  exports.versionFromProbeOutput = versionFromProbeOutput;
@@ -24,6 +26,13 @@ const fs_1 = __importDefault(require("fs"));
24
26
  const path_1 = __importDefault(require("path"));
25
27
  const child_process_1 = require("child_process");
26
28
  const script_sync_utils_1 = require("./script-sync-utils");
29
+ const machine_path_1 = require("./machine-path");
30
+ // Re-exported so the machine-PATH surface stays reachable from this module, which is
31
+ // where every other PATH helper already lives.
32
+ var machine_path_2 = require("./machine-path");
33
+ Object.defineProperty(exports, "parseRegQueryPathValue", { enumerable: true, get: function () { return machine_path_2.parseRegQueryPathValue; } });
34
+ Object.defineProperty(exports, "expandWindowsEnvRefs", { enumerable: true, get: function () { return machine_path_2.expandWindowsEnvRefs; } });
35
+ Object.defineProperty(exports, "resolveMachinePathCached", { enumerable: true, get: function () { return machine_path_2.resolveMachinePathCached; } });
27
36
  function getManagedNodeRoot() {
28
37
  return path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
29
38
  }
@@ -185,9 +194,6 @@ function resolveNpmGlobalBinDirs(basePath, env) {
185
194
  function buildPathWithManagedAgentBins(basePath) {
186
195
  return appendBinDirsToPath(stripManagedAgentBinDirsFromPath(basePath), getManagedAgentBinDirs());
187
196
  }
188
- function appendManagedAgentBinDirsToProcessPath() {
189
- process.env.PATH = buildPathWithManagedAgentBins(process.env.PATH);
190
- }
191
197
  // ─── Issue #1256 (slice a): location-agnostic PATH recovery ─────────────────
192
198
  //
193
199
  // A Hub launched from Launchpad/Finder/a Windows shortcut inherits the OS's minimal
@@ -350,17 +356,55 @@ function resolveNpmGlobalBinDirsCached(basePath) {
350
356
  // — resolve against this path for the system-install check, then consult
351
357
  // `getPortableManagedCommandPath()` (never the flat dir) as an explicit,
352
358
  // separate fallback tier.
359
+ // Issue #1748: the win32 machine-PATH read lives in ./machine-path so this file stays
360
+ // under the 500-line architecture threshold. These wrappers exist because changing the
361
+ // machine PATH also invalidates npm-global dirs, which are resolved against the PATH
362
+ // derived from it and cached here. Callers get one call that keeps both caches coherent
363
+ // rather than having to remember to clear a second one.
364
+ function invalidateMachinePathCache() {
365
+ (0, machine_path_1.clearMachinePathCache)();
366
+ cachedNpmGlobalBinDirsForRecovery.clear();
367
+ }
368
+ function __setMachinePathResolverForTests(resolver) {
369
+ (0, machine_path_1.setMachinePathResolver)(resolver);
370
+ cachedNpmGlobalBinDirsForRecovery.clear();
371
+ }
353
372
  function buildRecoveredSystemPath(basePath) {
354
373
  const withoutManaged = stripManagedAgentBinDirsFromPath(basePath);
355
- const withoutProjectBins = stripProjectLocalNodeBinDirs(withoutManaged);
374
+ const inherited = stripProjectLocalNodeBinDirs(withoutManaged);
375
+ // Issue #1748 (win32 only): the PATH the machine reports right now. This process may
376
+ // predate the user's install, so its own inherited PATH is a stale copy of the same
377
+ // registry. Machine entries go FIRST, because entries present in both would otherwise
378
+ // keep their stale relative position and an entry existing only on the machine PATH
379
+ // would land last, which is how resolution picked a different install than the one a
380
+ // fresh terminal resolves.
381
+ //
382
+ // Deliberately NOT applied to the POSIX login-shell tier below, which keeps its
383
+ // original append semantics. The staleness this reorders around is a Windows
384
+ // registry-snapshot problem; on POSIX a GUI-launched process has a minimal inherited
385
+ // PATH that carries no competing entry to out-order, so appending already resolves it.
386
+ // Promoting login-shell entries above inherited ones there would let the login shell
387
+ // silently override a directory a wrapper deliberately prepended for that launch,
388
+ // which is the same class of "wrong binary launched" bug this change exists to fix.
389
+ const machinePath = process.platform === 'win32' ? (0, machine_path_1.resolveMachinePathCached)() : null;
390
+ const machineEntries = machinePath ? machinePath.split(path_1.default.delimiter).filter(Boolean) : [];
391
+ const withMachine = machineEntries.length > 0
392
+ ? appendBinDirsToPath(machineEntries.join(path_1.default.delimiter), inherited.split(path_1.default.delimiter).filter(Boolean))
393
+ : inherited;
356
394
  const loginShellPath = resolveLoginShellPathCached();
357
395
  const loginShellEntries = loginShellPath ? loginShellPath.split(path_1.default.delimiter).filter(Boolean) : [];
358
396
  const withLoginShell = loginShellEntries.length > 0
359
- ? appendBinDirsToPath(withoutProjectBins, loginShellEntries)
360
- : withoutProjectBins;
361
- const withLoginShellClean = stripProjectLocalNodeBinDirs(withLoginShell);
362
- const npmGlobalBinDirs = resolveNpmGlobalBinDirsCached(withLoginShellClean);
363
- return appendBinDirsToPath(withLoginShellClean, npmGlobalBinDirs);
397
+ ? appendBinDirsToPath(withMachine, loginShellEntries)
398
+ : withMachine;
399
+ const cleaned = stripProjectLocalNodeBinDirs(withLoginShell);
400
+ // Strip managed dirs a second time. FRAIM registers its own directory on the machine
401
+ // PATH at install (persistShellPath), so the recovered PATH legitimately contains it,
402
+ // but this helper's contract is "what the real machine provides, excluding FRAIM's
403
+ // bundled copy" - which is what lets callers tell a user install apart from FRAIM's
404
+ // fallback. Without this the machine read would smuggle the managed dir back in.
405
+ const systemOnly = stripManagedAgentBinDirsFromPath(cleaned);
406
+ const npmGlobalBinDirs = resolveNpmGlobalBinDirsCached(systemOnly);
407
+ return appendBinDirsToPath(systemOnly, npmGlobalBinDirs);
364
408
  }
365
409
  function buildRecoveredAgentPath(basePath) {
366
410
  return appendBinDirsToPath(buildRecoveredSystemPath(basePath), getManagedAgentBinDirs());
@@ -89,11 +89,18 @@ function ensureOutputDirs() {
89
89
  fs_1.default.mkdirSync((0, script_sync_utils_1.getUserFraimDir)(), { recursive: true });
90
90
  fs_1.default.mkdirSync(path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'last-install'), { recursive: true });
91
91
  }
92
- // Append the portable Node bin dir to process PATH once at module load so
93
- // every spawnSync call (detection, login probe, change-agent) finds binaries
94
- // installed there without shadowing user/system installs.
92
+ // Issue #1748: this no longer touches process.env.PATH. It used to append FRAIM's
93
+ // managed bin dirs here so spawnSync callers would find them, but desktop-main imports
94
+ // this module, so every Hub inherited them and command resolution could no longer tell
95
+ // a user install from FRAIM's bundled copy. Anything that needs to find a managed
96
+ // binary must route through buildRecoveredAgentPath()/resolveManagedCommand() rather
97
+ // than relying on ambient process.env.PATH. What remains here is shim cleanup only.
95
98
  (function bootstrapFraimNodeBin() {
96
- (0, managed_agent_paths_1.appendManagedAgentBinDirsToProcessPath)();
99
+ // Issue #1748: this used to call appendManagedAgentBinDirsToProcessPath(), putting FRAIM's
100
+ // managed dirs on this process's own PATH. desktop-main imports this module, so every Hub
101
+ // inherited that, and command resolution could no longer tell a user install from FRAIM's
102
+ // bundled copy. Resolution now re-reads the machine PATH instead, and FRAIM's directory is
103
+ // reachable there because persistShellPath registers it with the OS at install time.
97
104
  // Issue #1285 (Implementation Strategy §3): remove any managed-agent shim
98
105
  // orphaned in the legacy flat directory by an older FRAIM version, so it
99
106
  // can no longer shadow the current versioned build.
@@ -914,9 +921,6 @@ class FirstRunSessionService {
914
921
  });
915
922
  const pathWithNpm = (0, managed_agent_paths_1.appendBinDirsToPath)(systemPath, [npm.binDir]);
916
923
  const outcome = await (0, managed_agent_install_1.installManagedAgent)({ label: option.label, installPackage: option.installPackage, launchCommand: option.launchCommand }, pathWithNpm, { runProcess, commandVersion });
917
- if (outcome.outcome === 'standard' && outcome.npmGlobalBinDirs.length > 0) {
918
- process.env.PATH = (0, managed_agent_paths_1.appendBinDirsToPath)(pathWithNpm, outcome.npmGlobalBinDirs);
919
- }
920
924
  this.setAgentInstallStatus(agentId, 'needs-sign-in', `Sign in to ${option.label} to activate it.`);
921
925
  appendInstallLog(outcome.outcome === 'standard' ? `agent-installed-standard ${agentId}` : `agent-installed-managed ${agentId}`);
922
926
  this.persist();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.322",
3
+ "version": "2.0.323",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "author": "Sid Mathur <sid.mathur@gmail.com>",
6
6
  "homepage": "https://github.com/mathursrus/FRAIM#readme",
@@ -46,6 +46,7 @@
46
46
  "dist/src/cli/setup/provider-prompts.js",
47
47
  "dist/src/cli/setup/user-level-sync.js",
48
48
  "dist/src/cli/utils/local-folder-sync.js",
49
+ "dist/src/cli/utils/machine-path.js",
49
50
  "dist/src/cli/utils/managed-agent-install.js",
50
51
  "dist/src/cli/utils/managed-agent-paths.js",
51
52
  "dist/src/cli/utils/managed-node-runtime.js",
@@ -212,7 +213,7 @@
212
213
  "electron-updater": "^6.8.9",
213
214
  "express": "^5.2.1",
214
215
  "extract-zip": "^2.0.1",
215
- "fraim": "2.0.322",
216
+ "fraim": "2.0.323",
216
217
  "mongodb": "^7.0.0",
217
218
  "node-cron": "4.2.1",
218
219
  "node-edge-tts": "^1.2.10",
@@ -592,9 +592,11 @@
592
592
  <span class="rb-handoff" id="review-handoff">Comment on the doc to leave inline notes, or type below to coach.</span>
593
593
  </div>
594
594
  <div class="coach-input">
595
- <button class="attach-btn" type="button" id="attach-btn" aria-label="Attach a file or screenshot" title="Attach a file or screenshot">&#128206;</button>
596
595
  <input type="file" id="attach-file-input" multiple accept="image/png,image/jpeg,image/gif,.pdf,.txt,.log" hidden>
597
- <div class="quick-coach-row composer-coach-actions" id="quick-coach-btns" hidden>
596
+ <textarea id="coach-text" aria-label="Type an instruction" placeholder="Type a job name, /handle, or describe what you need..." data-placeholder-idle="Type a job name, /handle, or describe what you need..." data-placeholder-active="Coach this run, or type new instructions..." data-placeholder-armed="Now type what you want done..."></textarea>
597
+ <div class="composer-footer">
598
+ <button class="attach-btn" type="button" id="attach-btn" aria-label="Attach a file or screenshot" title="Attach a file or screenshot">&#128206;</button>
599
+ <div class="quick-coach-row composer-coach-actions" id="quick-coach-btns" hidden>
598
600
  <button class="ghost quick-coach-btn mark-complete-btn composer-icon-btn" type="button" id="mark-complete-btn" aria-label="Mark the job complete" title="Mark complete">&#10003;</button>
599
601
  <button class="run-stop-btn composer-stop-btn" id="run-stop-btn" type="button" hidden aria-label="Stop the employee" title="Stop the employee">&#9209;</button>
600
602
  <div class="coach-dropdown" id="positive-reinforcement-wrap">
@@ -663,13 +665,13 @@
663
665
  title="Ask the employee to use your learned explanation preferences">The way I like it</button>
664
666
  </div>
665
667
  </div>
666
- </div>
667
- <textarea id="coach-text" aria-label="Type an instruction" placeholder="Type a job name, /handle, or describe what you need..." data-placeholder-idle="Type a job name, /handle, or describe what you need..." data-placeholder-active="Coach this run, or type new instructions..." data-placeholder-armed="Now type what you want done..."></textarea>
668
- <span class="active-employee-row composer-agent-row" hidden>
669
- <span class="active-employee-label">Maestro</span>
670
- <select id="active-employee-select" class="employee-select inline" aria-label="Agent Tool"></select>
671
- </span>
668
+ </div>
669
+ <span class="active-employee-row composer-agent-row" hidden>
670
+ <span class="active-employee-label">Maestro</span>
671
+ <select id="active-employee-select" class="employee-select inline" aria-label="Agent Tool"></select>
672
+ </span>
672
673
  <button class="send-button" type="button" id="send" aria-label="Send" title="Send" disabled>↑</button>
674
+ </div>
673
675
  </div>
674
676
  <div class="composer-hint" id="composer-hint">Attach or paste a screenshot, log, or PDF, up to 10&nbsp;MB each.</div>
675
677
  <div class="coach-note" id="coach-note"></div>
@@ -698,40 +700,8 @@
698
700
  is retired. Job browsing moves to the inline #composer-suggestions
699
701
  list; instructions move to the persistent composer's #coach-text.
700
702
  hire-notice is superseded by the non-blocking #hire-strip (#540).
701
- The real secondary options step2 carried - the A/B compare toggle and
702
- the Word document-context card - are not dropped: they moved into
703
- #composer-options below, an always-reachable floating "Options" toggle
704
- pinned near the composer (fixed-positioned, since this div is declared
705
- far from #persistent-composer in the DOM), keeping their exact
706
- ids/markup so the code that populates and reads them is unchanged.
707
- Manager decision (2026-09-14, PR #1660 review round 5): Teach a new
708
- job (#teach-btn) is out of this composer's scope entirely - it opens
709
- its own #teach-job-modal dialog below, unrelated to #persistent-
710
- composer/#composer-options. Picking an agent to run a Delegate Job
711
- with is the inline "Run with:" pills' (#cp-agent-picker) job; installing
712
- a missing one is the composer's own #cp-agent-install-panel "Go to
713
- Manager -> AI Agents" link's job. Options carries no agent-tool
714
- picker of its own. -->
715
- <div id="composer-options">
716
- <button type="button" class="ghost composer-options-toggle" id="composer-options-toggle" aria-expanded="false" aria-controls="composer-options-body">Options</button>
717
- <div id="composer-options-body" hidden>
718
- <div id="word-context-card" class="word-context-card" hidden>
719
- <div class="word-ctx-card-header">
720
- <span class="word-ctx-card-icon">📄</span>
721
- <span class="word-ctx-card-label" id="word-ctx-card-label">Document context</span>
722
- <button type="button" class="word-ctx-card-toggle" id="word-ctx-card-toggle" aria-label="Expand document context">▸</button>
723
- </div>
724
- <div class="word-ctx-card-body" id="word-ctx-card-body" hidden></div>
725
- </div>
726
- <div id="ab-toggle-wrap" hidden>
727
- <label class="ab-toggle-label">
728
- <input type="checkbox" id="ab-toggle">
729
- Compare with Direct mode (no FRAIM)
730
- </label>
731
- <p id="ab-toggle-explanation" hidden>FRAIM runs the job on the left. A direct session with no FRAIM framework runs on the right. Compare results side by side.</p>
732
- </div>
733
- </div>
734
- </div>
703
+ Job selection and agent setup use the inline composer catalog.
704
+ Document context remains in the task-pane context bar. -->
735
705
 
736
706
  <!-- Issue #512: New-project flow (4-step modal) -->
737
707
  <div id="np-modal" class="np-overlay" role="dialog" aria-modal="true" aria-labelledby="np-title" hidden>
@@ -145,9 +145,7 @@ function gatherElements() {
145
145
  // job-search/job-catalog/job-pick-status/hire-notice*/job-persona-filter/
146
146
  // picked-name/picked-desc/instructions were all part of the retired
147
147
  // #modal (legacy 2-step "New job" dialog) - no longer in the DOM.
148
- // Issue #1660 review round 3: employee-select/agent-install-panel (the
149
- // #composer-options popover's own agent-tool picker + install nudge) were
150
- // retired - no longer in the DOM (see index.html's #composer-options).
148
+ // Agent setup and selection use the inline composer controls.
151
149
  'cp-agent-install-panel', 'active-employee-select',
152
150
  // Issue #1292: "this conversation's agent is unavailable" notice.
153
151
  'active-agent-unavailable-note',
@@ -155,11 +153,10 @@ function gatherElements() {
155
153
  'tracker', 'tracker-rows', 'tracker-note',
156
154
  'totals',
157
155
  // Issue #442: A/B mode elements.
158
- 'ab-toggle-wrap', 'ab-direct-panel',
156
+ 'ab-direct-panel',
159
157
  'ab-direct-totals', 'ab-direct-progress', 'ab-direct-send',
160
158
  // Issue #489: Word context elements.
161
159
  'word-context-bar', 'word-ctx-text', 'word-ctx-refresh',
162
- 'word-context-card', 'word-ctx-card-label', 'word-ctx-card-body', 'word-ctx-card-toggle',
163
160
  // Issue #512 S6: review experience (R7) + learnings (R9) + reverse mentoring (R10).
164
161
  'review-actions', 'review-approve', 'review-request-changes', 'review-handoff',
165
162
  // Issue #770: Deliverables panel (single-run artifact home).
@@ -598,7 +595,6 @@ function applyWordContext(ctx, partial) {
598
595
  state.wordContext = ctx || null;
599
596
  }
600
597
  renderWordContextBar();
601
- renderWordContextInModal();
602
598
  renderTaskPaneLauncher();
603
599
  }
604
600
 
@@ -685,28 +681,6 @@ function renderWordContextBar() {
685
681
  }
686
682
  }
687
683
 
688
- function renderWordContextInModal() {
689
- const card = els['word-context-card'];
690
- const label = els['word-ctx-card-label'];
691
- const body = els['word-ctx-card-body'];
692
- if (!card || !label || !body) return;
693
- const wc = state.wordContext;
694
- if (!wc || (!wc.selection && !wc.bodyPreview && !wc.comments?.length)) {
695
- card.hidden = true;
696
- return;
697
- }
698
- card.hidden = false;
699
- // Label line: "3 words selected · MyDoc" or "MyDoc · 1,234 words"
700
- if (wc.selection) {
701
- const words = wc.selection.split(/\s+/).filter(Boolean).length;
702
- label.textContent = `${words} word${words !== 1 ? 's' : ''} selected${wc.docTitle ? ' · ' + wc.docTitle : ''}`;
703
- } else {
704
- label.textContent = (wc.docTitle || 'Document') + (wc.wordCount ? ` · ${wc.wordCount} words` : '');
705
- }
706
- // Body: shows block that will be sent to the agent
707
- body.textContent = buildWordContextBlock(wc);
708
- }
709
-
710
684
  function isTaskPaneSurface() {
711
685
  return document.body.dataset.surface === 'task-pane' || document.body.dataset.surface === 'extension';
712
686
  }
@@ -2332,8 +2306,19 @@ function newConversationId() {
2332
2306
  // #533 R6: remove a run from the Hub. This is a Hub-local projection delete — it
2333
2307
  // drops the conversation from state + localStorage and clears any pointer to it.
2334
2308
  // It never touches artifacts the employee wrote to disk (the confirm copy says so).
2335
- function deleteConversation(id) {
2309
+ async function deleteConversation(id) {
2336
2310
  if (!id) return false;
2311
+ const target = findConversation(id);
2312
+ if (!target) return false;
2313
+ const params = conversationLookupParams(target, target.projectPath);
2314
+ try {
2315
+ await requestJson(`/api/ai-hub/conversations/${encodeURIComponent(id)}?${params.toString()}`, {
2316
+ method: 'DELETE',
2317
+ });
2318
+ } catch (error) {
2319
+ showStatus(error instanceof Error ? error.message : String(error), true);
2320
+ return false;
2321
+ }
2337
2322
  let removed = false;
2338
2323
  for (const key of Object.keys(state.conversations || {})) {
2339
2324
  const list = state.conversations[key] || [];
@@ -2354,7 +2339,6 @@ function deleteConversation(id) {
2354
2339
  if (tfLearningRunByScope[sc] && tfLearningRunByScope[sc].id === id) tfLearningRunByScope[sc] = null;
2355
2340
  }
2356
2341
  }
2357
- persistConversations();
2358
2342
  if (typeof refreshStatusSurfaces === 'function') refreshStatusSurfaces();
2359
2343
  if (typeof renderActive === 'function') renderActive();
2360
2344
  if (typeof showStatus === 'function') showStatus('Run deleted.', false);
@@ -2394,7 +2378,19 @@ function tfBuildRunDeleteConfirm(conv) {
2394
2378
  msg.textContent = 'Delete "' + title + '"? This removes it from the Hub. The run’s artifacts on disk are untouched.';
2395
2379
  const row = document.createElement('div'); row.className = 'dc-row';
2396
2380
  const del = document.createElement('button'); del.className = 'dc-del'; del.type = 'button'; del.textContent = 'Delete run';
2397
- del.addEventListener('click', (e) => { e.stopPropagation(); state.pendingRunDeleteId = null; box.remove(); deleteConversation(conv.id); });
2381
+ del.addEventListener('click', async (e) => {
2382
+ e.stopPropagation();
2383
+ del.disabled = true;
2384
+ del.textContent = 'Deleting...';
2385
+ const removed = await deleteConversation(conv.id);
2386
+ if (removed) {
2387
+ state.pendingRunDeleteId = null;
2388
+ box.remove();
2389
+ return;
2390
+ }
2391
+ del.disabled = false;
2392
+ del.textContent = 'Delete run';
2393
+ });
2398
2394
  const cancel = document.createElement('button'); cancel.className = 'dc-cancel'; cancel.type = 'button'; cancel.textContent = 'Cancel';
2399
2395
  cancel.addEventListener('click', (e) => { e.stopPropagation(); state.pendingRunDeleteId = null; box.remove(); });
2400
2396
  row.appendChild(del); row.appendChild(cancel);
@@ -5345,55 +5341,40 @@ function positionCoachDropdown(dropdown, btn) {
5345
5341
  if (!dropdown || !btn || dropdown.hidden) return;
5346
5342
  const margin = 6;
5347
5343
  const viewportPad = 8;
5344
+ const visualViewport = window.visualViewport;
5345
+ const viewportLeft = visualViewport ? visualViewport.offsetLeft : 0;
5346
+ const viewportTop = visualViewport ? visualViewport.offsetTop : 0;
5347
+ const viewportWidth = visualViewport ? visualViewport.width : window.innerWidth;
5348
+ const viewportHeight = visualViewport ? visualViewport.height : window.innerHeight;
5349
+ const viewportRight = viewportLeft + viewportWidth;
5350
+ const viewportBottom = viewportTop + viewportHeight;
5348
5351
  ensureCoachDropdownPortal(dropdown);
5349
5352
  const rect = btn.getBoundingClientRect();
5350
5353
  const minWidth = Math.max(200, Math.ceil(rect.width));
5351
5354
  dropdown.style.minWidth = minWidth + 'px';
5352
- dropdown.style.maxWidth = Math.max(220, window.innerWidth - viewportPad * 2) + 'px';
5353
- dropdown.style.maxHeight = '';
5355
+ dropdown.style.maxWidth = Math.max(minWidth, viewportWidth - viewportPad * 2) + 'px';
5356
+ dropdown.style.maxHeight = Math.max(1, viewportHeight - viewportPad * 2) + 'px';
5354
5357
  dropdown.style.visibility = 'hidden';
5355
5358
  dropdown.style.transform = 'none';
5356
5359
  const menuRect = dropdown.getBoundingClientRect();
5357
5360
  const menuWidth = Math.max(menuRect.width, minWidth);
5358
5361
  const menuHeight = menuRect.height || 160;
5359
- const viewportMaxLeft = Math.max(viewportPad, window.innerWidth - menuWidth - viewportPad);
5360
- const wouldOverflowRight = rect.left + menuWidth > window.innerWidth - viewportPad;
5362
+ const viewportMinLeft = viewportLeft + viewportPad;
5363
+ const viewportMaxLeft = Math.max(viewportMinLeft, viewportRight - menuWidth - viewportPad);
5364
+ const wouldOverflowRight = rect.left + menuWidth > viewportRight - viewportPad;
5361
5365
  const idealLeft = wouldOverflowRight ? rect.right - menuWidth : rect.left;
5362
- const left = Math.min(Math.max(viewportPad, idealLeft), viewportMaxLeft);
5363
- const availableBelow = Math.max(0, window.innerHeight - rect.bottom - margin - viewportPad);
5364
- const availableAbove = Math.max(0, rect.top - margin - viewportPad);
5365
- const opensBelow = availableBelow >= Math.min(menuHeight, 160) || availableBelow >= availableAbove;
5366
- const availableSpace = opensBelow ? availableBelow : availableAbove;
5366
+ const left = Math.min(Math.max(viewportMinLeft, idealLeft), viewportMaxLeft);
5367
+ const availableBelow = Math.max(0, viewportBottom - rect.bottom - margin - viewportPad);
5368
+ const availableAbove = Math.max(0, rect.top - viewportTop - margin - viewportPad);
5369
+ const opensAbove = availableAbove >= menuHeight || (availableBelow < menuHeight && availableAbove >= availableBelow);
5370
+ const availableSpace = opensAbove ? availableAbove : availableBelow;
5367
5371
  const renderedHeight = Math.min(menuHeight, availableSpace || menuHeight);
5368
- let top;
5369
- if (opensBelow) {
5370
- top = Math.min(rect.bottom + margin, window.innerHeight - renderedHeight - viewportPad);
5371
- } else {
5372
- top = Math.max(viewportPad, rect.top - renderedHeight - margin);
5373
- }
5374
- // Issue #1723: the composer textarea (#coach-text) shares the same
5375
- // toolbar box as every coach-dropdown trigger, and is taller than the
5376
- // trigger row - it extends both above and below the trigger's own edges.
5377
- // A placement chosen purely from the trigger's own rect (either branch
5378
- // above) can still land on top of the textarea. If it does, push the
5379
- // menu to whichever side of the textarea actually clears it and has
5380
- // room; otherwise leave the original placement (no collision-free
5381
- // option fits in the viewport).
5382
- const composerTextarea = document.getElementById('coach-text');
5383
- const textareaRect = composerTextarea ? composerTextarea.getBoundingClientRect() : null;
5384
- const overlapsTextarea = textareaRect && top < textareaRect.bottom && top + renderedHeight > textareaRect.top;
5385
- if (overlapsTextarea) {
5386
- const spaceBelowTextarea = window.innerHeight - textareaRect.bottom - margin - viewportPad;
5387
- const spaceAboveTextarea = textareaRect.top - margin - viewportPad;
5388
- if (spaceBelowTextarea >= renderedHeight) {
5389
- top = textareaRect.bottom + margin;
5390
- } else if (spaceAboveTextarea >= renderedHeight) {
5391
- top = Math.max(viewportPad, textareaRect.top - renderedHeight - margin);
5392
- }
5393
- }
5372
+ const top = opensAbove
5373
+ ? Math.max(viewportTop + viewportPad, rect.top - renderedHeight - margin)
5374
+ : Math.min(rect.bottom + margin, viewportBottom - renderedHeight - viewportPad);
5394
5375
  dropdown.style.left = Math.round(left) + 'px';
5395
5376
  dropdown.style.top = Math.round(top) + 'px';
5396
- dropdown.style.maxHeight = Math.max(80, Math.floor(availableSpace || (window.innerHeight - viewportPad * 2))) + 'px';
5377
+ dropdown.style.maxHeight = Math.max(1, Math.floor(availableSpace || (viewportHeight - viewportPad * 2))) + 'px';
5397
5378
  dropdown.style.visibility = '';
5398
5379
  }
5399
5380
 
@@ -6084,7 +6065,33 @@ function attachmentUrlFor(conv, attachmentId) {
6084
6065
  return `/api/ai-hub/conversations/${encodeURIComponent(conv.id)}/attachments/${encodeURIComponent(attachmentId)}?${params.toString()}`;
6085
6066
  }
6086
6067
 
6068
+ const COMPOSER_MIN_HEIGHT = 54;
6069
+ const COMPOSER_MAX_HEIGHT = 360;
6070
+ const COMPOSER_VIEWPORT_RATIO = 0.45;
6071
+
6072
+ function composerVisibleViewportHeight() {
6073
+ return window.visualViewport && window.visualViewport.height
6074
+ ? window.visualViewport.height
6075
+ : window.innerHeight;
6076
+ }
6077
+
6078
+ function resizeComposerTextarea() {
6079
+ const textarea = els['coach-text'] || document.getElementById('coach-text');
6080
+ if (!textarea) return;
6081
+ const cap = Math.max(
6082
+ COMPOSER_MIN_HEIGHT,
6083
+ Math.min(COMPOSER_MAX_HEIGHT, composerVisibleViewportHeight() * COMPOSER_VIEWPORT_RATIO)
6084
+ );
6085
+ textarea.style.height = '0px';
6086
+ const contentHeight = textarea.scrollHeight;
6087
+ const height = Math.max(COMPOSER_MIN_HEIGHT, Math.min(contentHeight, cap));
6088
+ textarea.style.height = Math.ceil(height) + 'px';
6089
+ textarea.style.overflowY = contentHeight > height + 1 ? 'auto' : 'hidden';
6090
+ positionOpenCoachDropdowns();
6091
+ }
6092
+
6087
6093
  function syncSendButton() {
6094
+ resizeComposerTextarea();
6088
6095
  const conv = activeConversation();
6089
6096
  const hasText = els['coach-text'].value.trim().length > 0;
6090
6097
  if (isManagedDelegationChild(conv)) {
@@ -8165,7 +8172,6 @@ function renderCpAgentPicker() {
8165
8172
  pill.addEventListener('click', () => {
8166
8173
  state.cpEmployee = e.id;
8167
8174
  renderCpAgentPicker();
8168
- updateAbToggleVisibility();
8169
8175
  });
8170
8176
  }
8171
8177
  picker.appendChild(pill);
@@ -8381,18 +8387,6 @@ function showRerunToast(msg) {
8381
8387
  // job browsing is now the always-inline composer-suggestions list
8382
8388
  // (renderCpRows/buildCpRow), which every catalog job already flows through.
8383
8389
 
8384
- function updateAbToggleVisibility() {
8385
- const wrap = els['ab-toggle-wrap'];
8386
- if (!wrap) return;
8387
- // Issue #1657 Treatment 3: state.cpEmployee (the inline "Run with:" pills)
8388
- // is the only picker now (the #composer-options popover's own agent-tool
8389
- // select was removed per PR #1660 review round 3 - see index.html; Teach
8390
- // is out of this composer entirely as of round 5).
8391
- const empId = baseHostIdForAgent(state.cpEmployee || state.selectedEmployeeId || 'claude');
8392
- const emp = (state.bootstrap && state.bootstrap.employees || []).find(e => e.id === empId);
8393
- wrap.hidden = !(emp && emp.supportsRaw);
8394
- }
8395
-
8396
8390
  function hubEmployees() {
8397
8391
  return state.bootstrap?.employees || [];
8398
8392
  }
@@ -8660,7 +8654,6 @@ async function refreshEmployees() {
8660
8654
  const bootstrap = await requestJson(`/api/ai-hub/bootstrap?projectPath=${encodeURIComponent(state.projectPath)}`);
8661
8655
  applyBootstrap(bootstrap);
8662
8656
  renderCpAgentPicker();
8663
- updateAbToggleVisibility();
8664
8657
  // Issue #1256 (slice b): re-render the install panel + status cards too, not just the
8665
8658
  // employee picker, so a stale-roster correction (or a manual re-check) is visible
8666
8659
  // everywhere the roster is shown, not only in the picker this function originally served.
@@ -8731,9 +8724,6 @@ async function startRun(job, instructions, employeeId, preassignedConvId, invoke
8731
8724
  // looking at. stubPath resolution and FRAIM invocation are now server-side.
8732
8725
  const effectiveInstructions = await withWordContext(instructions);
8733
8726
 
8734
- // Issue #442: read the A/B toggle state from the modal before it closes.
8735
- const abToggle = document.getElementById('ab-toggle');
8736
- const isAB = !isFreeform && abToggle && abToggle.checked;
8737
8727
  const assignedPersonaKey = assignedPersonaKeyForJob(job);
8738
8728
  const assignedPersona = assignedPersonaKey && assignedPersonaKey.startsWith('custom:')
8739
8729
  ? (typeof tfPersonaByKey === 'function' ? tfPersonaByKey(assignedPersonaKey) : null)
@@ -8769,8 +8759,7 @@ async function startRun(job, instructions, employeeId, preassignedConvId, invoke
8769
8759
  artifacts: [],
8770
8760
  createdAt: startedAt,
8771
8761
  lastUpdatedAt: startedAt,
8772
- // Issue #442: A/B mode fields (only set when toggle was on).
8773
- compareMode: isAB ? 'ab' : undefined,
8762
+ // Historical comparison fields retained for shared conversation rendering.
8774
8763
  compareRunId: null,
8775
8764
  compareRun: null,
8776
8765
  // Issue #489: capture selection state at job-start so write-back knows insert-after vs append.
@@ -8812,7 +8801,6 @@ async function startRun(job, instructions, employeeId, preassignedConvId, invoke
8812
8801
  conversationId: conv.id,
8813
8802
  conversationTitle: conv.title,
8814
8803
  instructions: effectiveInstructions,
8815
- ...(isAB ? { compareMode: 'ab', directInstructions: effectiveInstructions } : {}),
8816
8804
  // Issue #1657 R7/R9: attachments staged in the palette/ad-hoc/Teach/
8817
8805
  // task-pane composer, already uploaded against `conv.id` by the
8818
8806
  // caller before this request.
@@ -10206,41 +10194,12 @@ function wireEvents() {
10206
10194
  });
10207
10195
  }
10208
10196
 
10209
- // Issue #442: keep A/B toggle visible only when the selected employee supports
10210
- // direct-path invocation (supportsRaw). Also wire the explanation paragraph.
10211
- // Issue #1660 review round 5: the inline "Run with:" pills (#cp-agent-picker)
10212
- // are the only agent picker now - see their own click handler in
10213
- // renderCpAgentPicker for where state.cpEmployee/updateAbToggleVisibility
10214
- // are kept live. Teach is out of this composer entirely.
10197
+ // The inline agent picker owns agent selection; clearing the armed job
10198
+ // returns this composer to its catalog.
10215
10199
  const armedJobClearBtn = document.getElementById('cp-armed-job-clear');
10216
10200
  if (armedJobClearBtn) {
10217
10201
  armedJobClearBtn.addEventListener('click', () => clearArmedJob());
10218
10202
  }
10219
- const composerOptionsToggle = document.getElementById('composer-options-toggle');
10220
- const composerOptionsBody = document.getElementById('composer-options-body');
10221
- const composerOptions = document.getElementById('composer-options');
10222
- if (composerOptions) document.getElementById('persistent-composer')?.appendChild(composerOptions);
10223
- if (composerOptionsToggle && composerOptionsBody) {
10224
- composerOptionsToggle.addEventListener('click', () => {
10225
- const expanded = composerOptionsBody.hidden;
10226
- composerOptionsBody.hidden = !expanded;
10227
- composerOptionsToggle.setAttribute('aria-expanded', String(expanded));
10228
- // PR #1660 review round 3: Options no longer carries its own agent-tool
10229
- // picker (see index.html) - refresh only the A/B toggle's visibility,
10230
- // which depends on the inline "Run with:" pills' live selection.
10231
- if (expanded) updateAbToggleVisibility();
10232
- });
10233
- }
10234
- const abToggleCheckbox = document.getElementById('ab-toggle');
10235
- if (abToggleCheckbox) {
10236
- abToggleCheckbox.addEventListener('change', () => {
10237
- const exp = document.getElementById('ab-toggle-explanation');
10238
- if (exp) exp.hidden = !abToggleCheckbox.checked;
10239
- // R1.3: Start button label changes to "Start A/B test" when toggle is on.
10240
- if (els['start']) els['start'].textContent = abToggleCheckbox.checked ? 'Start A/B test' : 'Start';
10241
- });
10242
- }
10243
-
10244
10203
  // Issue #442: Direct panel send button.
10245
10204
  const abDirectInput = document.getElementById('ab-direct-input');
10246
10205
  if (abDirectInput && els['ab-direct-send']) {
@@ -10348,7 +10307,21 @@ function wireEvents() {
10348
10307
  const inDropdown = e.target.closest('.coach-dropdown, .coach-dropdown-menu');
10349
10308
  if (!inDropdown) closeAllCoachDropdowns();
10350
10309
  });
10351
- window.addEventListener('resize', positionOpenCoachDropdowns);
10310
+ window.addEventListener('resize', resizeComposerTextarea);
10311
+ if (window.visualViewport) {
10312
+ window.visualViewport.addEventListener('resize', resizeComposerTextarea);
10313
+ window.visualViewport.addEventListener('scroll', positionOpenCoachDropdowns);
10314
+ }
10315
+ const composerInput = document.querySelector('#persistent-composer .coach-input');
10316
+ if (composerInput && typeof ResizeObserver !== 'undefined') {
10317
+ let observedComposerWidth = -1;
10318
+ new ResizeObserver((entries) => {
10319
+ const width = entries[0] ? entries[0].contentRect.width : composerInput.getBoundingClientRect().width;
10320
+ if (Math.abs(width - observedComposerWidth) < 0.5) return;
10321
+ observedComposerWidth = width;
10322
+ resizeComposerTextarea();
10323
+ }).observe(composerInput);
10324
+ }
10352
10325
  document.addEventListener('scroll', positionOpenCoachDropdowns, true);
10353
10326
  // #937: the approve dropdown no longer auto-closes on the conversation poll, so
10354
10327
  // give it explicit dismissal. A click anywhere outside the combo closes it (the
@@ -10415,19 +10388,10 @@ function wireEvents() {
10415
10388
  if (els['word-ctx-refresh']) {
10416
10389
  els['word-ctx-refresh'].addEventListener('click', async () => {
10417
10390
  const fresh = await requestWordContext('get-context');
10418
- if (fresh) { state.wordContext = fresh; renderWordContextBar(); renderWordContextInModal(); }
10419
- });
10420
- }
10421
- // Word context card expand/collapse toggle.
10422
- if (els['word-ctx-card-toggle']) {
10423
- els['word-ctx-card-toggle'].addEventListener('click', () => {
10424
- const body = els['word-ctx-card-body'];
10425
- if (!body) return;
10426
- const expanded = !body.hidden;
10427
- body.hidden = expanded;
10428
- els['word-ctx-card-toggle'].textContent = expanded ? '▸' : '▾';
10391
+ if (fresh) { state.wordContext = fresh; renderWordContextBar(); }
10429
10392
  });
10430
10393
  }
10394
+
10431
10395
  }
10432
10396
 
10433
10397
  // ---------------------------------------------------------------------------
@@ -3163,31 +3163,6 @@ button.small { padding: 4px 10px; font-size: 12px; }
3163
3163
 
3164
3164
  /* ── Issue #442: A/B Testing Mode ─────────────────────────────────────── */
3165
3165
 
3166
- /* Modal step 2 — A/B toggle */
3167
- #ab-toggle-wrap {
3168
- margin-top: 12px;
3169
- padding: 10px 12px;
3170
- background: var(--accent-soft);
3171
- border-radius: 8px;
3172
- border: 1px solid var(--line);
3173
- }
3174
- .ab-toggle-label {
3175
- display: flex;
3176
- align-items: center;
3177
- gap: 8px;
3178
- font-size: 13px;
3179
- font-weight: 500;
3180
- color: var(--text);
3181
- cursor: pointer;
3182
- }
3183
- .ab-toggle-label input[type="checkbox"] { cursor: pointer; }
3184
- #ab-toggle-explanation {
3185
- margin: 8px 0 0;
3186
- font-size: 12px;
3187
- color: var(--muted);
3188
- line-height: 1.5;
3189
- }
3190
-
3191
3166
  /* Rail badge */
3192
3167
  .ab-badge {
3193
3168
  margin-left: auto;
@@ -3673,53 +3648,6 @@ body:is([data-surface="task-pane"],[data-surface="extension"]) .word-context-bar
3673
3648
  }
3674
3649
  .word-ctx-refresh:hover { color: var(--accent); }
3675
3650
 
3676
- /* ── Word context card in step 2 modal ────────────────────────────────────── */
3677
- .word-context-card {
3678
- border: 1px solid var(--border, rgba(0,0,0,0.10));
3679
- border-radius: 8px;
3680
- margin-top: 8px;
3681
- font-size: 12px;
3682
- overflow: hidden;
3683
- }
3684
- .word-ctx-card-header {
3685
- display: flex;
3686
- align-items: center;
3687
- gap: 6px;
3688
- padding: 7px 10px;
3689
- background: var(--surface, rgba(0,0,0,0.03));
3690
- cursor: default;
3691
- }
3692
- .word-ctx-card-label {
3693
- flex: 1;
3694
- font-weight: 600;
3695
- font-size: 11px;
3696
- color: var(--muted);
3697
- overflow: hidden;
3698
- text-overflow: ellipsis;
3699
- white-space: nowrap;
3700
- }
3701
- .word-ctx-card-toggle {
3702
- background: none;
3703
- border: none;
3704
- color: var(--muted);
3705
- cursor: pointer;
3706
- font-size: 11px;
3707
- padding: 0 2px;
3708
- flex-shrink: 0;
3709
- }
3710
- .word-ctx-card-toggle:hover { color: var(--accent); }
3711
- .word-ctx-card-body {
3712
- padding: 8px 10px;
3713
- color: var(--fg, #231e17);
3714
- font-size: 11px;
3715
- line-height: 1.5;
3716
- white-space: pre-wrap;
3717
- word-break: break-word;
3718
- max-height: 120px;
3719
- overflow-y: auto;
3720
- border-top: 1px solid var(--border, rgba(0,0,0,0.07));
3721
- }
3722
-
3723
3651
  /* ── Extension surface overrides (Chrome/browser extensions) ─────────────────
3724
3652
  Inherits all task-pane layout rules (via :is() above) but hides the
3725
3653
  project label since extensions work on web pages, not local project paths. */
@@ -4170,9 +4098,18 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4170
4098
  .tree-job .tj-del:hover { color: var(--danger, #d2261f); border-color: var(--danger, #d2261f); }
4171
4099
  .del-confirm { margin: 8px 14px 4px; background: var(--surface); border: 1px solid var(--line);
4172
4100
  border-radius: 10px; padding: 10px 12px; font-size: 12px; color: var(--text); line-height: 1.5; }
4173
- .del-confirm .dc-row { display: flex; gap: 8px; margin-top: 8px; }
4101
+ /* #1740: the confirm is rendered inside the ~175px employee rail. An unwrappable action
4102
+ row makes the box's min-content width exceed the rail, which clipped Cancel and forced a
4103
+ horizontal rail scrollbar. Wrapping the row lets the box shrink to the rail. */
4104
+ .del-confirm .dc-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
4174
4105
  .del-confirm button { font: inherit; font-size: 12px; border-radius: 7px; padding: 5px 12px; cursor: pointer; }
4175
- .del-confirm .dc-del { background: var(--danger, #d2261f); color: #fff; border: none; }
4106
+ /* #1740: the in-flight label ("Deleting...") is a single unbreakable token while the idle
4107
+ label ("Delete run") wraps to two lines. Without a stable box the control widened and the
4108
+ confirm collapsed ~34px mid-click. Keep both labels on one line and reserve the width of
4109
+ the wider of the two (measured 79.3px for "Delete run" at 12px/400) so the swap is inert.
4110
+ The regression test asserts both the button and the confirm box are unchanged by it. */
4111
+ .del-confirm .dc-del { background: var(--danger, #d2261f); color: #fff; border: none;
4112
+ min-width: 80px; white-space: nowrap; }
4176
4113
  .del-confirm .dc-del:disabled { opacity: .55; cursor: not-allowed; }
4177
4114
  .del-confirm .dc-cancel { background: var(--surface); border: 1px solid var(--line); color: var(--text); }
4178
4115
  .del-confirm .dc-detail { margin-top: 6px; color: var(--muted); }
@@ -6908,6 +6845,7 @@ img.eh-av { object-fit: cover; background: var(--surface); }
6908
6845
  siblings get flex:1 so whichever is visible fills the remaining height and
6909
6846
  pushes the composer to the true bottom edge. */
6910
6847
  #empty { flex: 1; min-height: 0; overflow-y: auto; }
6848
+ #conversation:has(> #persistent-composer) { padding-bottom: 8px; }
6911
6849
  #persistent-composer {
6912
6850
  flex-shrink: 0;
6913
6851
  display: flex;
@@ -6915,6 +6853,67 @@ img.eh-av { object-fit: cover; background: var(--surface); }
6915
6853
  position: relative;
6916
6854
  }
6917
6855
 
6856
+ /* Issue #1744: keep the draft and its controls in one bordered composer while
6857
+ letting the text area size independently above the always-reachable footer. */
6858
+ #persistent-composer .coach-input {
6859
+ border: 1px solid var(--line);
6860
+ border-radius: 8px;
6861
+ background: var(--surface);
6862
+ overflow: visible;
6863
+ }
6864
+ #persistent-composer .coach-input:focus-within { border-color: var(--accent); }
6865
+ #persistent-composer .coach-input textarea {
6866
+ min-height: 54px;
6867
+ max-height: none;
6868
+ height: 54px;
6869
+ padding: 12px 16px;
6870
+ border: 0;
6871
+ border-radius: 8px 8px 0 0;
6872
+ resize: none;
6873
+ overflow-y: hidden;
6874
+ line-height: 22px;
6875
+ box-sizing: border-box;
6876
+ }
6877
+ #persistent-composer .composer-footer {
6878
+ display: flex;
6879
+ align-items: center;
6880
+ gap: 6px;
6881
+ min-height: 46px;
6882
+ padding: 7px;
6883
+ flex-wrap: wrap;
6884
+ }
6885
+ #persistent-composer .composer-footer .attach-btn,
6886
+ #persistent-composer .composer-footer .composer-coach-actions,
6887
+ #persistent-composer .composer-footer .composer-agent-row,
6888
+ #persistent-composer .composer-footer #send {
6889
+ position: static;
6890
+ inset: auto;
6891
+ transform: none;
6892
+ }
6893
+ #persistent-composer .composer-footer .composer-coach-actions {
6894
+ display: flex;
6895
+ flex: 0 1 auto;
6896
+ flex-wrap: wrap;
6897
+ gap: 5px;
6898
+ margin: 0;
6899
+ overflow: visible;
6900
+ }
6901
+ #persistent-composer .composer-footer .composer-coach-actions[hidden] { display: none; }
6902
+ #persistent-composer .composer-footer .composer-agent-row {
6903
+ display: flex;
6904
+ min-width: 0;
6905
+ max-width: 150px;
6906
+ margin-left: auto;
6907
+ }
6908
+ #persistent-composer .composer-footer .composer-agent-row[hidden] { display: none; }
6909
+ #persistent-composer .composer-footer .composer-agent-row .employee-select.inline { max-width: 120px; }
6910
+ #persistent-composer .composer-footer #send { flex-shrink: 0; }
6911
+
6912
+ @media (max-width: 650px) {
6913
+ #persistent-composer .composer-footer { gap: 4px; }
6914
+ #persistent-composer .composer-footer .composer-agent-row { margin-left: auto; }
6915
+ }
6916
+
6918
6917
  /* Full-panel A/B split (#442) turns #conversation into a flex row so
6919
6918
  #active-conv/#ab-direct-panel sit side by side - but #persistent-composer
6920
6919
  is now a third child of #conversation (not nested in either pane) and must
@@ -6969,51 +6968,6 @@ img.eh-av { object-fit: cover; background: var(--surface); }
6969
6968
  .composer-suggestions[hidden] { display: none; }
6970
6969
  .cp-employee-footer[hidden] { display: none; }
6971
6970
 
6972
- /* Composer options popover (word-context detail, Agent Tool select + CLI
6973
- install nudge, A/B compare toggle) - the real secondary controls #step2
6974
- used to carry, kept reachable from the composer without permanently
6975
- occupying the instruction input. Event setup places #composer-options in
6976
- the persistent composer's flow; the expanded panel opens above its button. */
6977
- #composer-options {
6978
- position: relative;
6979
- align-self: flex-end;
6980
- margin-top: 4px;
6981
- z-index: var(--z-dropdown-menu);
6982
- display: flex;
6983
- flex-direction: column;
6984
- align-items: flex-end;
6985
- gap: 8px;
6986
- }
6987
- body:has(.modal-overlay:not([hidden])) #composer-options { visibility: hidden; }
6988
- .composer-options-toggle {
6989
- font-size: 12px;
6990
- font-weight: 600;
6991
- padding: 6px 14px;
6992
- border-radius: 20px;
6993
- background: var(--surface);
6994
- border: 1px solid var(--line);
6995
- color: var(--muted);
6996
- box-shadow: 0 2px 10px rgba(0,0,0,.10);
6997
- cursor: pointer;
6998
- }
6999
- .composer-options-toggle:hover { color: var(--text); background: var(--soft); }
7000
- .composer-options-toggle[aria-expanded="true"] { color: var(--accent); border-color: var(--accent); }
7001
- #composer-options-body {
7002
- position: absolute;
7003
- bottom: calc(100% + 8px);
7004
- right: 0;
7005
- width: 300px;
7006
- max-width: calc(100vw - 48px);
7007
- max-height: 60vh;
7008
- overflow-y: auto;
7009
- background: var(--surface);
7010
- border: 1px solid var(--line);
7011
- border-radius: 10px;
7012
- box-shadow: 0 10px 32px rgba(0,0,0,.18);
7013
- padding: 14px;
7014
- }
7015
- #composer-options-body[hidden] { display: none; }
7016
-
7017
6971
  /* Issue #1657 post-launch fix: the armed-job chip shown while a catalog job
7018
6972
  is committed and the box is cleared for the manager to type instructions
7019
6973
  (see armSelectedJob in script.js). Sits directly above the input, in the