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
+226 -16
View File
@@ -111,6 +111,101 @@ const normalizeDirectoryPath = (value) => {
return trimmed;
};
const normalizePathForPersistence = (value) => {
if (typeof value !== 'string') {
return value;
}
const normalized = normalizeDirectoryPath(value);
if (typeof normalized !== 'string') {
return normalized;
}
const trimmed = normalized.trim();
if (!trimmed) {
return trimmed;
}
if (process.platform !== 'win32') {
return trimmed;
}
return trimmed.replace(/\//g, '\\');
};
const areStringArraysEqual = (a, b) => {
if (!Array.isArray(a) || !Array.isArray(b)) {
return false;
}
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
};
const normalizeSettingsPaths = (input) => {
const settings = input && typeof input === 'object' ? input : {};
let next = settings;
let changed = false;
const ensureNext = () => {
if (next === settings) {
next = { ...settings };
}
};
const normalizePathField = (key) => {
if (typeof settings[key] !== 'string' || settings[key].length === 0) {
return;
}
const normalized = normalizePathForPersistence(settings[key]);
if (normalized !== settings[key]) {
ensureNext();
next[key] = normalized;
changed = true;
}
};
const normalizePathArrayField = (key) => {
if (!Array.isArray(settings[key])) {
return;
}
const normalized = normalizeStringArray(
settings[key]
.map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry))
.filter((entry) => typeof entry === 'string' && entry.length > 0)
);
if (!areStringArraysEqual(normalized, settings[key])) {
ensureNext();
next[key] = normalized;
changed = true;
}
};
normalizePathField('lastDirectory');
normalizePathField('homeDirectory');
normalizePathArrayField('approvedDirectories');
normalizePathArrayField('pinnedDirectories');
if (Array.isArray(settings.projects)) {
const normalizedProjects = sanitizeProjects(settings.projects) || [];
if (JSON.stringify(normalizedProjects) !== JSON.stringify(settings.projects)) {
ensureNext();
next.projects = normalizedProjects;
changed = true;
}
}
return { settings: next, changed };
};
const OPENCHAMBER_USER_CONFIG_ROOT = path.join(os.homedir(), '.config', 'openchamber');
const OPENCHAMBER_USER_THEMES_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'themes');
@@ -562,7 +657,7 @@ const searchFilesystemFiles = async (rootPath, options) => {
}
const result = await new Promise((resolve) => {
const child = spawn('git', ['check-ignore', '--', ...pathsToCheck], {
const child = spawn(resolveGitBinaryForSpawn(), ['check-ignore', '--', ...pathsToCheck], {
cwd: dir,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
@@ -1136,7 +1231,11 @@ const buildTemplateVariables = async (payload, sessionId) => {
if (worktreeDir) {
try {
const { simpleGit } = await import('simple-git');
const git = simpleGit({ baseDir: worktreeDir, spawnOptions: { windowsHide: true } });
const git = simpleGit({
baseDir: worktreeDir,
spawnOptions: { windowsHide: true },
binary: resolveGitBinaryForSpawn(),
});
branch = await Promise.race([
git.revparse(['--abbrev-ref', 'HEAD']),
new Promise((_, reject) => setTimeout(() => reject(new Error('git timeout')), 3000)),
@@ -1802,7 +1901,8 @@ const sanitizeProjects = (input) => {
const candidate = entry;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
const normalizedPath = rawPath ? path.resolve(normalizeDirectoryPath(rawPath)) : '';
const resolvedPath = rawPath ? path.resolve(normalizeDirectoryPath(rawPath)) : '';
const normalizedPath = resolvedPath ? normalizePathForPersistence(resolvedPath) : '';
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : '';
const iconImage = candidate.iconImage && typeof candidate.iconImage === 'object'
@@ -1912,10 +2012,16 @@ const sanitizeSettingsUpdate = (payload) => {
result.splashFgDark = candidate.splashFgDark.trim();
}
if (typeof candidate.lastDirectory === 'string' && candidate.lastDirectory.length > 0) {
result.lastDirectory = candidate.lastDirectory;
const normalized = normalizePathForPersistence(candidate.lastDirectory);
if (typeof normalized === 'string' && normalized.length > 0) {
result.lastDirectory = normalized;
}
}
if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
result.homeDirectory = candidate.homeDirectory;
const normalized = normalizePathForPersistence(candidate.homeDirectory);
if (typeof normalized === 'string' && normalized.length > 0) {
result.homeDirectory = normalized;
}
}
// Absolute path to the opencode CLI binary (optional override).
@@ -1936,13 +2042,21 @@ const sanitizeSettingsUpdate = (payload) => {
}
if (Array.isArray(candidate.approvedDirectories)) {
result.approvedDirectories = normalizeStringArray(candidate.approvedDirectories);
result.approvedDirectories = normalizeStringArray(
candidate.approvedDirectories
.map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry))
.filter((entry) => typeof entry === 'string' && entry.length > 0)
);
}
if (Array.isArray(candidate.securityScopedBookmarks)) {
result.securityScopedBookmarks = normalizeStringArray(candidate.securityScopedBookmarks);
}
if (Array.isArray(candidate.pinnedDirectories)) {
result.pinnedDirectories = normalizeStringArray(candidate.pinnedDirectories);
result.pinnedDirectories = normalizeStringArray(
candidate.pinnedDirectories
.map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry))
.filter((entry) => typeof entry === 'string' && entry.length > 0)
);
}
@@ -2732,10 +2846,11 @@ const readSettingsFromDiskMigrated = async () => {
const migration3 = await migrateSettingsFromLegacyCollapsedProjects(migration2.settings);
const migration4 = await migrateSettingsNotificationDefaults(migration3.settings);
const migration5 = await migrateSettingsFromLegacyNamedTunnelKeys(migration4.settings);
if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed) {
await writeSettingsToDisk(migration5.settings);
const migration6 = normalizeSettingsPaths(migration5.settings);
if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed) {
await writeSettingsToDisk(migration6.settings);
}
return migration5.settings;
return migration6.settings;
};
const getOrCreateVapidKeys = async () => {
@@ -3401,6 +3516,11 @@ const persistSettings = async (changes) => {
const sanitized = sanitizeSettingsUpdate(changes);
let next = mergePersistedSettings(current, sanitized);
const normalizedState = normalizeSettingsPaths(next);
if (normalizedState.changed) {
next = normalizedState.settings;
}
if (Array.isArray(next.projects)) {
console.log(`[persistSettings] Validating ${next.projects.length} projects...`);
const validated = await validateProjectEntries(next.projects);
@@ -3795,6 +3915,7 @@ function getLoginShellEnvSnapshot() {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 10 * 1024 * 1024,
windowsHide: true,
});
if (result.status !== 0) {
@@ -3833,6 +3954,7 @@ function getWindowsShellEnvSnapshot() {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 10 * 1024 * 1024,
windowsHide: true,
});
if (result.status !== 0) {
continue;
@@ -3852,6 +3974,7 @@ function getWindowsShellEnvSnapshot() {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 10 * 1024 * 1024,
windowsHide: true,
});
if (result.status === 0 && typeof result.stdout === 'string' && result.stdout.length > 0) {
return parseNullSeparatedEnvSnapshot(result.stdout.replace(/\r?\n/g, '\0'));
@@ -3921,11 +4044,88 @@ let resolvedOpencodeBinary = null;
let resolvedOpencodeBinarySource = null;
let resolvedNodeBinary = null;
let resolvedBunBinary = null;
let resolvedGitBinary = null;
let useWslForOpencode = false;
let resolvedWslBinary = null;
let resolvedWslOpencodePath = null;
let resolvedWslDistro = null;
function resolveGitBinaryForSpawn() {
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 (isExecutable(candidate)) {
resolvedGitBinary = candidate;
return resolvedGitBinary;
}
}
const candidates = [];
const normalizeGitCandidate = (candidate) => {
if (typeof candidate !== 'string') {
return '';
}
const trimmed = candidate.trim();
if (!trimmed) {
return '';
}
const ext = path.extname(trimmed).toLowerCase();
if (ext === '.cmd' || ext === '.bat' || ext === '.com') {
const exeCandidate = trimmed.slice(0, -ext.length) + '.exe';
if (isExecutable(exeCandidate)) {
return exeCandidate;
}
}
return trimmed;
};
const pathCandidate = normalizeGitCandidate(searchPathFor('git'));
if (pathCandidate && isExecutable(pathCandidate)) {
candidates.push(pathCandidate);
}
const pathExeCandidate = normalizeGitCandidate(searchPathFor('git.exe'));
if (pathExeCandidate && isExecutable(pathExeCandidate)) {
candidates.push(pathExeCandidate);
}
const programRoots = [
process.env.ProgramFiles,
process.env['ProgramFiles(x86)'],
process.env.LocalAppData,
]
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter(Boolean);
for (const root of programRoots) {
const installCandidates = [
path.join(root, 'Git', 'cmd', 'git.exe'),
path.join(root, 'Git', 'bin', 'git.exe'),
path.join(root, 'Git', 'mingw64', 'bin', 'git.exe'),
path.join(root, 'Programs', 'Git', 'cmd', 'git.exe'),
path.join(root, 'Programs', 'Git', 'bin', 'git.exe'),
];
for (const candidate of installCandidates) {
const normalized = normalizeGitCandidate(candidate);
if (normalized && isExecutable(normalized)) {
candidates.push(normalized);
}
}
}
const preferredExe = candidates.find((candidate) => candidate.toLowerCase().endsWith('.exe'));
resolvedGitBinary = preferredExe || candidates[0] || 'git.exe';
return resolvedGitBinary;
}
function isExecutable(filePath) {
try {
const stat = fs.statSync(filePath);
@@ -3996,6 +4196,7 @@ function resolveWslExecutablePath() {
const result = spawnSync('where', ['wsl'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
const lines = (result.stdout || '')
@@ -4047,6 +4248,7 @@ function probeWslForOpencode() {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 6000,
windowsHide: true,
},
);
@@ -4163,6 +4365,7 @@ function resolveOpencodeCliPath() {
const result = spawnSync('where', ['opencode'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
const lines = (result.stdout || '')
@@ -4198,6 +4401,7 @@ function resolveOpencodeCliPath() {
const result = spawnSync(shell, ['-lic', 'command -v opencode'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
@@ -4248,6 +4452,7 @@ function resolveNodeCliPath() {
const result = spawnSync('where', ['node'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
const lines = (result.stdout || '')
@@ -4270,6 +4475,7 @@ function resolveNodeCliPath() {
const result = spawnSync(shell, ['-lic', 'command -v node'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
@@ -4329,6 +4535,7 @@ function resolveBunCliPath() {
const result = spawnSync('where', ['bun'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
const lines = (result.stdout || '')
@@ -4351,6 +4558,7 @@ function resolveBunCliPath() {
const result = spawnSync(shell, ['-lic', 'command -v bun'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
@@ -5677,7 +5885,7 @@ function killProcessOnPort(port) {
if (!port) return;
try {
// Kill any process listening on our port to clean up orphaned children.
const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000 });
const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000, windowsHide: true });
const output = result.stdout || '';
const myPid = process.pid;
for (const pidStr of output.split(/\s+/)) {
@@ -5761,6 +5969,7 @@ async function createManagedOpenCodeServerProcess({
const child = spawn(binary, args, {
cwd,
env,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
@@ -12656,18 +12865,18 @@ async function main(options = {}) {
// macOS: open -R selects the file in Finder; open opens a folder
const stat = await fsPromises.stat(resolved);
if (stat.isDirectory()) {
spawn('open', [resolved], { stdio: 'ignore', detached: true }).unref();
spawn('open', [resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
} else {
spawn('open', ['-R', resolved], { stdio: 'ignore', detached: true }).unref();
spawn('open', ['-R', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
}
} else if (platform === 'win32') {
// Windows: explorer /select, highlights the file
spawn('explorer', ['/select,', resolved], { stdio: 'ignore', detached: true }).unref();
spawn('explorer', ['/select,', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
} else {
// Linux: xdg-open opens the parent directory
const stat = await fsPromises.stat(resolved);
const dir = stat.isDirectory() ? resolved : path.dirname(resolved);
spawn('xdg-open', [dir], { stdio: 'ignore', detached: true }).unref();
spawn('xdg-open', [dir], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
}
res.json({ success: true, path: resolved });
@@ -12718,6 +12927,7 @@ async function main(options = {}) {
const child = spawn(shell, [shellFlag, command], {
cwd: resolvedCwd,
env: execEnv,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
@@ -12988,7 +13198,7 @@ async function main(options = {}) {
// Use git check-ignore with paths as arguments
// Pass paths directly as arguments (works for reasonable directory sizes)
const result = await new Promise((resolve) => {
const child = spawn('git', ['check-ignore', '--', ...pathsToCheck], {
const child = spawn(resolveGitBinaryForSpawn(), ['check-ignore', '--', ...pathsToCheck], {
cwd: resolvedPath,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],