Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
26d3eeb
refactor: split web ui modules and harden provider switching
SurviveM Apr 4, 2026
a5c327f
fix: restore node 18 web ui parity fixture
SurviveM Apr 4, 2026
3156c6b
fix: address web ui regression gaps
SurviveM Apr 4, 2026
a7eb34d
fix: harden web ui parity and modal regressions
SurviveM Apr 4, 2026
974fe04
test: stabilize web ui parity baseline
SurviveM Apr 4, 2026
c404349
fix: preserve provider state on edit and failed switch
SurviveM Apr 4, 2026
bf27523
fix: keep provider edit draft on update errors
SurviveM Apr 4, 2026
b359875
fix: harden provider inputs and card accessibility
SurviveM Apr 4, 2026
57d76d9
fix: harden api response error handling
SurviveM Apr 5, 2026
29c7da9
fix: tighten web ui startup and bundle guards
SurviveM Apr 5, 2026
e1e8c35
fix: harden web ui regression guards
SurviveM Apr 5, 2026
a73ad9e
fix: align openclaw modal busy state
SurviveM Apr 5, 2026
02c2d55
fix: stabilize web ui session and claude flows
SurviveM Apr 5, 2026
80d5f80
fix: stabilize openclaw modal persistence flow
SurviveM Apr 5, 2026
6358e7c
fix: guard overlapping openclaw modal actions
SurviveM Apr 5, 2026
cb05e7d
fix: harden config and agents modal busy guards
SurviveM Apr 5, 2026
9851d49
fix: reject invalid session standalone urls
SurviveM Apr 5, 2026
17d2424
fix: close remaining web ui review regressions
SurviveM Apr 5, 2026
d8dd962
fix: finish remaining web ui review follow-ups
SurviveM Apr 5, 2026
35a8be2
fix: preserve web ui parity contracts
SurviveM Apr 5, 2026
d1b924b
fix: harden web ui parity regressions
SurviveM Apr 5, 2026
2347988
ci: refine coderabbit review prompt
SurviveM Apr 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
Expand All @@ -23,4 +25,6 @@ jobs:
- name: Lint
run: npm run lint --if-present
- name: Test
env:
WEB_UI_PARITY_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
run: npm run test --if-present
1 change: 1 addition & 0 deletions .github/workflows/coderabbit-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ jobs:
const body = [
"@coderabbitai re-review",
"Stop making breaking changes, do a proper review!",
"If I merge this directly, will it introduce any regressions? Please list only the impacted issues. Do not include style suggestions, speculative concerns, or already-resolved items.",
`<!-- codexmate-coderabbit-review-commit-count: ${pr.commits} -->`,
].join("\n");

Expand Down
268 changes: 254 additions & 14 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ const {
validateWorkflowDefinition,
executeWorkflowDefinition
} = require('./lib/workflow-engine');
const {
readBundledWebUiCss,
readBundledWebUiHtml,
readExecutableBundledJavaScriptModule,
readExecutableBundledWebUiScript
} = require('./web-ui/source-bundle.cjs');

const DEFAULT_WEB_PORT = 3737;
const DEFAULT_WEB_HOST = '0.0.0.0';
Expand Down Expand Up @@ -9945,10 +9951,11 @@ function formatHostForUrl(host) {
return value;
}

// #region watchPathsForRestart
function watchPathsForRestart(targets, onChange) {
const disposers = [];
const debounceMs = 300;
let timer = null;
const watcherEntries = new Map();

const trigger = (info) => {
if (timer) clearTimeout(timer);
Expand All @@ -9958,35 +9965,201 @@ function watchPathsForRestart(targets, onChange) {
}, debounceMs);
};

