fix: improve Windows UX and stabilize chat/session behavior across runtimes (#693)

* fix: preserve unsent prompt when adding editor context in VS Code

* fix: append Add to chat selections as markdown blocks with stable spacing

Convert selected assistant content to markdown before appending
Wrap each Add to chat selection in an `md` fenced block
Preserve multiline composer formatting across repeated appends

* fix: normalize persisted Windows paths to prevent identity mismatches

* fix: hide Windows subprocess console popups across server tasks

Hide OpenCode startup and shell command child windows in the web server
Apply windowsHide to cloudflared and skills-catalog git subprocesses
Cover remaining git service exec paths that could surface console windows

* fix: restore chat auto re-pin when reaching bottom

Re-pin now triggers when scrolling back into the bottom zone, not only via the button.
Upward user scroll intent still unpins immediately and is not overridden by re-pin.
Unified bottom/re-pin threshold logic to reduce sensitivity mismatches.

* fix: restore chat scroll release on mobile during streaming

Restores pinned-scroll release on touch scroll up so mobile users can leave auto-follow while streaming.
Improves re-pin behavior near bottom to avoid sticky or inconsistent pin states.
Includes related chat UI and dependency updates in the same change set.

* fix: hide daemon startup probe consoles on Windows

* fix: prevent pinned scroll tug-of-war during streaming

* fix: prefer git.exe to avoid Windows diff popup flashes

* fix: prefer git.exe discovery in Windows git flows

* fix: avoid where probes in Windows git resolution

* fix: avoid update-check subprocess flashes on Windows

* fix: normalize read file path labels

* feat: add OpenChamber defaults and improve theme ports

Add new OpenChamber light and dark themes
Regenerate imported themes with stronger surface mapping
Set OpenChamber themes as the default top options

* fix: stabilize chat pin and unpin behavior during streaming

Restores reliable unpin on upward wheel and touch gestures while auto-follow is active.
Prevents immediate re-pin while the user is actively scrolling upward near the bottom.
Keeps smooth follow-to-bottom behavior while reducing scroll tug-of-war.

* fix: suppress Windows command popups in VSCode runtime processes

Hide spawned git and server process windows in VS Code runtime
Extend hidden-window handling to server port cleanup and reveal commands
Keep behavior unchanged on non-Windows platforms
This commit is contained in:
Bohdan Triapitsyn
2026-03-17 13:18:54 +02:00
committed by GitHub
parent a07c068b66
commit 3123de5f43
49 changed files with 8473 additions and 1183 deletions
+6 -1
View File
@@ -56,7 +56,11 @@ export async function checkCloudflaredAvailable() {
const cfPath = await searchPathFor('cloudflared');
if (cfPath) {
try {
const result = spawnSync(cfPath, ['--version'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
const result = spawnSync(cfPath, ['--version'], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
return { available: true, path: cfPath, version: result.stdout.trim() };
}
@@ -96,6 +100,7 @@ Or visit: https://developers.cloudflare.com/cloudflare-one/networks/connectors/c
const spawnCloudflared = (args, envOverrides = {}, resolvedBinaryPath = 'cloudflared') => spawn(resolvedBinaryPath, args, {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
env: {
...process.env,
CF_TELEMETRY_DISABLE: '1',
+117 -4
View File
@@ -8,6 +8,116 @@ import { promisify } from 'util';
const fsp = fs.promises;
const execFileAsync = promisify(execFile);
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
let resolvedGitBinary = null;
const isExecutableFile = (candidate) => {
if (typeof candidate !== 'string' || candidate.trim().length === 0) {
return false;
}
try {
const stat = fs.statSync(candidate);
if (!stat.isFile()) {
return false;
}
if (process.platform === 'win32') {
const ext = path.extname(candidate).toLowerCase();
return ext.length === 0 || ext === '.exe' || ext === '.cmd' || ext === '.bat' || ext === '.com';
}
fs.accessSync(candidate, fs.constants.X_OK);
return true;
} catch {
return false;
}
};
const normalizeGitExecutableCandidate = (candidate) => {
if (typeof candidate !== 'string') {
return null;
}
const trimmed = candidate.trim();
if (!trimmed) {
return null;
}
const ext = path.extname(trimmed).toLowerCase();
if (ext === '.cmd' || ext === '.bat' || ext === '.com') {
const exeCandidate = trimmed.slice(0, -ext.length) + '.exe';
if (isExecutableFile(exeCandidate)) {
return exeCandidate;
}
}
return trimmed;
};
const listPathExecutableCandidates = (binaryName) => {
const currentPath = process.env.PATH || '';
const seen = new Set();
const matches = [];
for (const segment of currentPath.split(path.delimiter)) {
const dir = typeof segment === 'string' ? segment.trim() : '';
if (!dir || seen.has(dir)) {
continue;
}
seen.add(dir);
matches.push(path.join(dir, binaryName));
}
return matches;
};
const listWindowsGitInstallCandidates = () => {
const roots = [
process.env.ProgramFiles,
process.env['ProgramFiles(x86)'],
process.env.LocalAppData,
]
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter(Boolean);
const candidates = [];
for (const root of roots) {
candidates.push(path.join(root, 'Git', 'cmd', 'git.exe'));
candidates.push(path.join(root, 'Git', 'bin', 'git.exe'));
candidates.push(path.join(root, 'Git', 'mingw64', 'bin', 'git.exe'));
candidates.push(path.join(root, 'Programs', 'Git', 'cmd', 'git.exe'));
candidates.push(path.join(root, 'Programs', 'Git', 'bin', 'git.exe'));
}
return candidates;
};
const resolveGitBinary = () => {
if (process.platform !== 'win32') {
return 'git';
}
if (resolvedGitBinary) {
return resolvedGitBinary;
}
const explicit = [process.env.GIT_BINARY, process.env.OPENCHAMBER_GIT_BINARY]
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter(Boolean);
for (const candidate of explicit) {
if (isExecutableFile(candidate)) {
resolvedGitBinary = candidate;
return resolvedGitBinary;
}
}
const discovered = [
...listPathExecutableCandidates('git.exe'),
...listPathExecutableCandidates('git'),
...listWindowsGitInstallCandidates(),
]
.map(normalizeGitExecutableCandidate)
.filter(Boolean)
.filter((candidate) => isExecutableFile(candidate));
const preferredExe = discovered.find((candidate) => candidate.toLowerCase().endsWith('.exe'));
resolvedGitBinary = preferredExe || discovered[0] || 'git.exe';
return resolvedGitBinary;
};
const getGitBinary = () => resolveGitBinary();
/**
* Escape an SSH key path for use in core.sshCommand.
@@ -126,10 +236,11 @@ const buildGitEnv = async () => {
const createGit = async (directory) => {
const env = await buildGitEnv();
const spawnOptions = { windowsHide: true };
const binary = getGitBinary();
if (!directory) {
return simpleGit({ env, spawnOptions });
return simpleGit({ env, spawnOptions, binary });
}
return simpleGit({ baseDir: normalizeDirectoryPath(directory), env, spawnOptions });
return simpleGit({ baseDir: normalizeDirectoryPath(directory), env, spawnOptions, binary });
};
const normalizeDirectoryPath = (value) => {
@@ -413,7 +524,7 @@ const parseGitErrorText = (error) => {
const runGitCommand = async (cwd, args) => {
try {
const { stdout, stderr } = await execFileAsync('git', args, {
const { stdout, stderr } = await execFileAsync(getGitBinary(), args, {
cwd,
env: await buildGitEnv(),
windowsHide: true,
@@ -623,6 +734,7 @@ const runWorktreeStartCommand = async (directory, command) => {
const result = await execFileAsync('cmd', ['/c', text], {
cwd: directory,
env: await buildGitEnv(),
windowsHide: true,
maxBuffer: 20 * 1024 * 1024,
}).then(({ stdout, stderr }) => ({ success: true, stdout, stderr })).catch((error) => ({
success: false,
@@ -1451,9 +1563,10 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
if (isImage) {
// For images, use git show with raw output and convert to base64
try {
const { stdout } = await execFileAsync('git', ['show', `HEAD:${filePath}`], {
const { stdout } = await execFileAsync(getGitBinary(), ['show', `HEAD:${filePath}`], {
cwd: directoryPath,
encoding: 'buffer',
windowsHide: true,
maxBuffer: 50 * 1024 * 1024, // 50MB max
});
if (stdout && stdout.length > 0) {
+26 -8
View File
@@ -11,6 +11,11 @@ const __dirname = path.dirname(__filename);
const PACKAGE_NAME = '@openchamber/web';
const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`;
const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md';
let cachedDetectedPm = null;
function getSpawnSyncBaseOptions() {
return process.platform === 'win32' ? { windowsHide: true } : {};
}
const UPDATE_CHECK_URL = process.env.OPENCHAMBER_UPDATE_API_URL || 'https://api.openchamber.dev/v1/update/check';
function getOpenChamberConfigDir() {
@@ -131,18 +136,24 @@ async function checkForUpdatesFromApi(currentVersion, options = {}) {
* 4. Fall back to npm
*/
export function detectPackageManager() {
if (cachedDetectedPm) {
return cachedDetectedPm;
}
const forcedPm = process.env.OPENCHAMBER_PACKAGE_MANAGER?.trim();
if (forcedPm && ['npm', 'pnpm', 'yarn', 'bun'].includes(forcedPm)) {
const forcedPmCommand = resolvePackageManagerCommand(forcedPm);
if (isCommandAvailable(forcedPmCommand)) {
return forcedPm;
cachedDetectedPm = forcedPm;
return cachedDetectedPm;
}
}
// Strategy 1: Detect from runtime executable path (reliable for server-side updates)
const runtimePm = detectPackageManagerFromRuntimePath(process.execPath);
if (runtimePm && isCommandAvailable(resolvePackageManagerCommand(runtimePm))) {
return runtimePm;
cachedDetectedPm = runtimePm;
return cachedDetectedPm;
}
// Strategy 2: Check user agent (most reliable during install)
@@ -165,7 +176,8 @@ export function detectPackageManager() {
// Strategy 4: Detect from invoked binary path (works for bun global symlink installs)
const invokedPm = detectPackageManagerFromInvocationPath(process.argv?.[1]);
if (invokedPm && isCommandAvailable(resolvePackageManagerCommand(invokedPm))) {
return invokedPm;
cachedDetectedPm = invokedPm;
return cachedDetectedPm;
}
if (!hintedPm) {
hintedPm = invokedPm;
@@ -176,7 +188,8 @@ export function detectPackageManager() {
const pkgPath = path.resolve(__dirname, '..', '..');
const pmFromPath = detectPackageManagerFromInstallPath(pkgPath);
if (pmFromPath && isCommandAvailable(resolvePackageManagerCommand(pmFromPath))) {
return pmFromPath;
cachedDetectedPm = pmFromPath;
return cachedDetectedPm;
}
if (!hintedPm) {
hintedPm = pmFromPath;
@@ -188,7 +201,8 @@ export function detectPackageManager() {
// Validate the hinted PM actually owns the global install.
// This avoids false positives (for example running via bunx while installed with npm).
if (hintedPm && isCommandAvailable(resolvePackageManagerCommand(hintedPm)) && isPackageInstalledWith(hintedPm)) {
return hintedPm;
cachedDetectedPm = hintedPm;
return cachedDetectedPm;
}
// Strategy 6: Check which PM binaries are available and preferred
@@ -203,12 +217,14 @@ export function detectPackageManager() {
if (check()) {
// Verify this PM actually has the package installed globally
if (isPackageInstalledWith(name)) {
return name;
cachedDetectedPm = name;
return cachedDetectedPm;
}
}
}
return 'npm';
cachedDetectedPm = 'npm';
return cachedDetectedPm;
}
function detectPackageManagerFromInstallPath(pkgPath) {
@@ -285,6 +301,7 @@ function isCommandAvailable(command) {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 5000,
...getSpawnSyncBaseOptions(),
});
return result.status === 0;
} catch {
@@ -314,6 +331,7 @@ function isPackageInstalledWith(pm) {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 10000,
...getSpawnSyncBaseOptions(),
});
if (result.status !== 0) return false;
@@ -444,7 +462,6 @@ export async function checkForUpdates(options = {}) {
const currentNum = parseVersion(currentVersion);
const latestNum = parseVersion(latestVersion);
const available = latestNum > currentNum;
let changelog;
if (available) {
changelog = await fetchChangelogNotes(currentVersion, latestVersion);
@@ -474,6 +491,7 @@ export function executeUpdate(pm = detectPackageManager(), options = {}) {
const result = spawnSync(command, {
stdio: 'inherit',
shell: true,
...getSpawnSyncBaseOptions(),
});
return {
@@ -45,6 +45,7 @@ export async function runGit(args, options = {}) {
const { stdout, stderr } = await execFileAsync('git', normalizedArgs, {
cwd,
env,
windowsHide: true,
timeout: timeoutMs,
maxBuffer,
});