Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5dbee84
feat(web-ui): refine layout hierarchy inspired by dory
awsl233777 Apr 7, 2026
214f4fd
fix(web-ui): tighten light layout refresh and parity coverage
awsl233777 Apr 7, 2026
1f673fb
fix(web-ui): hide scrollbars and harden reset-main
awsl233777 Apr 7, 2026
4c39313
fix(web-ui): tighten layout polish and parity drift
awsl233777 Apr 7, 2026
49a9567
refactor(web-ui): harden layout hierarchy
awsl233777 Apr 7, 2026
5309210
refactor(web-ui): tighten desktop hierarchy
awsl233777 Apr 7, 2026
8a9240b
refactor(web-ui): simplify navigation chrome
awsl233777 Apr 7, 2026
25b08de
feat(reset): support interactive PR sync
awsl233777 Apr 7, 2026
8f0324e
refactor(web-ui): remove landing-page chrome
awsl233777 Apr 7, 2026
4705477
refactor(web-ui): focus homepage on local workspace
awsl233777 Apr 7, 2026
51c4bbc
refactor(web-ui): rebuild workspace shell
awsl233777 Apr 7, 2026
cc38298
refactor(web-ui): tighten workspace density
awsl233777 Apr 7, 2026
0239f53
refactor(web-ui): align compact layout spec
awsl233777 Apr 7, 2026
4238b92
refactor(web-ui): normalize compact navigation rail
awsl233777 Apr 7, 2026
14d5551
refactor(web-ui): remove compact layout noise
awsl233777 Apr 7, 2026
e4374b8
refactor(web-ui): simplify workspace information flow
awsl233777 Apr 7, 2026
451b3a5
refactor(web-ui): shift settings panel into document flow
awsl233777 Apr 7, 2026
5452622
refactor(web-ui): keep split layout on tablet
awsl233777 Apr 8, 2026
e18aaaa
refactor(web-ui): tidy split layout nav details
awsl233777 Apr 8, 2026
4971406
refactor(web-ui): restyle shell like SaaS console
awsl233777 Apr 8, 2026
386b32a
refactor(web-ui): soften shell palette for reading
awsl233777 Apr 8, 2026
abd41cc
refactor(web-ui): keep shell chrome pinned
awsl233777 Apr 8, 2026
7f97d29
refactor(web-ui): always show nav meta and logo
awsl233777 Apr 8, 2026
9a18f45
refactor(web-ui): balance trash actions and content width
awsl233777 Apr 8, 2026
7844b7a
refactor(web-ui): widen main content wrapper
awsl233777 Apr 8, 2026
4ebcbfe
refactor(web-ui): move cli install guide into docs tab
awsl233777 Apr 8, 2026
b856f6b
test(web-ui): fix parity method drift ordering
awsl233777 Apr 8, 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
11 changes: 11 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,17 @@ npm start run --no-browser

> Convention: automated tests validate service and API behavior only, without opening browser pages.

### Developer helper scripts

```bash
npm run reset
npm run reset -- 79
```

- `npm run reset`: prompt for a PR number; leave it blank to return to default `origin/main`
- `npm run reset -- 79`: sync directly to the latest head snapshot of PR `#79`
- The script also handles local branch switching, workspace cleanup, untracked file cleanup, and final state validation

## Command Reference

| Command | Description |
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,17 @@ npm start run --no-browser

> 约定:自动化测试仅验证服务与 API,不依赖打开页面。

### 开发辅助脚本

```bash
npm run reset
npm run reset -- 79
```

- `npm run reset`:交互输入 PR 编号;留空则回到默认 `origin/main`
- `npm run reset -- 79`:直接同步到 PR `#79` 的最新 head 快照
- 脚本会自动完成本地分支切换、工作区清理、未跟踪文件清理与最终状态校验

## 命令速查

| 命令 | 说明 |
Expand Down
196 changes: 172 additions & 24 deletions cmd/reset-main.js
Original file line number Diff line number Diff line change
@@ -1,45 +1,193 @@
#!/usr/bin/env node
/**
* Reset working tree to origin/main:
* - fetch origin/main
* - checkout main
* - hard reset to origin/main
* - clean untracked files/dirs
* - print final status
* Interactive reset workflow:
* - blank input => reset working tree to origin/main
* - PR number => fetch PR snapshot and reset local branch to that PR head
* - always discard local changes, clean untracked files/dirs, and validate
* the final branch / commit / clean-tree state
*
* Cross-platform: requires Node.js and git in PATH.
*/

const { execSync } = require('child_process');
const readline = require('readline');