const addWatcher = (target, recursive) => {
const closeWatcher = (watchKey) => {
const entry = watcherEntries.get(watchKey);
if (!entry) return;
watcherEntries.delete(watchKey);
try {
entry.watcher.close();
} catch (_) {}
};

const listDirectoryTree = (rootDir) => {
const queue = [rootDir];
const directories = [];
const seen = new Set();
while (queue.length) {
const current = queue.shift();
if (!current || seen.has(current) || !fs.existsSync(current)) {
continue;
}
seen.add(current);
let stat = null;
try {
stat = fs.statSync(current);
} catch (_) {
continue;
}
if (!stat || !stat.isDirectory()) {
continue;
}
directories.push(current);
let entries = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch (_) {
continue;
}
for (const entry of entries) {
if (entry && typeof entry.isDirectory === 'function' && entry.isDirectory()) {
queue.push(path.join(current, entry.name));
}
}
}
return directories;
};

const isSameOrNestedPath = (candidate, rootDir) => {
return candidate === rootDir || candidate.startsWith(`${rootDir}${path.sep}`);
};

const addWatcher = (target, recursive, isDirectory = false) => {
if (!fs.existsSync(target)) return;
const watchKey = `${recursive ? 'recursive' : 'plain'}:${target}`;
if (watcherEntries.has(watchKey)) {
return true;
}
try {
const watcher = fs.watch(target, { recursive }, (eventType, filename) => {
const basename = isDirectory ? '' : path.basename(target);
const watchTarget = isDirectory ? target : path.dirname(target);
const watcher = fs.watch(watchTarget, { recursive }, (eventType, filename) => {
if (isDirectory && !recursive && eventType === 'rename') {
syncDirectoryTree(target);
}
if (!filename) return;
const lower = filename.toLowerCase();
if (!(/\.(html|js|mjs|css)$/.test(lower))) return;
trigger({ target, eventType, filename });
let normalizedFilename = String(filename).replace(/\\/g, '/');
if (!isDirectory) {
const fileNameOnly = normalizedFilename.split('/').pop();
if (fileNameOnly !== basename) {
return;
}
normalizedFilename = basename;
}
const lower = normalizedFilename.toLowerCase();
if (!(/\.(html|js|mjs|cjs|css)$/.test(lower))) return;
trigger({ target, eventType, filename: normalizedFilename });
});
watcher.on('error', () => {
closeWatcher(watchKey);
if (isDirectory && recursive && !fs.existsSync(target)) {
syncDirectoryTree(target);
addMissingDirectoryWatcher(target);
return;
}
if (isDirectory && !recursive) {
syncDirectoryTree(target);
} else if (fs.existsSync(target)) {
addWatcher(target, recursive, isDirectory);
}
});
watcherEntries.set(watchKey, {
watcher,
target,
recursive,
isDirectory
});
disposers.push(() => watcher.close());
return true;
} catch (e) {
return false;
}
};

const addMissingDirectoryWatcher = (target) => {
const parentDir = path.dirname(target);
if (!parentDir || parentDir === target || !fs.existsSync(parentDir)) {
return false;
}
const watchKey = `missing-dir:${target}`;
if (watcherEntries.has(watchKey)) {
return true;
}
const basename = path.basename(target);
try {
const watcher = fs.watch(parentDir, { recursive: false }, (_eventType, filename) => {
if (!filename) return;
const fileNameOnly = String(filename).replace(/\\/g, '/').split('/').pop();
if (fileNameOnly !== basename) {
return;
}
if (!fs.existsSync(target)) {
syncDirectoryTree(target);
return;
}
closeWatcher(watchKey);
const ok = addWatcher(target, true, true);
if (!ok) {
syncDirectoryTree(target);
}
});
watcher.on('error', () => {
closeWatcher(watchKey);
if (fs.existsSync(parentDir) && !fs.existsSync(target)) {
addMissingDirectoryWatcher(target);
}
});
watcherEntries.set(watchKey, {
watcher,
target: parentDir,
recursive: false,
isDirectory: false
});
return true;
} catch (_) {
return false;
}
};

const syncDirectoryTree = (rootDir) => {
const directories = listDirectoryTree(rootDir);
const existingDirectorySet = new Set(directories);
for (const [watchKey, entry] of Array.from(watcherEntries.entries())) {
if (!entry.isDirectory || entry.recursive) {
continue;
}
if (!isSameOrNestedPath(entry.target, rootDir)) {
continue;
}
if (!existingDirectorySet.has(entry.target)) {
closeWatcher(watchKey);
}
}
for (const directory of directories) {
addWatcher(directory, false, true);
}
};

for (const target of targets) {
const ok = addWatcher(target, true);
if (!fs.existsSync(target)) continue;
let stat = null;
try {
stat = fs.statSync(target);
} catch (_) {
continue;
}
if (stat && stat.isDirectory()) {
const ok = addWatcher(target, true, true);
if (!ok) {
syncDirectoryTree(target);
}
continue;
}
const ok = addWatcher(target, true, false);
if (!ok) {
addWatcher(target, false);
addWatcher(target, false, false);
}
}

return () => {
for (const dispose of disposers) {
try { dispose(); } catch (_) {}
if (timer) {
clearTimeout(timer);
timer = null;
}
for (const watchKey of Array.from(watcherEntries.keys())) {
closeWatcher(watchKey);
}
};
}
// #endregion watchPathsForRestart

function writeJsonResponse(res, statusCode, payload) {
const body = JSON.stringify(payload, null, 2);
Expand Down Expand Up @@ -10131,8 +10304,46 @@ async function handleImportSkillsZipUpload(req, res, options = {}) {
}
}

const PUBLIC_WEB_UI_DYNAMIC_ASSETS = new Map([
['app.js', {
mime: 'application/javascript; charset=utf-8',
reader: readExecutableBundledWebUiScript
}],
['index.html', {
mime: 'text/html; charset=utf-8',
reader: readBundledWebUiHtml
}],
['logic.mjs', {
mime: 'application/javascript; charset=utf-8',
reader: readExecutableBundledJavaScriptModule
}],
['styles.css', {
mime: 'text/css; charset=utf-8',
reader: readBundledWebUiCss
}]
]);

const PUBLIC_WEB_UI_STATIC_ASSETS = new Set([
'modules/config-mode.computed.mjs',
'modules/skills.computed.mjs',
'modules/skills.methods.mjs',
'session-helpers.mjs'
]);

function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser }) {
const connections = new Set();
const writeWebUiAssetError = (res, requestPath, error) => {
const message = error && error.message ? error.message : String(error);
console.error(`! Web UI 资源读取失败 [${requestPath}]:`, message);
if (res.headersSent) {
try {
res.destroy(error);
} catch (_) {}
return;
}
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Internal Server Error');
};

const server = http.createServer((req, res) => {
const requestPath = (req.url || '/').split('?')[0];
Expand Down Expand Up @@ -10562,6 +10773,14 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
res.end(errorBody, 'utf-8');
}
});
} else if (requestPath === '/web-ui') {
try {
const html = readBundledWebUiHtml(htmlPath);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
} catch (error) {
writeWebUiAssetError(res, requestPath, error);
}
} else if (requestPath.startsWith('/web-ui/')) {
const normalized = path.normalize(requestPath).replace(/^([\\.\\/])+/, '');
const filePath = path.join(__dirname, normalized);
Expand All @@ -10570,6 +10789,23 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
res.end('Forbidden');
return;
}
const relativePath = path.relative(webDir, filePath).replace(/\\/g, '/');
const dynamicAsset = PUBLIC_WEB_UI_DYNAMIC_ASSETS.get(relativePath);
if (dynamicAsset) {
try {
const assetBody = dynamicAsset.reader(filePath);
res.writeHead(200, { 'Content-Type': dynamicAsset.mime });
res.end(assetBody, 'utf-8');
} catch (error) {
writeWebUiAssetError(res, requestPath, error);
}
return;
}
if (!PUBLIC_WEB_UI_STATIC_ASSETS.has(relativePath)) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not Found');
return;
}
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not Found');
Expand Down Expand Up @@ -10642,9 +10878,13 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
res.writeHead(200, { 'Content-Type': mime });
fs.createReadStream(filePath).pipe(res);
} else {
const html = fs.readFileSync(htmlPath, 'utf-8');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
try {
const html = readBundledWebUiHtml(htmlPath);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
} catch (error) {
writeWebUiAssetError(res, requestPath, error);
}
}
});

Expand Down
2 changes: 2 additions & 0 deletions tests/e2e/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const testMessages = require('./test-messages');
const testMcp = require('./test-mcp');
const testWorkflow = require('./test-workflow');
const testInvalidConfig = require('./test-invalid-config');
const testWebUiAssets = require('./test-web-ui-assets');

async function main() {
const realHome = os.homedir();
Expand Down Expand Up @@ -118,6 +119,7 @@ async function main() {
await testMessages(ctx);
await testMcp(ctx);
await testWorkflow(ctx);
await testWebUiAssets(ctx);

} finally {
const waitForExit = new Promise((resolve) => {
Expand Down
Loading
Loading