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.
- package/dist/src/ai-hub/conversation-search-projection.js +9 -0
- package/dist/src/ai-hub/conversation-store.js +37 -5
- package/dist/src/ai-hub/server.js +20 -3
- package/dist/src/cli/doctor/checks/agent-cli-health-checks.js +18 -12
- package/dist/src/cli/mcp/command-resolution.js +7 -2
- package/dist/src/cli/setup/ide-invocation-surfaces.js +1 -1
- package/dist/src/cli/utils/machine-path.js +165 -0
- package/dist/src/cli/utils/managed-agent-install.js +6 -1
- package/dist/src/cli/utils/managed-agent-paths.js +54 -10
- package/dist/src/first-run/session-service.js +11 -7
- package/package.json +3 -2
- package/public/ai-hub/index.html +12 -42
- package/public/ai-hub/script.js +94 -130
- package/public/ai-hub/styles.css +73 -119
|
@@ -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
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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,
|
|
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'
|
|
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
|
|
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(
|
|
360
|
-
:
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|
package/public/ai-hub/index.html
CHANGED
|
@@ -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">📎</button>
|
|
596
595
|
<input type="file" id="attach-file-input" multiple accept="image/png,image/jpeg,image/gif,.pdf,.txt,.log" hidden>
|
|
597
|
-
<
|
|
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">📎</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">✓</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">⏹</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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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 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
|
-
|
|
702
|
-
|
|
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>
|
package/public/ai-hub/script.js
CHANGED
|
@@ -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
|
-
//
|
|
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-
|
|
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) => {
|
|
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(
|
|
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
|
|
5360
|
-
const
|
|
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(
|
|
5363
|
-
const availableBelow = Math.max(0,
|
|
5364
|
-
const availableAbove = Math.max(0, rect.top - margin - viewportPad);
|
|
5365
|
-
const
|
|
5366
|
-
const availableSpace =
|
|
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
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
//
|
|
10210
|
-
//
|
|
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',
|
|
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();
|
|
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
|
// ---------------------------------------------------------------------------
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|