function run(cmd) {
execSync(cmd, { stdio: 'inherit' });
const DEFAULT_REMOTE = 'origin';
const DEFAULT_MAIN_BRANCH = 'main';
const DEFAULT_PR_BRANCH_PREFIX = 'pr';

function run(cmd, options = {}) {
const execOptions = {
stdio: 'inherit',
...options
};
return execSync(cmd, execOptions);
}

function runCapture(cmd) {
return String(run(cmd, {
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf-8'
}) || '').trim();
}

function normalizePrNumberInput(value) {
const normalized = String(value ?? '').trim();
if (!normalized) {
return '';
}
if (!/^\d+$/.test(normalized)) {
throw new Error(`Invalid PR number: ${normalized}`);
}
return String(Number.parseInt(normalized, 10));
}

function buildResetPlan({
prNumber = '',
remote = DEFAULT_REMOTE,
mainBranch = DEFAULT_MAIN_BRANCH,
prBranchPrefix = DEFAULT_PR_BRANCH_PREFIX
} = {}) {
const normalizedPrNumber = normalizePrNumberInput(prNumber);

if (!normalizedPrNumber) {
const targetRef = `${remote}/${mainBranch}`;
return {
mode: 'main',
title: `Reset working tree to ${targetRef}`,
targetRef,
expectedBranch: mainBranch,
expectedRef: targetRef,
steps: [
{ title: `Fetch ${targetRef}`, command: `git fetch ${remote} ${mainBranch} --prune` },
{ title: 'Discard local changes', command: 'git reset --hard' },
{ title: 'Remove untracked files', command: 'git clean -fd' },
{ title: `Checkout ${mainBranch}`, command: `git checkout ${mainBranch}` },
{ title: `Reset local branch to ${targetRef}`, command: `git reset --hard ${targetRef}` },
{ title: 'Remove untracked files', command: 'git clean -fd' },
{ title: `Validate final state against ${targetRef}`, kind: 'validate' },
{ title: 'Final status', command: 'git status --short --branch' }
],
doneMessage: `Done. Working tree synced to ${targetRef}.`
};
}

const localBranch = `${prBranchPrefix}-${normalizedPrNumber}`;
const snapshotRef = `refs/remotes/${remote}/${prBranchPrefix}/${normalizedPrNumber}`;
return {
mode: 'pr',
prNumber: normalizedPrNumber,
title: `Reset working tree to PR #${normalizedPrNumber}`,
targetRef: `PR #${normalizedPrNumber}`,
expectedBranch: localBranch,
expectedRef: snapshotRef,
snapshotRef,
steps: [
{ title: `Fetch PR #${normalizedPrNumber} snapshot`, command: `git fetch ${remote} pull/${normalizedPrNumber}/head:${snapshotRef} --force` },
{ title: 'Discard local changes', command: 'git reset --hard' },
{ title: 'Remove untracked files', command: 'git clean -fd' },
{ title: `Checkout ${localBranch}`, command: `git checkout -B ${localBranch} ${snapshotRef}` },
{ title: `Reset local branch to PR #${normalizedPrNumber} snapshot`, command: `git reset --hard ${snapshotRef}` },
{ title: 'Remove untracked files', command: 'git clean -fd' },
{ title: `Validate final state against PR #${normalizedPrNumber}`, kind: 'validate' },
{ title: 'Final status', command: 'git status --short --branch' }
],
doneMessage: `Done. Working tree synced to PR #${normalizedPrNumber} on local branch ${localBranch}.`
};
}

function main() {
function validateFinalState(plan) {
const currentBranch = runCapture('git rev-parse --abbrev-ref HEAD');
if (currentBranch !== plan.expectedBranch) {
throw new Error(`Expected current branch ${plan.expectedBranch}, got ${currentBranch || '(unknown)'}.`);
}

const currentHead = runCapture('git rev-parse HEAD');
const expectedHead = runCapture(`git rev-parse ${plan.expectedRef}`);
if (currentHead !== expectedHead) {
throw new Error(`Expected HEAD ${expectedHead} from ${plan.expectedRef}, got ${currentHead}.`);
}

try {
run('git rev-parse --is-inside-work-tree');
run('git diff --quiet HEAD --', { stdio: 'ignore' });
run('git diff --cached --quiet HEAD --', { stdio: 'ignore' });
} catch (err) {
console.error('Not inside a git repository.');
process.exit(1);
throw new Error('Working tree is not clean after reset.');
}

console.log('[1/5] Fetch origin/main');
run('git fetch origin main --prune');
const untracked = runCapture('git ls-files --others --exclude-standard');
if (untracked) {
throw new Error(`Untracked files remain after reset:\n${untracked}`);
}
}

console.log('[2/5] Checkout main');
run('git checkout main');
function executeResetPlan(plan) {
console.log(plan.title);
const totalSteps = plan.steps.length;
plan.steps.forEach((step, index) => {
console.log(`[${index + 1}/${totalSteps}] ${step.title}`);
if (step.kind === 'validate') {
validateFinalState(plan);
return;
}
run(step.command);
});
console.log(plan.doneMessage);
}

console.log('[3/5] Reset local changes to origin/main');
run('git reset --hard origin/main');
function resolveArgPrNumber(argv = process.argv.slice(2)) {
const first = Array.isArray(argv) ? argv.find((item) => String(item ?? '').trim()) : '';
return normalizePrNumberInput(first || '');
}

console.log('[4/5] Remove untracked files');
run('git clean -fd');
function promptForPrNumber({ stdin = process.stdin, stdout = process.stdout } = {}) {
if (!stdin || !stdout || stdin.isTTY === false || stdout.isTTY === false) {
return Promise.resolve('');
}

console.log('[5/5] Final status');
run('git status --short --branch');
return new Promise((resolve, reject) => {
const rl = readline.createInterface({ input: stdin, output: stdout });
rl.question('PR 编号(留空则重置到 origin/main): ', (answer) => {
rl.close();
try {
resolve(normalizePrNumberInput(answer));
} catch (err) {
reject(err);
}
});
});
}

console.log('Done. Working tree synced to origin/main.');
async function main({ argv = process.argv.slice(2), stdin = process.stdin, stdout = process.stdout } = {}) {
try {
run('git rev-parse --is-inside-work-tree');
} catch (err) {
console.error('Not inside a git repository.');
process.exit(1);
}

const argPrNumber = resolveArgPrNumber(argv);
const prNumber = argPrNumber || await promptForPrNumber({ stdin, stdout });
const plan = buildResetPlan({ prNumber });
executeResetPlan(plan);
}

if (require.main === module) {
main().catch((err) => {
console.error(err && err.message ? err.message : err);
process.exit(1);
});
}

main();
module.exports = {
DEFAULT_REMOTE,
DEFAULT_MAIN_BRANCH,
DEFAULT_PR_BRANCH_PREFIX,
normalizePrNumberInput,
buildResetPlan,
validateFinalState,
executeResetPlan,
resolveArgPrNumber,
promptForPrNumber,
main
};
23 changes: 18 additions & 5 deletions tests/unit/compact-layout-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import assert from 'assert';
import {
readBundledWebUiCss,
readProjectFile,
readBundledWebUiScript
readBundledWebUiScript,
readBundledWebUiHtml
} from './helpers/web-ui-source.mjs';

test('app script includes compact layout detection and body class toggling', () => {
Expand All @@ -28,9 +29,9 @@ test('styles include force-compact fallback rules for readability on touch devic
assert.match(styles, /body\.force-compact\s*\{/);
assert.match(styles, /body\.force-compact\s+\.app-shell\s*\{/);
assert.match(styles, /body\.force-compact\s+\.status-inspector\s*\{[\s\S]*display:\s*none;/);
assert.match(styles, /body\.force-compact\s+\.top-tabs\s*\{[\s\S]*display:\s*grid\s*!important;[\s\S]*grid-template-columns:\s*repeat\(1,\s*minmax\(0,\s*1fr\)\);/);
assert.match(styles, /@media\s*\(min-width:\s*541px\)\s*\{[\s\S]*body\.force-compact\s+\.top-tabs\s*\{[\s\S]*repeat\(2,\s*minmax\(0,\s*1fr\)\);/);
assert.match(layoutShell, /@media\s*\(min-width:\s*961px\)\s*\{[\s\S]*body:not\(.force-compact\)\s+#app\s*>\s*\.top-tabs\s*\{[\s\S]*display:\s*none;/);
assert.match(styles, /body\.force-compact\s+\.top-tabs\s*\{[\s\S]*display:\s*flex\s*!important;[\s\S]*flex-wrap:\s*nowrap;[\s\S]*overflow-x:\s*auto;/);
assert.match(styles, /body\.force-compact\s+\.top-tabs::-webkit-scrollbar\s*\{[\s\S]*display:\s*none;/);
assert.match(layoutShell, /@media\s*\(min-width:\s*721px\)\s*\{[\s\S]*body:not\(.force-compact\)\s+#app\s*>\s*\.top-tabs\s*\{[\s\S]*display:\s*none;/);
assert.doesNotMatch(layoutShell, /^\s*\.top-tabs\s*\{[\s\S]*display:\s*none\s*!important;/m);
assert.match(styles, /body\.force-compact\s+\.card-subtitle/);
const compactSubtitleBlock = styles.match(/body\.force-compact\s+\.card-subtitle\s*\{[^}]*\}/);
Expand All @@ -47,10 +48,22 @@ test('styles include force-compact fallback rules for readability on touch devic

test('styles keep desktop layout wide and session history readable on large screens', () => {
const styles = readBundledWebUiCss();
assert.match(styles, /\.container\s*\{[\s\S]*max-width:\s*2200px;/);
assert.match(styles, /\.container\s*\{[\s\S]*max-width:\s*none;[\s\S]*min-height:\s*100vh;/);
assert.match(styles, /\.app-shell\s*\{[\s\S]*grid-template-columns:\s*248px\s+minmax\(0,\s*1fr\);[\s\S]*min-height:\s*100vh;[\s\S]*height:\s*100vh;[\s\S]*overflow:\s*hidden;/);
assert.match(styles, /\.side-rail\s*\{[\s\S]*overflow-y:\s*auto;[\s\S]*scrollbar-width:\s*none;/);
assert.match(styles, /\.main-panel\s*\{[\s\S]*overflow-y:\s*auto;[\s\S]*height:\s*100vh;[\s\S]*scrollbar-width:\s*none;/);
assert.match(styles, /\.main-panel-topbar\s*\{[\s\S]*position:\s*sticky;[\s\S]*top:\s*0;/);
assert.match(styles, /\.side-item-meta\s*\{[\s\S]*display:\s*flex;[\s\S]*opacity:\s*1;/);
assert.match(styles, /\.brand-logo\s*\{[\s\S]*width:\s*38px;[\s\S]*height:\s*38px;/);
assert.match(styles, /\.content-wrapper\s*\{[\s\S]*max-width:\s*1280px;/);
assert.match(styles, /\.trash-item-actions\s*\{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(116px,\s*116px\)\);/);
assert.match(styles, /\.trash-item-actions\s+\.btn-mini\s*\{[\s\S]*height:\s*38px;[\s\S]*min-height:\s*38px;[\s\S]*white-space:\s*nowrap;/);
assert.match(styles, /\.session-layout\s*\{[\s\S]*grid-template-columns:\s*minmax\(260px,\s*360px\)\s*minmax\(0,\s*1fr\);/);
assert.match(styles, /\.session-item\s*\{[\s\S]*min-height:\s*102px;/);

const html = readBundledWebUiHtml();
assert.match(html, /class="brand-logo"\s+src="\/res\/logo\.png"/);

const titleBlock = styles.match(/\.session-item-title\s*\{[^}]*\}/);
assert.ok(titleBlock, 'missing session item title style block');
assert.match(titleBlock[0], /display:\s*-webkit-box;/);
Expand Down
23 changes: 19 additions & 4 deletions tests/unit/config-tabs-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(html, /sessionTrashCount/);
assert.match(html, /id="side-tab-market"/);
assert.match(html, /id="tab-market"/);
assert.match(html, /id="side-tab-docs"/);
assert.match(html, /id="tab-docs"/);
assert.match(html, /id="side-tab-usage"/);
assert.match(html, /id="tab-usage"/);
assert.match(html, /data-main-tab="usage"/);
Expand All @@ -73,6 +75,16 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(html, /:aria-selected="mainTab === 'market'"/);
assert.match(html, /id="panel-market"/);
assert.match(html, /v-show="mainTab === 'market'"/);
assert.match(html, /data-main-tab="docs"/);
assert.match(html, /onMainTabPointerDown\('docs', \$event\)/);
assert.match(html, /onMainTabClick\('docs', \$event\)/);
assert.match(html, /aria-controls="panel-docs"/);
assert.match(html, /:aria-selected="mainTab === 'docs'"/);
assert.match(html, /id="panel-docs"/);
assert.match(html, /v-show="mainTab === 'docs'"/);
assert.match(html, /CLI 安装文档/);
assert.match(html, /installTargetCards/);
assert.match(html, /installTroubleshootingTips/);
assert.doesNotMatch(html, /<span class="selector-title">Skills<\/span>/);
assert.doesNotMatch(html, /openSkillsManager\(\{ targetApp: 'codex' \}\)/);
assert.match(html, /loadSkillsMarketOverview\(\{ forceRefresh: true, silent: false \}\)/);
Expand Down Expand Up @@ -103,10 +115,12 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(html, /class="side-section" role="navigation" aria-label="配置管理"/);
assert.match(html, /class="side-section" role="navigation" aria-label="会话管理"/);
assert.match(html, /class="side-section" role="navigation" aria-label="技能市场"/);
assert.match(html, /class="side-section" role="navigation" aria-label="文档"/);
assert.match(html, /class="side-section" role="navigation" aria-label="设置"/);
assert.doesNotMatch(sideRail, /role="tablist"/);
assert.doesNotMatch(sideRail, /role="tab"/);
assert.match(sideRail, /id="side-tab-config-codex"[\s\S]*:aria-current="mainTab === 'config' && configMode === 'codex' \? 'page' : null"/);
assert.match(sideRail, /id="side-tab-docs"[\s\S]*:aria-current="mainTab === 'docs' \? 'page' : null"/);
assert.match(sideRail, /id="side-tab-settings"[\s\S]*:aria-current="mainTab === 'settings' \? 'page' : null"/);
assert.match(html, /skillsDefaultRootPath/);
assert.match(html, /可直接导入/);
Expand Down Expand Up @@ -221,7 +235,6 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(modalsBasic, /<div v-if="showClaudeConfigModal" class="modal-overlay" @click\.self="closeClaudeConfigModal">/);
for (const modalTitleId of [
'add-provider-modal-title',
'install-cli-modal-title',
'edit-provider-modal-title',
'add-model-modal-title',
'manage-models-modal-title',
Expand All @@ -231,6 +244,8 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(modalsBasic, new RegExp(`aria-labelledby="${modalTitleId}"`));
assert.match(modalsBasic, new RegExp(`id="${modalTitleId}"`));
}
assert.doesNotMatch(modalsBasic, /install-cli-modal-title/);
assert.doesNotMatch(modalsBasic, /showInstallModal/);
assert.match(modalsBasic, /<input v-model="newProvider\.key" class="form-input" type="password" placeholder="sk-\.\.\.">/);
assert.match(modalsBasic, /<input v-model="editingProvider\.key" class="form-input" type="password" placeholder="留空则保持不变">/);
assert.match(modalsBasic, /<input v-model="newClaudeConfig\.apiKey" class="form-input" type="password" autocomplete="off" spellcheck="false" placeholder="sk-ant-\.\.\.">/);
Expand Down Expand Up @@ -420,13 +435,13 @@ test('trash item styles stay aligned with session card layout and keep mobile us
assert.match(styles, /\.session-item:focus-visible\s*\{[\s\S]*outline:\s*3px solid rgba\(201,\s*94,\s*75,\s*0\.25\);[\s\S]*outline-offset:\s*2px;/);
assert.match(styles, /\.trash-item-title\s*\{[\s\S]*-webkit-line-clamp:\s*2;/);
assert.match(styles, /\.trash-item-side\s*\{[\s\S]*min-width:\s*132px;/);
assert.match(styles, /\.trash-item-actions\s*\{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(108px,\s*108px\)\);/);
assert.match(styles, /\.trash-item-actions \.btn-mini\s*\{[\s\S]*min-height:\s*36px;/);
assert.match(styles, /\.trash-item-actions\s*\{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(116px,\s*116px\)\);/);
assert.match(styles, /\.trash-item-actions \.btn-mini\s*\{[\s\S]*height:\s*38px;[\s\S]*min-height:\s*38px;[\s\S]*white-space:\s*nowrap;/);
assert.match(styles, /\.trash-item-path\s*\{[\s\S]*grid-template-columns:\s*48px\s+minmax\(0,\s*1fr\);/);
assert.match(styles, /\.session-toolbar-grow\s*\{[\s\S]*grid-column:\s*1\s*\/\s*-1;/);
assert.match(mobile520Block, /\.trash-item-header\s*\{[\s\S]*flex-direction:\s*column;/);
assert.match(mobile520Block, /\.trash-item-actions\s*\{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\);/);
assert.match(mobile520Block, /\.trash-item-actions \.btn-mini\s*\{[\s\S]*min-height:\s*40px;/);
assert.match(mobile520Block, /\.trash-item-actions \.btn-mini\s*\{[\s\S]*min-height:\s*44px;/);
assert.match(styles, /@media \(max-width: 540px\)\s*\{[\s\S]*\.trash-item\.session-item\s*\{[\s\S]*height:\s*auto;/);
assert.match(styles, /@media \(max-width: 540px\)\s*\{[\s\S]*\.trash-item-header\s*\{[\s\S]*flex-direction:\s*column;/);
assert.match(styles, /@media \(max-width: 540px\)\s*\{[\s\S]*\.trash-item-mainline\s*\{[\s\S]*flex-direction:\s*column;/);
Expand Down
Loading
Loading