Compare commits

...
10 Commits
Author SHA1 Message Date
YHH 513d19db20 fix(studio): replace character icons with semantic Lucide icons (#15063) 2026-11-09 02:32:09 -08:00
YHH f396db7c20 fix(studio): settle thinking clocks at response boundaries (#15057) 2026-11-09 00:43:43 -08:00
esengine 49e64b6d13 test(boot): each turn of the MCP list-changed effect test has its own 90s deadline
A hung turn in the http leg ran to the 10-minute package timeout, which discarded every other test's result in the package (CI run 42576970095, and the -race job on #15025). The test now fails at the turn that hung.
2026-11-08 23:33:04 -08:00
YHH bccfb5d684 fix(studio): size provider fields to their available space (#15054) 2026-11-08 23:04:23 -08:00
YHH bd898c3f30 fix(studio): expand settings and clarify navigation controls (#15050) 2026-11-08 20:48:05 -08:00
esengine 29230c68bf fix(studio): a settings select keeps a long chosen name on one line
.prefs-card select set the padding to 0 10px and let the name wrap, so a long model name spilled over the row. It now keeps the 30px the arrow needs on the right, hides overflow and does not wrap. Held by perf/select-long-name.mjs.
2026-11-08 20:38:07 -08:00
esengine c12faae30a fix(studio): a select centres its chosen text; base-select left it against the top edge
appearance: base-select lays the selected content out itself and, at a fixed height, leaves it at the top (8px above, 29px below at 2x in Chromium 156). align-items: center on the select puts it in the middle. Held by perf/select-centered.mjs and a rule test.
2026-11-08 20:33:52 -08:00
esengine 44e3a19f8d fix(studio): a stuck, open activity heading is painted in the conversation's colour
design/turn.css set the open summary transparent while styles/studio.css makes it sticky, so rows scrolling under it showed through.
2026-11-08 18:46:47 -08:00
esengine 1738c499df fix(skill): the missing-description warning is said only for a skill that is kept
A nested file without a description is dropped from the scan, yet parseSkill warned that it would load. The warning moved to scanDir, where the keep decision is made, so docs such as pack/README.md stop raising it.
2026-11-08 18:40:09 -08:00
YHH 9ee11c6b1f fix(studio): clarify model choices and merge duplicate file entry (#15040) 2026-11-08 18:17:43 -08:00
225 changed files with 2293 additions and 1573 deletions

No files matched your search

+24
View File
@@ -143,6 +143,7 @@ jobs:
node perf/hoverradius.mjs
node perf/jump-ends.mjs
node perf/modelrows.mjs
node perf/model-menu-badge.mjs
node perf/explorersize.mjs
node perf/git-panel.mjs
node perf/session-move-to.mjs
@@ -157,9 +158,17 @@ jobs:
node perf/codeeditor-size.mjs
node perf/composer-reflow.mjs
node perf/composer-toolbar-row.mjs
node perf/control-icons.mjs
node perf/nav-selection.mjs
node perf/action-icons.mjs
node perf/rowhit.mjs
node perf/prefs-wide-centered.mjs
node perf/settings-fullscreen.mjs
node perf/settings-page-column.mjs
node perf/sidebar-tools.mjs
node perf/select-alignment.mjs
node perf/provider-fields.mjs
node perf/thought-clock.mjs
node perf/tab-pip.mjs
node perf/syntax-tokens.mjs
node perf/tab-close-inside.mjs
@@ -217,6 +226,21 @@ jobs:
node perf/narrow-rail.mjs
node perf/selfcheck.mjs branch-tooltip
- name: Keep UI control previews
if: always() && steps.spa.outputs.run == 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: studio-ui-control-previews
path: |
desktop/frontend-next/perf/shots/control-icons-*.png
desktop/frontend-next/perf/shots/nav-selection-*.png
desktop/frontend-next/perf/shots/settings-full-*.png
desktop/frontend-next/perf/shots/sidebar-tools-*.png
desktop/frontend-next/perf/shots/select-alignment-*.png
desktop/frontend-next/perf/shots/provider-fields-*.png
desktop/frontend-next/perf/shots/thought-clock-*.png
retention-days: 7
# What a hosted page does over the relay, end to end: the production bundle,
# the real Studio host and kernel, a real Chromium; only the accounts API and
# the relay are stand-ins (tools/relayfake). Event-transport changes are
+6
View File
@@ -53,6 +53,12 @@ node perf/queue.mjs # 待送达:正文读不回来时,「改」不许
node perf/reask.mjs # 改写框:没有换行的长段落,框要长到让人读得到全文,宽屏和手机宽度各一遍
node perf/composer-reflow.mjs # Draft and skill-chip wrapping during column transitions, interface zoom and native scrollbar gutters
node perf/composer-toolbar-row.mjs # The buttons under the composer stay on one row from 900 to 400 px of conversation width, with the widest realistic labels
node perf/control-icons.mjs # Composer and browser action icons: readable geometry, alignment, running states, focus and labels
node perf/nav-selection.mjs # Navigation selection: quiet theme surfaces, icon contrast and keyboard focus in default, sky and photo packs
node perf/select-alignment.mjs # Selects: rendered text alignment and keyboard dismissal in both languages, schemes, widths and font sizes
node perf/settings-fullscreen.mjs # All settings categories fill the window with fixed gutters, single-column cards and contained controls at wide/phone widths and enlarged text
node perf/settings-page-column.mjs # Pill navigation scrolls to expanded controls without replacing editors or hiding them under the sticky index
node perf/sidebar-tools.mjs # Named footer card: saved order and visibility, hover transfer, click, keyboard focus and viewport containment
node perf/workbench-save.mjs # File toolbar sizing, Save contrast and edit/save/read states: light/dark × Chinese/English × desktop/browser × wide/phone
node perf/onboarding.mjs # First-run setup: zoom-aware card bounds, resize, scrolling, focus and completion
node perf/running.mjs # 执行过程:在跑的步骤自己在动、收起后由标题接着动,步骤行没有空栏
@@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = new URL(process.env.PERF_URL ?? "http://localhost:4399/perf.html");
const browser = await chromium.launch();
const measure = (buttons) => buttons.map((button) => {
const icon = button.querySelector("svg");
if (!icon) return { missing: true };
const box = button.getBoundingClientRect();
const svg = icon.getBoundingClientRect();
const glyph = icon.getBBox();
const unit = svg.width / icon.viewBox.baseVal.width;
return {
missing: false,
glyphWidth: glyph.width * unit,
glyphHeight: glyph.height * unit,
offsetX: Math.abs(svg.x + svg.width / 2 - box.x - box.width / 2),
offsetY: Math.abs(svg.y + svg.height / 2 - box.y - box.height / 2),
fits: svg.left >= box.left && svg.right <= box.right && svg.top >= box.top && svg.bottom <= box.bottom,
accessible: !!button.getAttribute("aria-label") && icon.getAttribute("aria-hidden") === "true",
};
});
const check = (sizes, label, centered = true) => {
assert.ok(sizes.length > 0, `${label}: controls exist`);
for (const size of sizes) {
assert.ok(!size.missing && size.accessible && size.fits, `${label}: named SVG controls fit (${JSON.stringify(size)})`);
assert.ok(size.glyphWidth >= 9 && size.glyphHeight >= 9, `${label}: symbols remain legible (${JSON.stringify(size)})`);
if (centered) assert.ok(size.offsetX <= 1 && size.offsetY <= 1, `${label}: symbols are centered (${JSON.stringify(size)})`);
}
};
try {
await mkdir("perf/shots", { recursive: true });
for (const colorScheme of ["light", "dark"]) for (const width of [1300, 390]) for (const scale of [1, 1.3]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme, reducedMotion: "reduce" });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.addInitScript(() => localStorage.setItem("rx-lang", "zh"));
const url = new URL(base);
url.searchParams.set("running", "1");
await page.goto(url.href, { waitUntil: "networkidle" });
await page.locator('.compose [data-action="session.stop"]').waitFor();
await page.evaluate((value) => document.documentElement.style.setProperty("--fs-scale", String(value)), scale);
const actions = page.locator(".compose .go");
const send = actions.locator('[data-action="session.send"]');
const stop = actions.locator('[data-action="session.stop"]');
const label = `${colorScheme}/${width}/${scale}`;
assert.equal(await send.isDisabled(), true, `${label}: an empty running draft cannot send`);
const disabledBackground = await send.evaluate((button) => getComputedStyle(button).backgroundColor);
check(await actions.locator("button").evaluateAll(measure), label);
await actions.screenshot({ path: `perf/shots/control-icons-${colorScheme}-${width}-${scale}.png` });
await page.locator(".compose textarea").fill("继续检查");
assert.equal(await send.isEnabled(), true, `${label}: a running draft can steer`);
await send.evaluate(async (button) => {
getComputedStyle(button).backgroundColor;
await Promise.all(button.getAnimations().map((animation) => animation.finished.catch(() => {})));
});
assert.notEqual(await send.evaluate((button) => getComputedStyle(button).backgroundColor), disabledBackground, `${label}: a disabled send looks inactive`);
check(await send.evaluateAll(measure), `${label}/steer`, false);
await stop.focus();
assert.ok(await stop.evaluate((button) => parseFloat(getComputedStyle(button).outlineWidth) >= 2), `${label}: keyboard focus is visible`);
await stop.click();
await page.waitForFunction(() => document.querySelector('[data-action="session.stop"]')?.getAttribute("aria-busy") === "true");
assert.equal(await stop.isDisabled(), true, `${label}: a pending stop cannot be repeated`);
check(await stop.evaluateAll(measure), `${label}/stopping`);
assert.deepEqual(errors, []);
await page.close();
console.log(`PASS composer icons ${label}: legibility, alignment, focus and running states`);
}
for (const colorScheme of ["light", "dark"]) {
const page = await browser.newPage({ viewport: { width: 1300, height: 900 }, colorScheme, reducedMotion: "reduce" });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.addInitScript(() => {
localStorage.setItem("rx-lang", "zh"); localStorage.setItem("rx-nav-rail", "on");
window.reasonixHost = {
shell: "electron", platform: "windows", titleBar: false,
isWindowMaximised: async () => false, setUnreadBadge: async () => {},
showBrowserView: async () => {}, hideBrowserView: async () => {}, freezeBrowserView: async () => "",
controlBrowserView: async () => {}, navigateBrowserView: async () => "", onBrowserLoadState: () => () => {},
};
});
const url = new URL(base);
url.searchParams.set("pref", "zh");
url.searchParams.set("ws", "2");
url.searchParams.set("sess", "6");
url.searchParams.set("agentpage", "web");
await page.goto(url.href, { waitUntil: "networkidle" });
assert.deepEqual(errors, []);
await page.locator('.navbtn[aria-label="文件"]').first().click();
await page.locator('[data-action-click="workbench.tab"][data-target="browser:view-a"]').click();
const buttons = page.locator(".bnav .bbtn");
await buttons.first().waitFor();
assert.equal(await buttons.count(), 4, "the browser exposes four navigation actions");
check(await buttons.evaluateAll(measure), `${colorScheme}/browser`);
for (const button of await buttons.all()) assert.equal(await button.getAttribute("title"), await button.getAttribute("aria-label"));
await page.locator(".bnav").screenshot({ path: `perf/shots/control-icons-browser-${colorScheme}.png` });
assert.deepEqual(errors, []);
await page.close();
console.log(`PASS browser icons ${colorScheme}: legibility, alignment and action labels`);
}
for (const colorScheme of ["light", "dark"]) for (const width of [1300, 390]) {
const page = await browser.newPage({ viewport: { width: 1300, height: 900 }, colorScheme, reducedMotion: "reduce" });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(`${base}?pref=zh&ws=2&sess=6&turns=2`, { waitUntil: "networkidle" });
const label = `${colorScheme}/${width}/library`;
const toolIcons = page.locator(".sym svg");
assert.ok(await toolIcons.count() > 0, `${label}: transcript icons exist`);
assert.ok(await toolIcons.evaluateAll((icons) => icons.every((icon) => icon.classList.contains("lucide"))), `${label}: tool cards use library icons`);
await page.locator('.model-picker').click();
assert.ok(await page.locator(".studio-model-menu .mi-mono .lucide-server").count() > 0, `${label}: custom providers use service icons`);
assert.equal(await page.locator(".studio-model-menu .mi-mono").first().textContent(), "");
assert.ok(await page.locator(".studio-model-menu .menu-current.lucide-check").count() > 0, `${label}: selection uses a library check`);
await page.keyboard.press("Escape");
await page.locator('[data-action="chrome.settings"]').evaluate((button) => button.click());
await page.locator('.prefs-nav [data-value="controls"]').click();
await page.setViewportSize({ width, height: 900 });
const order = page.locator('[data-action="appearance.nav-entry-move"]');
await order.first().scrollIntoViewIfNeeded();
check(await order.evaluateAll(measure), `${label}/reorder`);
const down = order.locator('.lucide-arrow-down').first().locator('..');
const value = await down.getAttribute("data-value");
await down.focus();
await page.keyboard.press("Enter");
const moved = page.locator(`[data-action="appearance.nav-entry-move"][data-value="${value.replace(/:down$/, ":up")}"]`);
assert.equal(await moved.isEnabled(), true, `${label}: reordering remains keyboard operable`);
await page.locator("#set-naventries").screenshot({ path: `perf/shots/control-icons-library-${colorScheme}-${width}.png` });
assert.deepEqual(errors, []);
await page.close();
console.log(`PASS semantic icons ${label}: tools, providers, selection and keyboard reordering`);
}
} finally {
await browser.close();
}
+3 -5
View File
@@ -1,5 +1,3 @@
// Settings and feedback centre on the window, not on the area below the title
// bar; a phone-width window keeps settings full screen. Geometry needs a real browser.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
@@ -20,10 +18,10 @@ for (const [w, h] of [[1600, 1000], [1280, 760], [390, 800]]) {
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?turns=5&pref=en`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.keyboard.press("Meta+Comma");
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-sheet");
await page.waitForTimeout(300);
const prefs = await box(page, ".prefs-sheet");
const prefs = await box(page, ".prefs-screen");
await page.keyboard.press("Escape");
await page.waitForSelector(".prefs-sheet", { state: "detached" });
await page.evaluate(() => document.querySelector('[data-action="feedback.open"]')?.click());
@@ -32,7 +30,7 @@ for (const [w, h] of [[1600, 1000], [1280, 760], [390, 800]]) {
const fbk = await box(page, ".fbk");
const tag = `${w}x${h}`;
if (w > 760) {
check(`${tag}: settings sits in the window's vertical middle`, Math.abs(prefs.top - prefs.bottom) <= 2, JSON.stringify(prefs));
check(`${tag}: settings fills the window`, prefs.top === 0 && prefs.bottom === 0, JSON.stringify(prefs));
check(`${tag}: feedback sits in the window's vertical middle`, Math.abs(fbk.top - fbk.bottom) <= 2, JSON.stringify(fbk));
} else {
check(`${tag}: settings still reaches the bottom of a phone-width window`, prefs.bottom === 0, JSON.stringify(prefs));
+8
View File
@@ -61,6 +61,14 @@ function benchPack(): ThemePack | null {
class BenchPort extends MockPort {
private driverRunning: boolean | undefined;
async providerHealthSettings() {
return query.has("modelhealth") ? { enabled: true } : super.providerHealthSettings();
}
async providerHealth(name: string) {
return query.has("modelhealth")
? { connection: "reachable" as const, wallet: "unsupported" as const, checkedAt: Date.now() }
: super.providerHealth(name);
}
opensFiles() { return query.has("deliveries") || super.opensFiles(); }
revealsFiles() { return query.has("deliveries") || super.revealsFiles(); }
async account() {
+2 -2
View File
@@ -38,8 +38,8 @@ for (const scheme of ["light", "dark"]) {
await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => /备份原文件并修复/.test(x.textContent))?.click());
await page.waitForTimeout(300);
await page.evaluate(() => document.activeElement?.blur());
const settingsOut = await outside(".prefs-sheet", 60);
check(`${scheme}: Tab never leaves the settings sheet`, settingsOut.length === 0, settingsOut.slice(0, 3).join("; "));
const settingsOut = await outside(".prefs-screen", 60);
check(`${scheme}: Tab never leaves the full-screen settings surface`, settingsOut.length === 0, settingsOut.slice(0, 3).join("; "));
await page.keyboard.press("Escape");
await page.waitForTimeout(400);
+78 -19
View File
@@ -1,28 +1,87 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
const PAGE = new URL(process.env.PERF_URL ?? "http://localhost:4399/perf.html");
PAGE.searchParams.set("modelhealth", "1");
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 1300, height: 900 } });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(".model-picker");
await page.locator(".model-picker").first().click();
await page.waitForSelector(".studio-model-menu .studio-item-line");
const sizes = await page.evaluate(() => {
const lines = [...document.querySelectorAll(".studio-model-menu .studio-item-line")].filter((line) => line.querySelector(".studio-item-badge"));
for (const line of lines) line.querySelector(".lb").textContent = "workbuddy/deepseek-v4.1-flash-preview-extended";
return lines.map((line) => {
const badge = line.querySelector(".studio-item-badge").getBoundingClientRect();
return { width: Math.round(badge.width), height: Math.round(badge.height) };
await mkdir("perf/shots", { recursive: true });
for (const lang of ["zh", "en"]) for (const width of [1300, 760, 390]) for (const scale of [1, 1.3]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: lang === "zh" ? "light" : "dark" });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.addInitScript((language) => localStorage.setItem("rx-lang", language), lang);
await page.goto(PAGE.href, { waitUntil: "networkidle" });
await page.waitForSelector(".model-picker");
await page.evaluate((value) => document.documentElement.style.setProperty("--fs-scale", String(value)), scale);
await page.locator(".model-picker").first().click();
const menu = page.locator(".studio-model-menu:not([hidden])");
await menu.locator('.menu-item-status[data-tone="positive"]').first().waitFor();
const effortWrapped = await menu.locator(".menu-aside .studio-item-line").evaluateAll((lines) => lines.some((line) => {
const meta = line.querySelector(".studio-item-meta");
return meta && meta.getBoundingClientRect().top >= line.querySelector(".lb").getBoundingClientRect().bottom;
}));
assert.ok(!effortWrapped, `${lang}/${width}/${scale}: reasoning choices keep their label and tier together`);
const vision = menu.locator(".menu-rows button.mi:has(.studio-item-badge)").first();
const visionRef = await vision.getAttribute("data-value");
await vision.click();
await page.waitForFunction((value) => document.querySelector('.studio-model-menu:not([hidden]) button[data-on]')?.dataset.value === value, visionRef);
await page.keyboard.press("Escape");
await page.locator(".model-picker").first().click();
await menu.locator('.menu-item-status[data-tone="positive"]').first().waitFor();
await menu.evaluate((element) => {
for (const line of element.querySelectorAll(".menu-rows .studio-item-line")) {
line.querySelector(".lb").textContent = "deepseek-v4.1-flash-preview-extended";
}
for (const head of element.querySelectorAll(".menu-rows .mi.head")) {
head.querySelector(".lb").textContent = "Shanghai_AI_Lab";
head.querySelector(".rt").textContent = "discovery-api.intern-ai.org.cn/compatible-mode/v1";
}
});
});
assert.ok(sizes.length > 0, "the menu has a model that reads images");
for (const size of sizes) assert.ok(size.height <= 20, `a long model name leaves the badge on one line (${JSON.stringify(size)})`);
assert.deepEqual(errors, []);
console.log(`PASS model menu badge stays on one line beside a long name (${sizes.length} rows)`);
const measured = await menu.evaluate((element) => {
const rows = element.querySelector(".menu-rows");
const sizes = [...rows.querySelectorAll(".studio-item-line")].map((line) => {
const label = line.querySelector(".lb");
const badge = line.querySelector(".studio-item-badge");
const status = line.closest("button").querySelector(".menu-item-status");
const labelBox = label.getBoundingClientRect();
const badgeBox = badge?.getBoundingClientRect();
return {
labelWidth: labelBox.width,
badgeHeight: badgeBox?.height,
badgeLine: badge ? parseFloat(getComputedStyle(badge).lineHeight) + 4 : 0,
badgeClipped: badge ? badge.scrollWidth > badge.clientWidth + 1 : false,
badgeOverlap: badgeBox ? badgeBox.right > status.getBoundingClientRect().left : false,
};
});
return { sizes, overflow: rows.scrollWidth - rows.clientWidth, menuOverflow: element.scrollWidth - element.clientWidth };
});
const tag = `${lang}/${width}/${scale}`;
await menu.screenshot({ path: `perf/shots/model-menu-${lang}-${width}-${scale}.png` });
assert.ok(measured.sizes.some((size) => size.badgeHeight), `${tag}: a model reads images`);
for (const size of measured.sizes) {
assert.ok(size.labelWidth >= 96 * scale, `${tag}: the model name remains readable (${JSON.stringify(size)})`);
if (size.badgeHeight) assert.ok(size.badgeHeight <= size.badgeLine + 1 && !size.badgeClipped && !size.badgeOverlap, `${tag}: the image badge stays whole (${JSON.stringify(size)})`);
}
assert.ok(measured.overflow <= 1 && measured.menuOverflow <= 1, `${tag}: long endpoints do not create horizontal scrolling (${JSON.stringify(measured)})`);
await menu.locator(".mfind").fill("kimi");
const choice = menu.locator(".menu-rows button.mi:not(.plain)").first();
const picked = await choice.getAttribute("data-value");
assert.ok(picked?.includes("kimi"), `${tag}: search still finds the model`);
await choice.click();
await page.waitForFunction((value) => document.querySelector('.studio-model-menu:not([hidden]) button[data-on]')?.dataset.value === value, picked);
await page.keyboard.press("Escape");
await menu.waitFor({ state: "hidden" });
await page.locator(".model-picker").first().click();
await page.waitForFunction((value) => document.querySelector('.studio-model-menu:not([hidden]) button[data-on]')?.dataset.value === value, picked);
assert.equal(await menu.locator(".mfind").inputValue(), "", `${tag}: reopening clears the search`);
await page.keyboard.press("Escape");
await menu.waitFor({ state: "hidden" });
assert.deepEqual(errors, []);
console.log(`PASS model menu ${tag}: names, badges, connection status and endpoints fit`);
await page.close();
}
} finally {
await browser.close();
}
+1 -1
View File
@@ -19,7 +19,7 @@ try {
await page.waitForSelector(".app", { timeout: 20000 });
await page.getByRole("button", { name: "设置", exact: true }).click();
await page.locator('[role="tab"][title="模型"]').click();
await page.locator('[data-action="settings.open-page"][data-target="providers"]').click();
await page.locator('[data-action="settings.page-index"][data-target="providers"]').click();
await page.getByText("myrelay", { exact: true }).first().click();
await page.getByText("从服务商读取可用模型").first().click();
await page.waitForSelector(".mrows .mline");
+1 -1
View File
@@ -24,7 +24,7 @@ for (const scheme of ["light", "dark"]) {
await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => /备份原文件并修复/.test(x.textContent))?.click());
await page.waitForTimeout(400);
await page.evaluate(() => document.querySelector('[role="tab"][title="模型"]')?.click());
await page.evaluate(() => document.querySelector('[data-action="settings.open-page"][data-target="providers"]')?.click());
await page.evaluate(() => document.querySelector('[data-action="settings.page-index"][data-target="providers"]')?.click());
await page.waitForSelector(".pdetail", { state: "attached" });
await page.evaluate(() => [...document.querySelectorAll("button,li,a")].find((x) => /myrelay-work/.test(x.textContent) && x.textContent.length < 80)?.click());
await page.waitForSelector(".pdetail .mlhead", { state: "attached" });
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = new URL(process.env.PERF_URL ?? "http://localhost:4399/perf.html");
const browser = await chromium.launch();
try {
await mkdir("perf/shots", { recursive: true });
for (const colorScheme of ["light", "dark"]) for (const pack of ["default", "sky", "photo"]) {
const page = await browser.newPage({ viewport: { width: 1300, height: 900 }, colorScheme, reducedMotion: "reduce" });
await page.addInitScript(() => { localStorage.setItem("rx-lang", "zh"); localStorage.setItem("rx-nav-rail", "on"); });
const url = new URL(base);
for (const [key, value] of Object.entries({ pref: "zh", ws: "2", sess: "6", turns: "1" })) url.searchParams.set(key, value);
if (pack !== "default") url.searchParams.set("pack", pack);
await page.goto(url.href, { waitUntil: "networkidle" });
const current = page.locator('.navbtn[aria-current="page"]');
await current.waitFor();
const label = `${colorScheme}/${pack}`;
await page.locator(".nav").screenshot({ path: `perf/shots/nav-selection-${colorScheme}-${pack}.png` });
const colors = await current.evaluate((button) => {
const ink = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
const rgb = (color) => {
ink.clearRect(0, 0, 1, 1); ink.fillStyle = color; ink.fillRect(0, 0, 1, 1);
return [...ink.getImageData(0, 0, 1, 1).data];
};
const style = getComputedStyle(button);
const bg = rgb(style.backgroundColor);
const fg = rgb(style.color);
const over = (top, bottom) => {
const a = top[3] / 255, b = bottom[3] / 255, opacity = a + b * (1 - a);
return opacity ? top.slice(0, 3).map((v, i) => (v * a + bottom[i] * b * (1 - a)) / opacity).concat(opacity * 255) : top;
};
let surface = [0, 0, 0, 0];
for (let element = button; element; element = element.parentElement) surface = over(surface, rgb(getComputedStyle(element).backgroundColor));
const anchor = document.createElement("span");
anchor.style.backgroundColor = "var(--accent)";
button.append(anchor);
const accent = rgb(getComputedStyle(anchor).backgroundColor);
anchor.remove();
const luminance = (color) => color.slice(0, 3).map((v) => {
const s = v / 255; return s <= .04045 ? s / 12.92 : ((s + .055) / 1.055) ** 2.4;
}).reduce((sum, v, i) => sum + v * [.2126, .7152, .0722][i], 0);
const [a, b] = [luminance(over(fg, surface)), luminance(surface)].sort((x, y) => y - x);
return { bg, accent, contrast: (a + .05) / (b + .05), marker: getComputedStyle(button, "::before").width };
});
assert.notDeepEqual(colors.bg, colors.accent, `${label}: navigation uses a quiet selection surface`);
assert.ok(colors.contrast >= 3, `${label}: the selected icon stays legible (${JSON.stringify(colors)})`);
assert.ok(parseFloat(colors.marker) >= 2, `${label}: a side marker also identifies the current section`);
await current.focus();
assert.ok(await current.evaluate((button) => parseFloat(getComputedStyle(button).outlineWidth) >= 2), `${label}: keyboard focus stays visible`);
assert.equal(await current.getAttribute("aria-label"), "会话");
await page.close();
console.log(`PASS navigation selection ${label}: quiet surface, contrast and focus`);
}
} finally {
await browser.close();
}
@@ -40,12 +40,12 @@ for (const scheme of ["light", "dark"]) {
await page.waitForSelector('[data-action="settings.section"][data-value="tools"]');
await section("session");
await page.waitForTimeout(300);
check(`${tag}: session messages have a settings entry of their own`, (await page.locator('[data-action="settings.open-page"][data-target="peers"]').count()) === 1);
check(`${tag}: session messages have a settings entry of their own`, (await page.locator('[data-action="settings.page-index"][data-target="peers"]').count()) === 1);
await section("tools");
await page.waitForTimeout(300);
check(`${tag}: and no longer sit at the foot of tools and permissions`, (await page.locator('[role="switch"][aria-label="启用会话间消息"]').count()) === 0);
await section("session");
await page.locator('[data-action="settings.open-page"][data-target="peers"]').click();
await page.locator('[data-action="settings.page-index"][data-target="peers"]').click();
await page.waitForSelector('[role="switch"][aria-label="启用会话间消息"]', { timeout: 8000 });
const s = await page.evaluate(() => {
const sw = (n) => document.querySelector(`[role="switch"][aria-label="${n}"]`);
@@ -1,5 +1,3 @@
// A wide settings window centres its content column between the nav and
// the window edge: the room left over is shared, not piled up on the right.
import assert from "node:assert/strict";
import { chromium } from "playwright";
@@ -18,9 +16,17 @@ try {
const room = await page.evaluate(() => {
const main = document.querySelector(".prefs-main").getBoundingClientRect();
const col = document.querySelector(".prefs-main .prefs-col").getBoundingClientRect();
return { left: Math.round(col.left - main.left), right: Math.round(main.right - col.right), width: Math.round(col.width) };
const screen = document.querySelector(".prefs").getBoundingClientRect();
const sheet = document.querySelector(".prefs-sheet").getBoundingClientRect();
const nav = document.querySelector(".prefs-nav").getBoundingClientRect();
const cards = [...document.querySelectorAll(".prefs-col > .prefs-card")].map((c) => c.getBoundingClientRect());
return { sheet: { left: sheet.left, right: sheet.right, bottom: sheet.bottom }, nav: nav.width, left: Math.round(col.left - main.left), right: Math.round(main.right - col.right), width: Math.round(col.width), screen: { x: screen.x, y: screen.y, width: screen.width, height: screen.height }, single: cards.every((c, i) => Math.abs(c.width - col.width) < 1 && (!i || c.top >= cards[i - 1].bottom)) };
});
assert.ok(room.width <= 1200, `the column keeps its cap (${room.width})`);
assert.deepEqual(room.screen, { x: 0, y: 0, width, height: 900 }, "settings occupies the application window");
assert.deepEqual(room.sheet, { left: 0, right: width, bottom: 900 });
assert.equal(room.nav, 240);
assert.ok(room.single, "all cards fill one content column");
assert.ok(room.left >= 20 && room.left <= 32 && room.right >= 20 && room.right <= 32, `${width}px: only fixed content gutters remain (${JSON.stringify(room)})`);
assert.ok(Math.abs(room.left - room.right) <= 2, `${width}px: the room is shared evenly (left ${room.left}, right ${room.right})`);
assert.deepEqual(errors, []);
console.log(`PASS ${width}px: column ${room.width}px with ${room.left}px each side`);
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const browser = await chromium.launch();
try {
await mkdir("perf/shots", { recursive: true });
for (const colorScheme of ["light", "dark"]) for (const language of ["zh", "en"]) for (const width of [1800, 1400, 1000, 390]) for (const scale of [1, 1.3]) {
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 }, colorScheme, reducedMotion: "reduce" });
page.setDefaultTimeout(10000);
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("requestfailed", (request) => errors.push(`${request.url()}: ${request.failure()?.errorText}`));
await page.addInitScript((value) => localStorage.setItem("rx-lang", value), language);
await page.goto(`${base}?pref=${language}&turns=1`, { waitUntil: "networkidle" });
assert.deepEqual(errors, [], "the browser fixture and its styles load successfully");
await page.locator('[data-action="chrome.settings"]').evaluate((button) => button.click());
await page.locator('.prefs-nav [data-value="model"]').click();
await page.getByText("myrelay", { exact: true }).first().click();
const form = page.locator(".addp[data-edit]");
await form.locator('.token-limits input').first().fill("1600000");
await form.locator('[data-value="model-window"]').last().fill("1000000");
await form.locator('[data-value="model-max-output"]').last().fill("1000000");
await page.setViewportSize({ width, height: 1000 });
await page.evaluate(async (value) => {
document.documentElement.style.setProperty("--fs-scale", String(value));
await document.fonts.ready;
}, scale);
const label = `${colorScheme}-${language}-${width}-${scale}`;
const billing = form.locator('[data-value="billing-mode"]');
const currency = form.locator('[data-value="model-price-currency"]').last();
await currency.selectOption("USD");
await billing.selectOption("subscription_equivalent");
const measure = () => form.evaluate((root) => {
const context = document.createElement("canvas").getContext("2d");
const fields = [...root.querySelectorAll('.ml-row > .unit-field input, .ml-row > select, [data-value="billing-mode"]')];
const clipped = fields.flatMap((field) => {
const style = getComputedStyle(field);
context.font = style.font;
const text = field.tagName === "SELECT" ? field.selectedOptions[0].textContent : field.value || field.placeholder;
const available = field.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
const needed = context.measureText(text).width;
return needed > available + 1 ? [{ field: field.getAttribute("aria-label"), text, needed, available }] : [];
});
const rows = [...root.querySelectorAll(".ml-row")];
const uneven = rows.flatMap((row) => {
const heights = [...row.querySelectorAll(":scope > .unit-field, :scope > select")].map((field) => field.getBoundingClientRect().height);
return Math.max(...heights) - Math.min(...heights) > 1 ? [heights] : [];
});
const outside = fields.filter((field) => {
const box = field.getBoundingClientRect(), parent = field.closest(".ml-row, label").getBoundingClientRect();
return box.right > parent.right + 1 || box.left < parent.left - 1;
}).map((field) => field.getAttribute("aria-label"));
for (const row of rows) {
const controls = row.querySelectorAll(":scope > .unit-field, :scope > select");
if (row.getBoundingClientRect().right - controls[controls.length - 1].getBoundingClientRect().right > 1) outside.push("unfilled row");
}
return { clipped, uneven, outside };
});
const selected = await measure();
await form.locator(".ml-group").first().scrollIntoViewIfNeeded();
await page.screenshot({ path: `perf/shots/provider-fields-${label}.png` });
assert.deepEqual(selected, { clipped: [], uneven: [], outside: [] }, `${label}: values, inherited hints and the full billing choice fit aligned controls`);
await billing.selectOption("payg");
assert.deepEqual(await measure(), { clipped: [], uneven: [], outside: [] }, `${label}: the default billing choice fits too`);
await currency.focus();
await page.keyboard.press("Space");
await page.waitForFunction(() => document.querySelector('[data-value="model-price-currency"]:open'));
await page.keyboard.press("Enter");
assert.equal(await currency.inputValue(), "USD", `${label}: keyboard confirmation preserves the currency`);
await form.locator('[data-value="model-window"]').last().fill("");
assert.deepEqual(await measure(), { clipped: [], uneven: [], outside: [] }, `${label}: clearing a value reveals its complete inheritance hint`);
await form.locator('.token-limits input').first().fill("");
assert.deepEqual(await measure(), { clipped: [], uneven: [], outside: [] }, `${label}: an undeclared shared window also fits`);
await billing.scrollIntoViewIfNeeded();
await page.screenshot({ path: `perf/shots/provider-fields-prices-${label}.png` });
assert.deepEqual(errors, []);
await page.close();
console.log(`PASS provider fields ${label}: text fit, aligned heights, currency, billing and inheritance`);
}
} finally {
await browser.close();
}
@@ -19,7 +19,7 @@ try {
await page.waitForSelector(".app", { timeout: 20000 });
await page.getByRole("button", { name: "设置", exact: true }).click();
await page.locator('[role="tab"][title="模型"]').click();
await page.locator('[data-action="settings.open-page"][data-target="providers"]').click();
await page.locator('[data-action="settings.page-index"][data-target="providers"]').click();
await page.waitForSelector('[data-action-click="provider.select"]');
const text = await page.evaluate(() => [...document.querySelectorAll('[data-action-click="provider.select"]')].map((b) => b.textContent ?? ""));
check(`${scheme}: the server on port 8756 is listed`, text.some((t) => t.includes("127.0.0.1:8756")), text.join(" | "));
@@ -1,5 +1,3 @@
// With the icon rail hidden, the sidebar's shortcut strip is one row of equal icon buttons on a single line,
// and the rows around it (new session, search, feedback) draw their icon and label at the sidebar's own edge.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=1&sess=1&turns=1";
@@ -17,18 +15,19 @@ try {
await ctx.addInitScript(() => { try { localStorage.setItem("rx-nav-rail", "off"); } catch {} });
const page = await ctx.newPage();
await page.goto(BASE, { waitUntil: "networkidle" });
await page.locator(".studio-tools").hover();
await page.waitForSelector(".studio-quicknav > button");
const strip = await page.evaluate(() => [...document.querySelectorAll(".studio-quicknav > button")].map((b) => {
const r = b.getBoundingClientRect();
const svg = b.querySelector("svg, .studio-icon").getBoundingClientRect();
return { w: r.width, h: r.height, cy: r.y + r.height / 2, icon: [svg.width, svg.height], iconCy: svg.y + svg.height / 2 };
return { w: r.width, h: r.height, left: r.left, cy: r.y + r.height / 2, icon: [svg.width, svg.height], iconCy: svg.y + svg.height / 2 };
}));
const spread = (key) => Math.max(...strip.map((s) => s[key])) - Math.min(...strip.map((s) => s[key]));
check(`${scheme}: the shortcut strip has several buttons`, strip.length >= 5, `${strip.length}`);
check(`${scheme}: the shortcut card has several entries`, strip.length >= 5, `${strip.length}`);
check(`${scheme}: the shortcut buttons share one size`, spread("w") <= TOLERANCE && spread("h") <= TOLERANCE, JSON.stringify(strip.map((s) => [s.w, s.h])));
check(`${scheme}: the shortcut buttons and their icons sit on one horizontal line`, spread("cy") <= TOLERANCE && spread("iconCy") <= TOLERANCE, `${spread("cy")} / ${spread("iconCy")}`);
check(`${scheme}: the shortcut rows align and center their icons`, spread("left") <= TOLERANCE && strip.every((s) => Math.abs(s.cy - s.iconCy) <= TOLERANCE), JSON.stringify(strip.map((s) => [s.left, s.cy - s.iconCy])));
check(`${scheme}: the shortcut icons share one size`, new Set(strip.map((s) => s.icon.join("x"))).size === 1, strip.map((s) => s.icon.join("x")).join(" "));
const rows = await page.evaluate(() => [".studio-new-task", ".studio-search", ".studio-feedback"].map((s) => {
const rows = await page.evaluate(() => [".studio-new-task", ".studio-search", ".studio-tools-row", ".studio-feedback"].map((s) => {
const row = document.querySelector(s);
if (!row) return null;
const r = row.getBoundingClientRect();
@@ -16,6 +16,7 @@ try {
await ctx.addInitScript(() => { try { localStorage.setItem("rx-nav-rail", "off"); } catch {} });
const page = await ctx.newPage();
await page.goto(BASE, { waitUntil: "networkidle" });
await page.locator(".studio-tools").hover();
await page.waitForSelector(".studio-quicknav > button");
await page.mouse.move(1270, 890);
const looks = await page.evaluate(() => [...document.querySelectorAll(".studio-quicknav > button")].map((b) => {
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const browser = await chromium.launch();
try {
await mkdir("perf/shots", { recursive: true });
for (const colorScheme of ["light", "dark"]) for (const width of [1300, 390]) for (const language of ["zh", "en"]) for (const scale of [1, 1.3]) {
const page = await browser.newPage({ viewport: { width: 1300, height: 900 }, colorScheme, reducedMotion: "reduce" });
page.setDefaultTimeout(10000);
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.addInitScript((value) => localStorage.setItem("rx-lang", value), language);
await page.goto(`${base}?pref=${language}&turns=1`, { waitUntil: "networkidle" });
await page.locator('[data-action="chrome.settings"]').click();
await page.locator('.prefs-nav [data-value="common"]').click();
const select = page.locator('[data-action="appearance.language"]');
await select.waitFor();
await page.setViewportSize({ width, height: 900 });
await page.evaluate(async (value) => {
document.documentElement.style.setProperty("--fs-scale", String(value));
await document.fonts.ready;
}, scale);
const label = `${colorScheme}-${width}-${language}-${scale}`;
const colors = await select.evaluate((field) => ({ ink: getComputedStyle(field).color, ground: getComputedStyle(field).backgroundColor }));
const shot = await select.screenshot({ path: `perf/shots/select-alignment-${label}.png` });
const bounds = await page.evaluate(async ({ data, ink, ground }) => {
const picture = new Image();
picture.src = `data:image/png;base64,${data}`;
await picture.decode();
const canvas = document.createElement("canvas");
canvas.width = picture.width; canvas.height = picture.height;
const ctx = canvas.getContext("2d");
const rgb = (color) => { ctx.fillStyle = color; ctx.fillRect(0, 0, 1, 1); return [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3); };
const fg = rgb(ink), bg = rgb(ground);
ctx.drawImage(picture, 0, 0);
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
const distance = (pixel, color) => color.reduce((sum, channel, i) => sum + (channel - pixel[i]) ** 2, 0);
let top = canvas.height, bottom = -1;
for (let y = 1; y < canvas.height - 1; y++) for (let x = 12; x < canvas.width - 34; x++) {
const pixel = pixels.slice((y * canvas.width + x) * 4, (y * canvas.width + x) * 4 + 3);
if (distance(pixel, fg) < distance(pixel, bg)) { top = Math.min(top, y); bottom = Math.max(bottom, y); }
}
return { top, bottom, height: canvas.height, offset: Math.abs((top + bottom + 1 - canvas.height) / 2) };
}, { data: shot.toString("base64"), ...colors });
assert.ok(bounds.bottom > bounds.top, `${label}: the selected text is visible`);
assert.ok(bounds.offset <= 2, `${label}: text is vertically centered (${JSON.stringify(bounds)})`);
assert.deepEqual(await select.locator("option").allTextContents(), language === "zh" ? ["跟随系统", "简体中文", "英文"] : ["Follow the system", "简体中文", "English"]);
await select.focus();
await page.keyboard.press("Space");
await page.waitForFunction(() => document.querySelector('[data-action="appearance.language"]').matches(":open"));
await page.keyboard.press("Escape");
assert.equal(await page.evaluate(() => localStorage.getItem("rx-lang")), language, `${label}: dismissing the picker preserves the language`);
assert.deepEqual(errors, []);
await page.close();
console.log(`PASS select ${label}: centered text, language names and keyboard dismissal`);
}
} finally {
await browser.close();
}
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const context = await browser.newContext({ viewport: { width: 1500, height: 900 }, colorScheme: scheme, deviceScaleFactor: 2 });
const page = await context.newPage();
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.locator(".prefs-nav button", { hasText: /^常用/ }).first().click();
await page.waitForTimeout(500);
const png = await page.locator("select").first().screenshot();
const ink = await page.evaluate(async ({ image }) => {
const img = new Image();
img.src = image;
await img.decode();
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const context2d = canvas.getContext("2d");
context2d.drawImage(img, 0, 0);
const data = context2d.getImageData(0, 0, img.width, img.height).data;
const at = (x, y) => [data[(y * img.width + x) * 4], data[(y * img.width + x) * 4 + 1], data[(y * img.width + x) * 4 + 2]];
const bg = at(Math.floor(img.width / 2), 6);
let top = img.height;
let bottom = -1;
for (let y = 4; y < img.height - 4; y++) {
for (let x = 16; x < img.width * 0.7; x++) {
const p = at(x, y);
if (Math.abs(p[0] - bg[0]) + Math.abs(p[1] - bg[1]) + Math.abs(p[2] - bg[2]) > 150) {
top = Math.min(top, y);
bottom = Math.max(bottom, y);
}
}
}
return { above: top, below: img.height - 1 - bottom };
}, { image: `data:image/png;base64,${png.toString("base64")}` });
assert.ok(Math.abs(ink.above - ink.below) <= 4, `${scheme}: the chosen text sits in the middle of its select (${JSON.stringify(ink)})`);
console.log(`PASS ${scheme}: select text centred (${ink.above}px above, ${ink.below}px below at 2x)`);
await context.close();
}
} finally {
await browser.close();
}
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 1500, height: 900 }, deviceScaleFactor: 2 });
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.locator(".prefs-nav button", { hasText: /^模型/ }).first().click();
await page.waitForTimeout(400);
await page.getByRole("button", { name: "按用途设置" }).click();
await page.waitForTimeout(500);
const fit = await page.evaluate(() => {
const select = document.querySelector("select.usage-pick");
const option = document.createElement("option");
option.value = "long";
option.textContent = "deepseek-v4.1-flash-1017-preview-extended · 读图";
select.appendChild(option);
select.value = "long";
select.dispatchEvent(new Event("change", { bubbles: true }));
return new Promise((resolve) => requestAnimationFrame(() => {
const box = select.getBoundingClientRect();
resolve({ height: Math.round(box.height), overflow: select.scrollHeight - select.clientHeight, whiteSpace: getComputedStyle(select).whiteSpace });
}));
});
assert.equal(fit.whiteSpace, "nowrap", "a select never wraps the name it shows");
assert.ok(fit.overflow <= 2, `a long chosen name does not spill out of its box (${JSON.stringify(fit)})`);
console.log("PASS a long chosen name stays on one line inside its select");
} finally {
await browser.close();
}
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const browser = await chromium.launch();
try {
await mkdir("perf/shots", { recursive: true });
for (const colorScheme of ["light", "dark"]) for (const width of [1600, 390]) for (const scale of [1, 1.3]) {
const page = await browser.newPage({ viewport: { width: 1300, height: 1000 }, colorScheme, reducedMotion: "reduce" });
page.setDefaultTimeout(10000);
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(`${base}?pref=zh&turns=1`, { waitUntil: "networkidle" });
await page.locator('[data-action="chrome.settings"]').click();
await page.locator('.prefs-nav').waitFor();
await page.setViewportSize({ width, height: 1000 });
await page.evaluate((size) => document.documentElement.style.setProperty('--fs-scale', String(size)), scale);
const sections = await page.locator('.prefs-nav [role="tab"]').evaluateAll((tabs) => tabs.map((tab) => tab.dataset.value));
for (const section of sections) {
await page.locator(`.prefs-nav [data-value="${section}"]`).click();
await page.waitForTimeout(200);
const geometry = await page.locator('.prefs-screen').evaluate((root) => {
const sheet = root.querySelector('.prefs-sheet').getBoundingClientRect();
const panel = root.querySelector('.prefs-main');
const col = panel.querySelector('.prefs-col').getBoundingClientRect();
const cards = [...panel.querySelectorAll('.prefs-col > .prefs-card')].map((card) => card.getBoundingClientRect());
const nav = root.querySelector('.prefs-nav').getBoundingClientRect();
return {
sheet: { left: sheet.left, right: sheet.right, bottom: sheet.bottom }, nav: nav.width,
gutters: [col.left - panel.getBoundingClientRect().left, panel.getBoundingClientRect().right - col.right],
spill: panel.scrollWidth - panel.clientWidth,
logsFit: [...panel.querySelectorAll('#set-logs .acct-note')].every((note) => note.scrollWidth <= note.clientWidth + 1),
single: cards.every((card, i) => Math.abs(card.width - col.width) < 1 && (!i || card.top >= cards[i - 1].bottom)),
};
});
const label = `${colorScheme} ${width}px ${scale} ${section}`;
if (geometry.spill > 1 || !geometry.logsFit) {
console.log(label, await page.locator('.prefs-main').evaluate((panel) => [...panel.querySelectorAll('*')]
.filter((node) => node.clientWidth > 1 && node.scrollWidth > node.clientWidth + 1)
.map((node) => ({ tag: node.tagName, class: node.className, width: node.clientWidth, scroll: node.scrollWidth }))));
await page.screenshot({ path: `perf/shots/settings-full-failed-${colorScheme}-${width}-${scale}-${section}.png` });
}
assert.deepEqual(geometry.sheet, { left: 0, right: width, bottom: 1000 }, label);
assert.ok(geometry.single, `${label}: full-width cards in one column`);
assert.ok(geometry.spill <= 1, `${label}: horizontal overflow ${geometry.spill}px`);
assert.ok(geometry.logsFit, `${label}: log paths wrap within their rows`);
if (width > 640) {
assert.equal(geometry.nav, 240, label);
assert.deepEqual(geometry.gutters, [24, 24], label);
}
if (section === 'common' && scale === 1) await page.screenshot({ path: `perf/shots/settings-full-${colorScheme}-${width}.png` });
}
assert.deepEqual(errors, []);
console.log(`PASS full-screen settings ${colorScheme} ${width}px scale ${scale}: ${sections.length} categories`);
await page.close();
}
} finally {
await browser.close();
}
@@ -25,14 +25,18 @@ try {
}
await page.locator(".prefs-nav button", { hasText: /^操作与性能/ }).first().click();
await page.waitForTimeout(400);
await page.getByRole("button", { name: "调整导航" }).click();
const keys = await page.locator('#set-keys').elementHandle();
await page.locator('[data-action="settings.page-index"][data-target="naventries"]').click();
await page.waitForTimeout(600);
const shown = await page.evaluate(() => {
const card = document.querySelector(".prefs-col .deep-page").closest(".prefs-card");
return [...card.children].filter((c) => !c.classList.contains("deep-page") && getComputedStyle(c).display !== "none").length;
assert.ok(await keys.evaluate((node) => node.isConnected), "locating a region preserves sibling editors");
const placement = await page.locator('#set-naventries').evaluate((node) => {
const panel = node.closest('.prefs-main');
const index = panel.querySelector('.page-index').getBoundingClientRect();
return { top: node.getBoundingClientRect().top, index: index.bottom, bottom: panel.getBoundingClientRect().bottom };
});
assert.equal(shown, 0, "an open sub-page shows nothing of the page it was opened from");
console.log("PASS sub-page: nothing of the parent page remains above it");
assert.ok(placement.top >= placement.index && placement.top < placement.bottom, `located region clears the pill index: ${JSON.stringify(placement)}`);
assert.equal(await page.locator('.deep-page').count(), 0);
console.log("PASS locating a region scrolls without replacing any editors");
assert.deepEqual(errors, []);
} finally {
await browser.close();
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const browser = await chromium.launch();
try {
await mkdir("perf/shots", { recursive: true });
for (const colorScheme of ["light", "dark"]) for (const width of [1440, 390]) for (const hidden of [false, true]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme, reducedMotion: "reduce" });
page.setDefaultTimeout(10000);
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.addInitScript((hide) => {
localStorage.setItem("rx-nav-rail", "off");
localStorage.setItem("rx-lang", "zh");
localStorage.setItem("rx-nav-entries", JSON.stringify({ order: ["remote", "usage"], hidden: hide ? ["usage", "files", "tools", "ext", "memory", "remote"] : [] }));
}, hidden);
await page.goto(`${base}?pref=zh&turns=1`, { waitUntil: "networkidle" });
if (width === 390) await page.locator('.studio-menu').click();
const root = page.locator(".studio-tools");
const settings = root.locator('[data-action="chrome.settings"]');
assert.equal(await settings.innerText(), "设置");
const alignment = await settings.evaluate((button) => {
const icon = button.querySelector('svg').getBoundingClientRect();
const feedback = document.querySelector('.studio-feedback svg').getBoundingClientRect();
return Math.abs(icon.x - feedback.x);
});
assert.ok(alignment <= 4, `settings and feedback icons align: ${alignment}`);
if (hidden) {
assert.equal(await root.locator('.studio-tools-toggle').count(), 0);
await root.hover();
await page.waitForTimeout(250);
assert.equal(await page.getByRole('menu', { name: '侧栏入口' }).count(), 0);
} else {
await root.hover();
const menu = page.getByRole('menu', { name: '侧栏入口' });
await menu.waitFor();
assert.deepEqual(await menu.locator('button > span').allTextContents(), ['远程', '用量', '文件', '工具与权限', '扩展', '记忆']);
const item = menu.getByRole('menuitem', { name: '文件' });
await item.hover();
await page.waitForTimeout(220);
assert.ok(await menu.isVisible(), 'pointer can cross the gap into the card');
const bounds = await menu.boundingBox();
assert.ok(bounds.x >= 0 && bounds.x + bounds.width <= width && bounds.y >= 0 && bounds.y + bounds.height <= 900);
assert.equal(await menu.locator('button svg.lucide').count(), 6);
await page.screenshot({ path: `perf/shots/sidebar-tools-${colorScheme}-${width}.png` });
await page.mouse.move(width - 5, 10);
await menu.waitFor({ state: 'hidden' });
const toggle = root.locator('.studio-tools-toggle');
await toggle.focus();
await page.keyboard.press('Enter');
await page.waitForFunction(() => document.activeElement?.getAttribute('role') === 'menuitem');
await page.keyboard.press('End');
assert.equal(await page.locator(':focus > span').innerText(), '记忆');
await page.keyboard.press('Escape');
assert.ok(await toggle.evaluate((node) => node === document.activeElement));
await toggle.click();
await item.click();
await menu.waitFor({ state: 'hidden' });
assert.equal(await page.locator('.app').getAttribute('data-browser'), 'on');
if (width === 390) await page.locator('.studio-menu').click();
}
await settings.click();
await page.locator('.prefs-screen').waitFor();
assert.equal(await page.getByRole('menu', { name: '侧栏入口' }).count(), 0);
assert.deepEqual(errors, []);
console.log(`PASS sidebar ${colorScheme} ${width}px ${hidden ? 'all hidden' : 'named hover/click/keyboard card'}`);
await page.close();
}
} finally {
await browser.close();
}
+1 -1
View File
@@ -37,7 +37,7 @@ for (const scheme of ["light", "dark"]) {
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.locator(".prefs-nav button", { hasText: "外观" }).first().click();
await page.locator('[data-action="settings.open-page"][data-target="size"]').click();
await page.locator('[data-action="settings.page-index"][data-target="size"]').click();
await page.locator('[data-action="appearance.text-size"]', { hasText: new RegExp(`^${name}$`) }).click();
await page.waitForTimeout(200);
if (shotDir && scale === 1.3) await page.screenshot({ path: `${shotDir}/${scheme}-settings-1.3.png` });
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const browser = await chromium.launch();
try {
await mkdir("perf/shots", { recursive: true });
for (const colorScheme of ["light", "dark"]) {
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 }, colorScheme, reducedMotion: "reduce" });
page.setDefaultTimeout(10000);
await page.addInitScript(() => localStorage.setItem("rx-lang", "zh"));
await page.goto(`${base}?pref=zh&turns=1`, { waitUntil: "networkidle" });
const pane = page.locator(".pane[data-active]");
await pane.waitFor();
const before = await pane.locator(".think").count();
const now = 1_700_000_000_000;
await page.clock.setFixedTime(now);
const feed = (event) => page.evaluate((ev) => window.__feed(ev), event);
await feed({ kind: "turn_started" });
await feed({ kind: "reasoning", text: "计时回归" });
await pane.locator(".think[data-live]").waitFor();
await page.clock.setFixedTime(now + 1200);
await page.waitForFunction(() => document.querySelector('.pane[data-active] .think[data-live] .think-label')?.textContent.includes("1.2"));
const tool = { id: "clock-call", name: "bash", readOnly: true, issuer: "model" };
await feed({ kind: "tool_dispatch", tool: { ...tool, partial: true } });
await page.waitForFunction(() => !document.querySelector('.pane[data-active] .think[data-live]'));
const labels = () => pane.locator(".think-label:not(.think-out)").allTextContents();
assert.equal((await labels()).at(-1), "思考 1.2 秒 · 4 字");
await page.clock.setFixedTime(now + 1_800_000);
await page.waitForTimeout(200);
assert.equal((await labels()).at(-1), "思考 1.2 秒 · 4 字", "a tool's running time never becomes thinking time");
await feed({ kind: "text", text: "检查工具输出。" });
await feed({ kind: "message", text: "检查工具输出。", reasoning: "计时回归", thoughtMs: 1100 });
await feed({ kind: "tool_dispatch", tool });
await feed({ kind: "tool_result", tool: { ...tool, output: "done" } });
assert.equal(await pane.locator(".think").count(), before + 1, "interleaved tool events keep one response card");
await feed({ kind: "reasoning", text: "第二轮" });
await page.clock.setFixedTime(now + 1_800_500);
await feed({ kind: "message", text: "完成。", reasoning: "第二轮", thoughtMs: 500 });
await feed({ kind: "turn_done" });
await page.clock.setFixedTime(now + 3_600_000);
await page.waitForTimeout(300);
assert.equal(await pane.locator(".think[data-live]").count(), 0);
assert.deepEqual((await labels()).slice(-2), ["思考 1.1 秒 · 4 字", "思考 0.5 秒 · 3 字"]);
await pane.locator(".think").last().scrollIntoViewIfNeeded();
await page.screenshot({ path: `perf/shots/thought-clock-${colorScheme}.png` });
await page.close();
console.log(`PASS ${colorScheme}: completed thoughts stay still after tools, later rounds and another 30 minutes`);
}
} finally {
await browser.close();
}
+1 -1
View File
@@ -23,7 +23,7 @@ for (const scheme of ["light", "dark"]) {
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector('[data-action="settings.section"][data-value="tools"]');
await page.evaluate(() => document.querySelector('[data-action="settings.section"][data-value="tools"]')?.click());
await page.locator('[data-action="settings.open-page"][data-target="web-access"]').click();
await page.locator('[data-action="settings.page-index"][data-target="web-access"]').click();
await page.waitForSelector("#set-web-access");
const r = await page.evaluate(() => {
const g = document.querySelector("#set-web-access");
+2 -3
View File
@@ -577,7 +577,6 @@ export const ACTIONS: UIAction[] = [
{ id: "tray.icon", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "tray.close-to-tray", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "notify.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "notify.more", kind: "view", target: "none", proof: "interaction" },
{ id: "notify.turn-done", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "notify.approval", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "notify.ask", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -651,8 +650,8 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.effects", kind: "view", target: "none", proof: "browser" },
{ id: "pane.close", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "settings.section", kind: "navigation", target: "none", proof: "interaction" },
{ id: "settings.open-page", kind: "navigation", target: "entity", proof: "interaction" },
{ id: "settings.close-page", kind: "navigation", target: "none", proof: "interaction" },
{ id: "settings.edit-persona", kind: "navigation", target: "none", proof: "interaction" },
{ id: "sidebar.tools", kind: "view", target: "none", proof: "interaction" },
{ id: "settings.page", kind: "navigation", target: "none", proof: "interaction" },
// Finding a setting changes what is on screen and nothing else: it reaches
// no kernel state, and the jump it offers is settings.section, because
@@ -16,7 +16,6 @@ export const WORKBENCH_ACTIONS: UIAction[] = [
{ id: "workbench.change-open", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.section-fold", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.changes-page", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.launch-find", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.dismiss", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.launch-browser", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.launch-terminal", kind: "view", target: "none", proof: "interaction" },
+12 -4
View File
@@ -90,11 +90,12 @@
.compose .btn.send { border: 0; background: var(--accent); color: var(--accent-fg); }
.compose .btn.send:hover:not(:disabled) { background: var(--accent); filter: brightness(1.18); }
.compose .btn.send:disabled { background: var(--overlay); color: var(--ghost); filter: none; opacity: 1; }
.compose .btn.send:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; box-shadow: none; }
.compose .btn.send:focus-visible, .compose .btn.stop:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; box-shadow: none; }
.compose .btn.send[data-running]:not(:disabled) { width: auto; display: inline-flex; gap: 5px; padding: 0 12px 0 10px; border-radius: var(--r-pill); background: var(--accent); color: var(--accent-fg); font: var(--w-med) var(--fs-ui)/1 var(--ui); }
.compose .btn.stop, .compose .btn.stop[data-primary] { border: 1px solid var(--err); background: transparent; color: var(--err-ink); }
.compose .btn.stop:hover { background: color-mix(in srgb, var(--err) 9%, transparent); border-color: var(--err); color: var(--err-ink); }
.compose .btn :is(.ic, .ic > svg) { width: 13px; height: 13px; }
.compose .btn.stop, .compose .btn.stop[data-primary] { border: 0; background: color-mix(in srgb, var(--err) 12%, transparent); color: var(--err-ink); }
.compose .btn.stop:hover:not(:disabled) { background: color-mix(in srgb, var(--err) 20%, transparent); color: var(--err-ink); }
.compose .btn :is(.ic, .ic > svg) { width: 18px; height: 18px; }
.compose .btn.send .ic > svg { stroke-width: 2; }
.studio-meterrail { left: 0; right: 0; padding: 0 2px; gap: 0; color: var(--faint); font: var(--w-reg) var(--fs-micro)/1 var(--ui); }
.studio-meterrail b { color: var(--muted); font-weight: var(--w-med); }
@@ -133,6 +134,13 @@
:root .composer-menu.studio-model-menu .studio-item-line > .lb { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-med) var(--fs-ui)/1.3 var(--ui); }
:root .composer-menu.studio-model-menu .studio-item-meta { flex: none; color: var(--faint); font: var(--w-reg) var(--fs-micro)/1.3 var(--ui); white-space: nowrap; }
:root .composer-menu.studio-model-menu .studio-item-badge { flex: none; white-space: nowrap; }
:root .composer-menu.studio-model-menu .menu-rows .mi.head .lb { min-width: 0; max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
:root .composer-menu.studio-model-menu .menu-rows .mi.head .rt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
:root .composer-menu.studio-model-menu .menu-rows .studio-item-line { display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; gap: 2px 8px; }
:root .composer-menu.studio-model-menu .menu-rows .studio-item-line > .lb { grid-column: 1 / -1; }
:root .composer-menu.studio-model-menu .menu-rows .studio-item-meta { grid-column: 1; grid-row: 2; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
:root .composer-menu.studio-model-menu .menu-rows .studio-item-badge { grid-column: 2; grid-row: 2; }
:root .composer-menu.studio-model-menu .menu-rows .mi .tx > .ds { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
:root .composer-menu.studio-model-menu .mi[data-on]::after { color: var(--net-ink); }
:root .composer-menu.studio-effort-menu .mi.head { padding: 12px 14px 8px; }
:root .composer-menu.studio-effort-menu .mi.head:first-child .lb { color: var(--text); font: var(--w-semi) var(--fs-ui)/1.3 var(--ui); }
@@ -76,7 +76,7 @@
.onb-demo .d.tool code { color: var(--faint); font: var(--w-reg) var(--fs-micro) var(--mono); }
.onb-demo .d.tool small { margin-left: auto; color: var(--faint); font: var(--w-reg) var(--fs-micro) var(--mono); }
.onb-demo .d.tool small.add { color: var(--add); }
.onb-demo .d.tool small.ok { color: var(--ok-ink); }
.onb-demo .d.tool small.ok { display: inline-flex; align-items: center; gap: 4px; color: var(--ok-ink); }
.onb-demo .d.done { margin-top: 4px; padding-top: 10px; border-top: 1px solid var(--hair); color: var(--ok-ink); }
.onb-demo .demo-spin { color: var(--net); }
.onb-demo .demo-spin path { animation: demo-draw 1.2s ease-in-out infinite; }
@@ -106,7 +106,7 @@
.onb-flow .onb-go .onb-spin { width: 16px; height: 16px; color: currentColor; opacity: 1; }
.onb-flow .onb-go .onb-spin path { animation: demo-draw 1.1s ease-in-out infinite; }
.onb-flow .onb-go .onb-check { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.onb-flow .onb-go .onb-check path { stroke-dasharray: 100; stroke-dashoffset: 100; animation: demo-draw 300ms ease-out forwards; }
.onb-flow .onb-go .onb-check path { stroke-dasharray: 32; stroke-dashoffset: 32; animation: demo-draw 300ms ease-out forwards; }
.onb-flow .onb-found[data-enter] { animation: onb-banner-in 260ms var(--ease-out) both; }
@keyframes onb-banner-in { from { opacity: 0; transform: translateY(-8px); } }
@media (prefers-reduced-motion: reduce) {
@@ -1,7 +1,6 @@
[data-fold~="phone"] .prefs { inset: 0; padding: 0; }
[data-fold~="phone"] .prefs-sheet { width: 100%; height: 100%; border: 0; border-radius: 0; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto auto minmax(0, 1fr); }
[data-fold~="phone"] .prefs-sheet .prefs-hd h1 { grid-column: 1; grid-row: 1; padding: 14px 16px 8px; }
[data-fold~="phone"] .prefs-sheet .prefs-actions { grid-column: 1; grid-row: 1; justify-self: end; align-self: center; padding: 8px 10px; }
[data-fold~="phone"] .prefs-sheet .prefs-find { grid-column: 1; grid-row: 2; width: calc(100% - 24px); margin: 0 12px 8px; }
[data-fold~="phone"] .prefs-sheet > .prefs-hd > .prefs-find { grid-column: 1; grid-row: 2; width: calc(100% - 24px); margin: 0 12px 8px; }
[data-fold~="phone"] .prefs-sheet .prefs-nav { grid-column: 1; grid-row: 3; display: flex; flex-direction: row; gap: 4px; padding: 4px 28px 8px 10px; mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--hair); scrollbar-width: none; }
@@ -9,6 +8,7 @@
[data-fold~="phone"] .prefs-sheet .prefs-nav :is(.navsec, .nv) { display: none; }
[data-fold~="phone"] .prefs-sheet .prefs-nav button { flex: none; min-height: 32px; padding: 6px 10px; }
[data-fold~="phone"] .prefs-sheet .prefs-main { grid-column: 1; grid-row: 4; padding: 16px 14px 48px; }
[data-fold~="phone"] .prefs-sheet .prefs-main > .page-index { top: -16px; }
[data-fold~="phone"] .prefs-sheet[data-solo] .prefs-main { grid-column: 1; grid-row: 2 / -1; }
[data-fold~="phone"] .prefs-sheet .prefs-main .prefs-pagetitle { display: grid; gap: 6px; padding-right: 0; }
[data-fold~="phone"] .prefs-sheet .prefs-main .prefs-pagetitle > * { max-width: none; width: auto; }
@@ -29,13 +29,6 @@ describe("the settings sheet on a phone", () => {
expect(rule(S + " .prefs-main")).toMatch(/grid-column:\s*1/);
});
it("puts the close button in the title row, at the right", () => {
const actions = rule(S + " .prefs-actions");
expect(actions).toMatch(/grid-column:\s*1/);
expect(actions).toMatch(/grid-row:\s*1/);
expect(actions).toMatch(/justify-self:\s*end/);
});
it("turns the category list into a strip that scrolls sideways without a scrollbar", () => {
const nav = rule(S + " .prefs-nav");
expect(nav).toMatch(/display:\s*flex/);
+1 -5
View File
@@ -30,7 +30,6 @@
[data-fold~="phone"] .flow .call[data-approval] .apv-ft .apv-more { display: block; order: 3; grid-column: 1 / -1; justify-self: start; min-height: 32px; padding: 0; border: 0; background: none; color: var(--link); font: var(--w-reg) var(--fs-meta)/1 var(--ui); cursor: pointer; }
.turntools .turn-more { display: none; }
.btn.stop .ic-pause { display: none; }
[data-fold~="phone"] .compose .composecaps { display: none; }
[data-fold~="phone"] .compose .turntools { flex-wrap: wrap; align-items: center; gap: 6px; row-gap: 8px; }
[data-fold~="phone"] .compose .turntools > .studio-attach:first-child { box-sizing: border-box; width: 32px; height: 32px; padding: 0; border: 1px solid var(--border); border-radius: 50%; background: transparent; color: var(--muted); }
@@ -48,11 +47,8 @@
[data-fold~="phone"] .compose .turntools[data-more] > :not(:first-child, .studio-model-group, .turn-more) { display: flex; }
[data-fold~="phone"] .compose .go { align-self: flex-start; gap: 6px; }
[data-fold~="phone"] .compose .go .btn.send, [data-fold~="phone"] .compose .go .btn.stop { box-sizing: border-box; width: 36px; height: 36px; min-width: 0; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center; }
[data-fold~="phone"] .compose .go .btn.send { background: var(--accent); color: var(--accent-fg); }
[data-fold~="phone"] .compose .go .btn.send:not(:disabled) { background: var(--accent); color: var(--accent-fg); }
[data-fold~="phone"] .compose .go .btn.stop { background: var(--overlay); color: var(--text); }
[data-fold~="phone"] .compose .go .btn > span:not(.ic) { display: none; }
[data-fold~="phone"] .compose .go .btn svg { width: 15px; height: 15px; stroke-width: 1.45; }
[data-fold~="phone"] .compose .btn.stop .ic-stop { display: none; }
[data-fold~="phone"] .compose .btn.stop .ic-pause { display: inline; }
[data-fold~="phone"] .compose .turntools > :first-child { order: 0; }
[data-fold~="phone"] .compose .turntools[data-more]::after { content: ""; order: 3; flex: 0 0 100%; height: 0; }
@@ -119,15 +119,13 @@ describe("the composer's tool row on a phone", () => {
expect(rule(row + " > .turn-more")).toMatch(/display:\s*inline-grid/);
});
it("makes send a 36px accent circle, and stop the same size with the pause mark", () => {
it("makes send a 36px accent circle, and stop the same size", () => {
const both = rule('[data-fold~="phone"] .compose .go .btn.send, [data-fold~="phone"] .compose .go .btn.stop');
expect(both).toMatch(/width:\s*36px/);
expect(both).toMatch(/height:\s*36px/);
expect(both).toMatch(/border-radius:\s*50%/);
expect(rule('\n[data-fold~="phone"] .compose .go .btn.send')).toMatch(/background:\s*var\(--accent\)/);
expect(rule('\n[data-fold~="phone"] .compose .go .btn.send:not(:disabled)')).toMatch(/background:\s*var\(--accent\)/);
expect(rule('\n[data-fold~="phone"] .compose .go .btn.stop')).toMatch(/background:\s*var\(--overlay\)/);
expect(rule('[data-fold~="phone"] .compose .btn.stop .ic-stop')).toMatch(/display:\s*none/);
expect(rule(".btn.stop .ic-pause")).toMatch(/display:\s*none/);
});
it("drops the keyboard hints, which a touch screen has no keys for", () => {
@@ -18,10 +18,9 @@ describe("the settings search results", () => {
expect(found).toMatch(/width:\s*auto/);
});
it("cover the whole column, leaving the header row's height empty and the close button above them", () => {
it("cover the whole column, leaving room above the results", () => {
expect(found).toMatch(/top:\s*0/);
expect(found).toMatch(/padding:\s*58px /);
expect(studio).toMatch(/\.prefs-hd \{ min-height: 58px/);
expect(rule(':root:not([data-fold~="phone"]) .prefs-actions')).toMatch(/z-index:\s*6/);
});
});
+43 -46
View File
@@ -1,3 +1,12 @@
.prefs.prefs-screen { inset: 0; display: flex; flex-direction: column; align-items: stretch; padding: 0; backdrop-filter: none; -webkit-backdrop-filter: none; }
.prefs-screen > .prefs-sheet { flex: 1; min-height: 0; width: 100%; height: auto; border: 0; border-radius: 0; box-shadow: none; }
.prefs-windowbar { flex: none; display: flex; align-items: center; justify-content: space-between; min-height: var(--chrome-h, 48px); padding: 0 12px; border-bottom: 1px solid var(--hair); background: var(--page); -webkit-app-region: drag; }
.prefs-back { display: inline-flex; align-items: center; gap: 8px; min-height: 32px; padding: 0 10px; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--muted); font: var(--w-med) var(--fs-ui)/1.4 var(--ui); cursor: pointer; -webkit-app-region: no-drag; }
.prefs-back:hover:not(:disabled) { background: var(--overlay); color: var(--text); }
.prefs-back:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.prefs-back:disabled { opacity: .5; cursor: default; }
.prefs-windowbar .winctl { margin-left: auto; }
[data-titlebar="app"][data-platform="darwin"] .prefs-windowbar { padding-left: var(--lights-w, 80px); }
.prefs-sheet { grid-template-columns: 240px minmax(0, 1fr); border-radius: var(--r-lg); background: var(--surface); }
.prefs-hd h1 { padding: 14px 18px 10px; background: var(--page); font: var(--w-semi) var(--fs-lead)/1.3 var(--ui); letter-spacing: 0; }
.prefs-sheet > .prefs-hd > .prefs-find {
@@ -10,30 +19,37 @@
.prefs-nav button { height: 30px; min-height: 30px; padding: 0 10px; gap: 9px; border-radius: var(--r-sm); color: var(--muted); font: var(--w-reg) var(--fs-ui) var(--ui); }
.prefs-nav button[aria-selected="true"] { background: var(--overlay); color: var(--text); font-weight: var(--w-reg); }
.prefs-nav button .nv { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint); }
.prefs-main { padding: 18px 24px; background: var(--surface); }
.prefs-pagetitle { margin: 0 0 12px; gap: 10px; max-width: none; align-items: baseline; }
.prefs-pagetitle h2 { font: var(--w-semi) var(--fs-title)/1.3 var(--ui); }
.prefs-pagetitle .desc { font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); color: var(--faint); }
.prefs-col { max-width: 880px; margin-inline: auto; }
.prefs-card { margin: 0 0 14px; padding: 12px 18px 4px; border: 1px solid var(--hair); border-radius: var(--r-lg); background: var(--raised); overflow: visible; }
.prefs-card > header { padding: 0; margin: 0 0 4px; }
.prefs-card > header h3 { margin: 0; font: var(--w-semi) var(--fs-ui)/1.4 var(--ui); }
.prefs-card > header p { display: none; }
.prefs-main { padding: 24px 24px 48px; background: var(--surface); }
.prefs-pagetitle { display: flex; flex-direction: column; margin: 0 0 16px; gap: 6px; max-width: none; align-items: flex-start; }
.prefs-pagetitle h2 { font: var(--w-semi) var(--fs-display)/1.3 var(--ui); }
.prefs-pagetitle .desc { display: block; font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); color: var(--muted); }
.prefs-pagetitle .count { white-space: nowrap; }
.prefs-col { width: 100%; max-width: none; margin: 0; min-width: 0; }
.prefs-card { box-sizing: border-box; width: 100%; margin: 0 0 16px; padding: 18px 20px 8px; border: 1px solid var(--hair); border-radius: var(--r-lg); background: var(--raised); overflow: visible; }
.prefs-card > header { padding: 0; margin: 0 0 12px; }
.prefs-card > header h3 { margin: 0; font: var(--w-semi) var(--fs-title)/1.4 var(--ui); }
.prefs-card > header p { display: block; margin: 4px 0 0; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
.prefs-card > .grp { margin: 0; padding: 10px 0; border: 0; border-top: 1px solid var(--hair); border-radius: 0; background: none; box-shadow: none; column-gap: 16px; }
.prefs-card > .grp .grp-hd h3 { font: var(--w-med) var(--fs-ui)/1.4 var(--ui); color: var(--text); }
.prefs-card > .grp .grp-hd :is(h3, h4) { font: var(--w-med) var(--fs-ui)/1.4 var(--ui); color: var(--text); }
.prefs-card > .grp .hint { margin: 2px 0 0; font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); color: var(--faint); max-width: none; }
.prefs-card > .grp .apply { margin: 2px 0 0; font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); color: var(--faint); }
.prefs-card .seg { padding: 2px; gap: 2px; border-radius: var(--r-sm); background: var(--overlay); }
.prefs-card .seg button { height: 26px; min-height: 26px; padding: 0 10px; border-radius: var(--r-xs); font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted); }
.prefs-card .seg button[aria-pressed="true"], .prefs-card .seg button[aria-checked="true"] { background: var(--raised); color: var(--text); font-weight: var(--w-med); box-shadow: 0 1px 2px rgb(0 0 0 / .07); }
.prefs-card select { width: 140px; height: 30px; padding: 0 10px; border-radius: var(--r-sm); border: 1px solid var(--border); background-color: var(--surface); font: var(--w-reg) var(--fs-ui) var(--ui); }
.prefs-card select { width: 140px; height: 30px; padding: 0 30px 0 10px; overflow: hidden; white-space: nowrap; border-radius: var(--r-sm); border: 1px solid var(--border); background-color: var(--surface); font: var(--w-reg) var(--fs-ui) var(--ui); }
.prefs-card select.peerfield { width: 100%; text-overflow: ellipsis; white-space: nowrap; }
.prefs-col .prefs-card > .grp .grp-hd h3 { font-size: var(--fs-ui); font-weight: var(--w-med); }
.prefs-card .addp select { width: 100%; height: 32px; }
.prefs-card .addp .unit-field { height: 32px; }
.prefs-card .addp .ml-group { container: model-fields / inline-size; font-size: var(--fs-meta); }
.prefs-card .addp .ml-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.prefs-card .addp .ml-row .me-name { grid-column: 1 / -1; }
.prefs-card .addp .mp-row { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.prefs-card .addp .ml-row > select { font-size: var(--fs-meta); }
.prefs-card .addp .mp-source-fields { grid-template-columns: repeat(auto-fit, minmax(min(100%, 22em), 1fr)); }
@container model-fields (width < 74em) { .prefs-card .addp .mp-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@container model-fields (width < 45em) { .prefs-card .addp .ml-row { grid-template-columns: minmax(0, 1fr); } }
.prefs-col .prefs-card > .grp .grp-hd :is(h3, h4) { font-size: var(--fs-ui); font-weight: var(--w-med); }
.prefs-main { container: prefsmain / inline-size; }
@container prefsmain (min-width: 860px) {
.prefs-col[data-cols="2"] { max-width: 1400px; column-count: 2; column-gap: 16px; }
.prefs-col[data-cols="2"] > .prefs-card { break-inside: avoid; }
}
.prefs-card > .srow, .grp[data-setting="global-shortcut"] .srow { display: flex; align-items: center; gap: 16px; padding: 10px 0; border-top: 1px solid var(--hair); }
.srow-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.srow-name { color: var(--text); font: var(--w-med) var(--fs-ui)/1.4 var(--ui); }
@@ -58,8 +74,7 @@
.prefs-col .prefs-card .seg button { font-weight: var(--w-reg); }
.prefs-col .prefs-card .seg button[aria-pressed="true"], .prefs-col .prefs-card .seg button[aria-checked="true"] { font-weight: var(--w-med); }
.prefs-main { grid-row: 1 / span 3; }
.prefs-actions { grid-row: 1; position: absolute; top: 10px; right: 14px; z-index: 6; padding: 0; }
.prefs-pagetitle { padding-right: 150px; }
.prefs-pagetitle { padding-right: 0; }
.prefs-hd h1 { padding: 14px 18px 10px; }
.prefs-sheet > .prefs-hd > .prefs-find { margin: 0 10px 8px; width: calc(100% - 20px); }
.prefs-nav { padding: 0 10px 14px; gap: 2px; }
@@ -103,7 +118,7 @@
.ext-add-menu small { color: var(--faint); font: var(--w-med) var(--fs-micro) var(--ui); }
.grp[data-setting="ext-runtime"] { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 10px 2px; border: 0; background: none; box-shadow: none; }
.grp[data-setting="ext-runtime"] .grp-hd { order: 2; margin: 0; }
.grp[data-setting="ext-runtime"] .grp-hd h3 { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.grp[data-setting="ext-runtime"] .grp-hd :is(h3, h4) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.grp[data-setting="ext-runtime"] .hint { order: 1; flex: 1; min-width: 240px; margin: 0; color: var(--faint); font: var(--w-med) var(--fs-meta)/1.6 var(--ui); }
.grp[data-setting="ext-runtime"] .grp-items { order: 3; flex-basis: 100%; }
.grp[data-setting="ext-runtime"] .apply { display: none; }
@@ -252,7 +267,6 @@
.rmthost-menu > button { min-height: 30px; padding: 4px 10px; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--text); font: var(--w-med) var(--fs-ui) var(--ui); text-align: start; white-space: nowrap; cursor: pointer; }
.rmthost-menu > button:hover:not(:disabled) { background: var(--overlay); }
.rmthost-menu > button[data-danger] { color: var(--err-ink); }
.grp-link { padding: 0; }
.grp-row {
display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 14px; box-sizing: border-box;
border: 0; border-radius: inherit; background: transparent; color: var(--text); text-align: start; cursor: pointer;
@@ -263,28 +277,19 @@
.grp-row-t small { overflow: hidden; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); white-space: nowrap; text-overflow: ellipsis; }
.grp-row > .now { flex: none; color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); }
.grp-row > .studio-icon { flex: none; width: 14px; height: 14px; color: var(--faint); }
.prefs-col[data-deep] :not(.deep-page, .deep-page *, :has(.deep-page)) { display: none !important; }
.prefs-col[data-deep] :is(.prefs-card, .grp):has(.deep-page) { padding: 0; border: 0; background: none; box-shadow: none; }
.deep-page { display: flex; flex-direction: column; gap: 12px; animation: m-panel var(--t-panel) var(--ease-out, ease-out); }
.deep-crumb { display: flex; align-items: center; gap: 6px; color: var(--faint); font: var(--w-med) var(--fs-meta) var(--ui); }
.deep-crumb button { padding: 0; border: 0; background: none; color: var(--muted); font: inherit; cursor: pointer; }
.deep-crumb button:hover { color: var(--text); text-decoration: underline; }
.deep-crumb b { color: var(--text); font-weight: var(--w-semi); }
@media (prefers-reduced-motion: reduce) { .deep-page { animation: none; } }
.prefs-sheet[data-solo] { grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr); }
.prefs-sheet[data-solo] .prefs-find { display: none; }
.prefs-sheet[data-solo]::before, .prefs-sheet[data-solo]::after { display: none; }
.prefs-sheet[data-solo] .prefs-hd h1 { border-bottom: 1px solid var(--hair); }
.prefs-sheet[data-solo] .prefs-hd h1 { grid-column: 1; grid-row: 1; padding: 16px 22px 8px; font-size: var(--fs-title); }
.prefs-sheet[data-solo] .prefs-actions { grid-column: 2; grid-row: 1; border-bottom: 1px solid var(--hair); background: none; }
.prefs-sheet[data-solo] .prefs-main { grid-column: 1 / -1; grid-row: 2; }
.prefs-sheet[data-solo] .prefs-col { max-width: none; }
.ext-md { display: grid; grid-template-columns: minmax(300px, 400px) minmax(0, 1fr); gap: 16px; align-items: start; }
.ext-md-list { min-width: 0; }
.ext-md-list .grp { margin: 0; padding: 0 0 8px; border: 0; border-radius: 0; background: none; box-shadow: none; }
.ext-md-list .grp-hd { margin: 10px 0 2px; padding: 0 10px; }
.ext-md-list .grp-hd h3 { color: var(--muted); font: var(--w-semi) var(--fs-meta) var(--ui); }
.ext-md-list .grp-hd :is(h3, h4) { color: var(--muted); font: var(--w-semi) var(--fs-meta) var(--ui); }
.ext-md-list .grp-hd .now { padding: 0; background: none; color: var(--faint); }
.ext-md-list .srv > :not(summary):not(.srv-hd) { display: none; }
.ext-md-list .srv > summary, .ext-md-list .srv > .srv-hd, .ext-md-list .skrow { cursor: pointer; border-radius: var(--r-md); }
@@ -363,9 +368,6 @@
.ulegend i[data-s="miss"], .ustack .utip i[data-s="miss"] { background: var(--chart-miss); }
.ustack .utip .r { display: flex; justify-content: space-between; gap: 12px; color: var(--muted); font: var(--w-med) var(--fs-micro) var(--ui); }
.ustack .utip .r b { color: var(--text); font-weight: var(--w-semi); }
.prefs { padding: 24px; }
.prefs-sheet { width: min(1360px, 100%); height: min(900px, 100%); }
.srow-ctl .unit-field input { width: 72px; }
.srow-val { color: var(--muted); font: var(--w-reg) var(--fs-meta) var(--ui); max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.srow-val > * { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.prefs-card > .grp[data-inline] { display: grid; grid-template-columns: minmax(0, 1fr) fit-content(320px); align-items: center; column-gap: 16px; row-gap: 2px; }
@@ -388,7 +390,8 @@
.prefs-icon:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.prefs-icon .studio-icon { width: 16px; height: 16px; }
.prefs-card .unit-field { align-items: center; gap: 6px; height: 28px; padding: 0 10px 0 2px; border: 1px solid var(--hair); border-radius: var(--r-sm); background: transparent; }
.prefs-card .unit-field input { width: 64px; text-align: right; font: var(--w-reg) var(--fs-meta) var(--mono); }
.prefs-card .unit-field input { flex: 1; width: 0; text-align: right; font: var(--w-reg) var(--fs-meta) var(--mono); }
.prefs-card :is(.srow-ctl, .threshold-control) .unit-field input { flex: none; width: 64px; }
.prefs-card .threshold-control { width: auto; justify-content: flex-start; }
.prefs-card .threshold-control .unit-field { flex: none; }
.prefs-card .unit-field > i { padding: 0; border: 0; color: var(--faint); font: var(--w-reg) var(--fs-meta) var(--ui); font-style: normal; }
@@ -406,13 +409,12 @@
.usage .urow-t .n { font: var(--w-semi) var(--fs-meta) var(--mono); }
.usage .urow-t .v { font: var(--w-reg) var(--fs-meta) var(--mono); }
.usage .utwo .ucard + .ucard .ucard-h h3 { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted); }
.deep-page .grp-hd h3 { font: var(--w-semi) var(--fs-title)/1.3 var(--ui); }
.memdetail .facts h4 { font: var(--w-med) var(--fs-micro) var(--ui); }
.memhead .btn { font: var(--w-reg) var(--fs-meta) var(--ui); }
.ext-bar .mkt-tabs button { font: var(--w-reg) var(--fs-meta) var(--ui); }
.ext-bar .mkt-tabs button[aria-selected="true"] { font-weight: var(--w-med); }
.ext-bar .ext-add-btn { font: var(--w-reg) var(--fs-meta) var(--ui); }
.ext-md-list .grp-hd h3 { font: var(--w-med) var(--fs-meta) var(--ui); }
.ext-md-list .grp-hd :is(h3, h4) { font: var(--w-med) var(--fs-meta) var(--ui); }
.ext-md-list .grp-hd .now { font: var(--w-reg) var(--fs-meta) var(--ui); }
.ext-md-list .srv > summary b, .ext-md-list .srv > .srv-hd b, .ext-md-list .skrow .nm { font-weight: var(--w-med); }
.ext-detail-hd > b { font: var(--w-semi) var(--fs-title) var(--ui); }
@@ -436,24 +438,20 @@
.changed-keys { display: flex; flex-direction: column; gap: 4px; margin: 4px 0 0; padding: 8px 0 2px; border-top: 1px solid var(--hair); list-style: none; }
.changed-keys li { color: var(--muted); font: var(--w-reg) var(--fs-meta) var(--mono); }
.prefs-card > .grp { grid-template-columns: minmax(0, 1fr) fit-content(320px); }
.prefs-main > :is(.prefs-pagetitle, .page-index) { max-width: 880px; margin-inline: auto; }
@container prefsmain (min-width: 860px) {
.prefs-main:has(.prefs-col[data-cols="2"]) > :is(.prefs-pagetitle, .page-index) { max-width: 1400px; }
}
.prefs-main > :is(.prefs-pagetitle, .page-index) { max-width: none; margin-inline: 0; }
.prefs-main > .page-index { position: sticky; top: -24px; z-index: 2; padding-block: 12px; background: var(--surface); border-bottom: 1px solid var(--hair); }
.apply[data-usual] { display: none; }
.prefs-card > .grp > .apply { display: none; }
.scope-tag { display: inline-grid; place-items: center; height: 18px; padding: 0 7px; border-radius: var(--r-pill); background: var(--overlay); color: var(--muted); font: var(--w-reg) var(--fs-micro)/1 var(--ui); white-space: nowrap; }
.prefs-card > .grp { padding-block: 6px; min-height: 36px; box-sizing: border-box; }
.prefs-card > .srow { padding-block: 6px; min-height: 36px; box-sizing: border-box; }
.prefs-card > .grp > .hint, .srow-desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.prefs-card > .grp > .hint, .srow-desc { overflow-wrap: anywhere; white-space: normal; }
.prefs-card > .grp > .hint { margin: 0; line-height: 1.4; }
.prefs-card > .grp > .grp-hd { display: flex; align-items: center; gap: 8px; }
.prefs-pagetitle { flex-wrap: wrap; }
.prefs-pagetitle > .usual { flex-basis: 100%; margin: 0; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); }
.srow-name { display: flex; align-items: center; gap: 8px; }
.prefs-pagetitle > .usual { margin: 0; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); }
.srow-name { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; }
.srow-meta > .apply:not([data-usual]) { display: none; }
.prefs-strip { position: fixed; inset: 0 0 auto; height: var(--chrome-h, 39px); z-index: 50; background: color-mix(in srgb, var(--page) 40%, transparent); -webkit-backdrop-filter: blur(14px) saturate(.92); backdrop-filter: blur(14px) saturate(.92); pointer-events: none; }
:root[data-fold~="scene"] .prefs-strip { display: none; }
.usage .ucharts-spend { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.usage .utable th { font: var(--w-med) var(--fs-micro) var(--ui); }
.usage .utable th, .usage .utable td { padding-block: 9px; }
@@ -479,4 +477,3 @@
.page-index .pi-label { margin-right: 2px; color: var(--faint); font: var(--w-reg) var(--fs-meta) var(--ui); }
.page-index .spill { height: 26px; }
:root:not([data-fold~="phone"]) .prefs-found { top: 0; left: 240px; right: 0; bottom: 0; width: auto; max-height: none; padding: 58px 22px 14px; border: 0; border-left: 1px solid var(--hair); border-radius: 0 var(--r-lg) var(--r-lg) 0; background: var(--surface); box-shadow: none; }
:root:not([data-fold~="phone"]) .prefs-actions { position: relative; z-index: 6; }
+13 -5
View File
@@ -39,17 +39,25 @@
.railfoot { flex: none; display: flex; flex-direction: column; gap: 0; padding: 0 10px 10px; border: 0; background: transparent; }
.railfoot::before { content: ""; display: block; height: 1px; margin: 6px 4px; background: var(--hair); }
.studio-quicknav { display: flex; align-items: center; gap: 2px; margin: 0; padding: 0 2px; }
.studio-quicknav > .qspace { flex: 1; }
.studio-tools-row { display: flex; align-items: center; min-height: 32px; gap: 2px; }
.studio-tools-row > button { display: inline-flex; align-items: center; gap: 10px; height: 32px; padding: 0 10px; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.4 var(--ui); cursor: pointer; }
.studio-tools-row > .studio-settings { flex: 1; min-width: 0; text-align: left; }
.studio-tools-row > .studio-tools-toggle { flex: none; width: 32px; padding: 0; justify-content: center; }
.studio-tools-row > button:hover, .studio-tools-toggle[aria-expanded="true"] { background: var(--overlay); color: var(--text); }
.studio-tools-row > button:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.studio-tools-row .upd-dot { margin-left: auto; }
.studio-quicknav { position: fixed; z-index: 80; display: flex; flex-direction: column; gap: 2px; box-sizing: border-box; width: 260px; max-width: calc(100vw - 24px); max-height: calc(100vh - 24px); overflow-y: auto; margin: 0; padding: 6px; border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--float); box-shadow: var(--shadow-float); }
.studio-quicknav[hidden] { display: none; }
.studio-quicknav > button {
position: relative; width: 28px; height: 28px; padding: 0; flex: none; box-sizing: border-box;
display: inline-grid; place-items: center; border: 0; border-radius: var(--r-sm);
background: transparent; color: var(--muted); cursor: pointer;
position: relative; width: 100%; min-height: 36px; padding: 8px 10px; flex: none; box-sizing: border-box;
display: flex; align-items: center; gap: 10px; border: 0; border-radius: var(--r-sm);
background: transparent; color: var(--text); font: var(--w-reg) var(--fs-ui)/1.4 var(--ui); text-align: left; cursor: pointer;
transition: background var(--m-line), color var(--m-line), transform var(--t-press);
}
.studio-quicknav > button:hover { background: var(--overlay); color: var(--text); }
.studio-quicknav > button:active { transform: scale(.94); }
.studio-quicknav > button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.studio-quicknav > button > :is(small, kbd) { margin-left: auto; color: var(--faint); font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); }
.studio-quicknav > button :is(svg, .studio-icon) { width: 16px; height: 16px; stroke-width: 1.45; }
.studio-quicknav :is(.qdot, .upd-dot) { position: absolute; top: 3px; right: 3px; width: 7px; height: 7px; box-sizing: border-box; border-radius: 50%; border: 1.5px solid var(--page); box-shadow: none; }
.studio-quicknav .qdot[data-tone="ok"] { background: var(--ok); }
+1 -1
View File
@@ -15,7 +15,7 @@
height: 26px; min-height: 26px; padding: 0 6px; margin: 0 -6px; gap: 7px; box-sizing: border-box; border-radius: var(--r-xs);
background: transparent; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1 var(--ui);
}
.activity-group[open] > summary { background: transparent; border-bottom: 0; }
.activity-group[open] > summary { background: var(--surface); border-bottom: 0; }
.activity-group > summary:hover { background: var(--overlay); color: var(--text); }
.activity-group > summary .studio-icon { width: 12px; height: 12px; color: var(--faint); }
.activity-group .activity-title { color: inherit; font: inherit; }
@@ -17,4 +17,8 @@ describe("the turn follows the boards", () => {
expect(rule(".rc[data-worked]")).toMatch(/justify-content:\s*flex-end/);
expect(rule(".rc[data-worked] .rc-tick")).toMatch(/width:\s*14px/);
});
it("paints a stuck, open activity heading in the conversation's own colour so rows scroll under it unseen", () => {
expect(rule(".activity-group[open] > summary")).toMatch(/background:\s*var\(--surface\)/);
});
});
+2 -2
View File
@@ -2,7 +2,7 @@ import type { Messages } from "./types";
export const LOOK: Messages = {
"look.a_few_of_name_s": { zh: "{name} 有几项没生效", en: "A few of {name}'s entries did not take" },
"look.advanced_look": { zh: "高级外观", en: "Advanced appearance" },
"look.at_the_current_window_size": { zh: "当前窗口尺寸下,此壁纸只有上下会被裁剪,左右正好填满,因此横向焦点无法调整。", en: "At the current window size this wallpaper is cropped only top and bottom and fills the width exactly, so the horizontal focus cannot be moved." },
"look.at_the_current_window_size_2": { zh: "当前窗口尺寸下,此壁纸只有左右会被裁剪,上下正好填满,因此纵向焦点无法调整。", en: "At the current window size this wallpaper is cropped only left and right and fills the height exactly, so the vertical focus cannot be moved." },
"look.back_to_the_default_typeface": { zh: "恢复默认字体", en: "Back to the default typeface" },
@@ -41,7 +41,7 @@ export const LOOK: Messages = {
"look.if_the_window_keeps_your": { zh: "窗口一直占用显卡、或界面花屏时,先试「视觉效果:节能」,不行再改为「仅软件渲染」:改用 CPU 绘制,显卡不再参与。设置保存后,需退出并重新打开 Studio 才会生效。", en: "If the window keeps your GPU busy or draws garbage, try Visual effects: Reduced first; if that is not enough, choose Software only: the CPU draws the window and the GPU stays out of it. The choice is saved now and applies after you quit and reopen Studio." },
"look.illustrated_and_background_themes_you": { zh: "插画与背景主题,可随时切回基础主题", en: "Illustrated and background themes; you can return to a foundation theme at any time" },
"look.import_theme": { zh: "导入主题…", en: "Import theme…" },
"look.import_themes_custom_colours": { zh: "导入主题、自定义颜色", en: "Import themes, custom colours" },
"look.import_wallpaper_engine": { zh: "导入 Wallpaper Engine…", en: "Import Wallpaper Engine…" },
"look.imported_name": { zh: "已导入「{name}」。", en: "Imported “{name}”." },
"look.in_effect_now": { zh: "立即生效", en: "In effect now" },
+2 -4
View File
@@ -253,11 +253,9 @@ export const MODELS: Messages = {
"models.with_no_level_picked_the": { zh: "未选档位时按继承处理。", en: "With no level picked, the model inherits." },
"models.you_can_write_proxy_password": { zh: "可填写 ${PROXY_PASSWORD}", en: "You can write ${PROXY_PASSWORD}" },
"models.check_while_the_list_opens": { zh: "打开模型列表时顺便检查连接和余额,不发送生成请求", en: "Check connections and balances when the model list opens; no generation request is sent" },
"models.services_connected_n": { zh: "已接入 {n} 个服务;地址、密钥、模型列表在这里改", en: "{n} services connected; change addresses, keys and model lists here" },
"models.manage_services": { zh: "管理服务", en: "Manage services" },
"models.services": { zh: "服务", en: "Services" },
"models.uses_and_context": { zh: "用途与上下文", en: "Uses and context" },
"models.which_model_for_each_use": { zh: "对话、规划、子代理、看图分别用哪个模型", en: "Which model chat, planning, subagents and image reading each use" },
"models.set_by_use": { zh: "按用途设置", en: "Set by use" },
"models.compacts_from_this_share": { zh: "上下文到多少开始压缩;默认窗口的 {p}%", en: "How full the context gets before it compacts; {p}% of the window by default" },
};
+3 -5
View File
@@ -123,15 +123,13 @@ export const SAFETY: Messages = {
"safety.writing_files_every_write_tool": { zh: "写文件(全部写入工具)", en: "writing files (every write tool)" },
"safety.ask_before_writing_or_running": { zh: "写文件、跑命令前要不要先问", en: "Whether to ask before writing files or running commands" },
"safety.allow_and_deny_by_command": { zh: "按命令、路径写死的允许和拒绝;拒绝规则永远优先", en: "Allow and deny by command or path; a deny rule always wins" },
"safety.edit_rules_n": { zh: "编辑规则({n} 条)", en: "Edit rules ({n})" },
"safety.ask_before_copied_text": { zh: "照抄网页或工具结果里的文字去调用工具前先问你", en: "Ask before a call that copies text from a web page or tool result" },
"safety.which_tools_the_model_sees": { zh: "模型能看到哪些工具", en: "Which tools the model can see" },
"safety.view_and_adjust_n": { zh: "查看与调整({n})", en: "View and adjust ({n})" },
"safety.commands_write_only_here": { zh: "命令只能写工作区、临时目录和工具缓存", en: "Commands can only write to the workspace, temp folders and tool caches" },
"safety.which_program_runs_a_command": { zh: "跑命令用哪个程序", en: "Which program runs a command" },
"safety.when_two_sessions_edit_one": { zh: "两个会话同时改一个项目时怎么处理", en: "What happens when two sessions edit one project" },
"safety.web": { zh: "联网", en: "Web" },
"safety.search_and_fetch_when_needed": { zh: "需要时搜索网页、抓取页面内容", en: "Search the web and fetch pages when needed" },
"safety.details": { zh: "详细设置", en: "Details" },
"safety.what_outside_text_covers": { zh: "调用逐字复制了网页或工具结果里的文字时,Auto 和 YOLO 也先问你。只认用户配置;已授权的 MCP 服务器、改写过或编码过的文字不在其内。无人可问时(无头 YOLO),这类调用会被拒绝而不是执行。修改会重建运行时,任务运行期间无法变更。默认关闭。", en: "When a call copies text word for word from a web page or a tool result, Auto and YOLO ask you first. Only your user config counts; servers you have authorised, and text that was reworded or encoded, are not covered. When nobody can be asked (headless YOLO) such a call is refused instead of run. Changing it rebuilds the runtime and cannot be done while a task runs. Off by default." },
};
+20 -26
View File
@@ -1,6 +1,7 @@
import type { Messages } from "./types";
export const SETTINGS: Messages = {
"settings.back_to_workspace": { zh: "返回工作区", en: "Back to workspace" },
"settings.a_system_notification_when_a": { zh: "一轮结束、等你处理时,系统通知提醒你", en: "A system notification when a turn ends or something waits for you" },
"settings.account": { zh: "账号", en: "Account" },
"settings.account_and_about": { zh: "账号与关于", en: "Account and About" },
@@ -38,7 +39,7 @@ export const SETTINGS: Messages = {
"settings.data": { zh: "数据", en: "Data" },
"settings.default_0_nothing_is_folded": { zh: "默认 0:不自动收起,打开的会话都保留。设成一个数字后,打开的会话超过它时,会收起最久没动的空闲会话;对话仍在列表里,随时可以重新打开。保留得越多,占用的内存越多。", en: "Default 0: nothing is folded away and every open session stays. Set a number and, once more sessions than that are open, the idle one untouched longest is folded away; its conversation stays in the list and can be opened again. The more you keep, the more memory it uses." },
"settings.detailed": { zh: "详细", en: "Detailed" },
"settings.details": { zh: "细项", en: "Details" },
"settings.door_does_not_offer_model": { zh: "{door} 不提供 {model},请在下方选择该来源支持的模型。", en: "{door} does not offer {model}. Choose one it does provide below." },
"settings.drafts_a_plan_read_only": { zh: "仅生成计划,不写入", en: "Drafts a plan, read-only" },
"settings.empty_is_automatic_1_to": { zh: "留空表示自动;可填 1 到 {max}", en: "Empty is automatic; 1 to {max}" },
@@ -181,7 +182,7 @@ export const SETTINGS: Messages = {
"settings.tool_approvals": { zh: "工具批准", en: "Tool approvals" },
"settings.type_this_code_into_the": { zh: "在浏览器里打开的页面输入这串代码。没自动打开就手动访问 {uri}", en: "Type this code into the page your browser opened. If none opened, go to {uri}" },
"settings.use_send_now_in_the": { zh: "可在待发送队列中选择“立即发送”来停止当前回复并开始新一轮。暂停、编辑和取回仍然可用。", en: "Use Send now in the queue to stop the current response and start a new turn. Hold, edit, and take back remain available." },
"settings.where_you_are": { zh: "当前位置", en: "Where you are" },
"settings.changes_apply_from_the_next_turn": { zh: "启用、停用、安装或更新后,从下一轮开始生效;正在跑的这一轮不受影响。改了扩展自己的代码,用「重载运行时」重新读取。", en: "Enabling, disabling, installing or updating takes effect from the next turn; the one running is unaffected. After editing an extension's own code, use Reload runtime to read it again." },
"settings.used_for_posting_to_the": { zh: "用于社区发帖与崩溃问题跟进。与模型 API key 无关 —— 登录不会上传你的对话、代码或密钥。", en: "Used for posting to the community and following up crashes. Unrelated to your model API keys — signing in uploads none of your conversations, code or secrets." },
"settings.warns_when_the_drive_holding": { zh: "项目或会话所在的磁盘剩余空间很少时提醒(不足 5 GB 或不足 3%),同一块盘长时间不重复提醒;只提醒,不会清理任何文件", en: "Warns when the drive holding your project or sessions runs short (under 5 GB or 3%), and does not repeat for the same drive for hours. It only warns; it never deletes anything." },
@@ -208,41 +209,40 @@ export const SETTINGS: Messages = {
"settings.token_unit": { zh: "令牌", en: "tokens" },
"settings.edit_persona": { zh: "编辑人设", en: "Edit persona" },
"settings.persona_hint": { zh: "写在每个会话开头的身份说明", en: "The identity note written at the start of every session" },
"settings.presets_hint": { zh: "工具、模型、提示词的组合;新任务默认用哪个", en: "A bundle of tools, model and prompt; which one new tasks start with" },
"settings.sessions_send_each_other": { zh: "让一个会话给另一个会话发消息(实验)", en: "Let one session send another a message (experimental)" },
"settings.persona_and_presets": { zh: "人设与预设", en: "Persona and presets" },
"settings.manage_presets": { zh: "管理预设", en: "Manage presets" },
"settings.which_currency_costs_show": { zh: "费用用什么币种显示", en: "Which currency costs are shown in" },
"settings.days_before_archiving": { zh: "多少天没动的会话自动归档", en: "How many idle days before a conversation is archived" },
"settings.migrated_sessions_in_the_sidebar": { zh: "旧版迁移过来的会话是否出现在侧栏", en: "Whether sessions migrated from 1.x show in the sidebar" },
"settings.where_session_records_go": { zh: "会话记录写到哪里", en: "Where session records are written" },
"settings.open_memory": { zh: "打开记忆", en: "Open memory" },
"settings.what_it_remembers": { zh: "它记住的东西:星系、遗忘、整理", en: "What it remembers: constellations, forgetting, tidying" },
"settings.view_usage": { zh: "查看用量", en: "View usage" },
"settings.usage_hint": { zh: "输入、输出、费用,按天和按模型看", en: "Input, output and cost, by day and by model" },
"settings.manage_storage": { zh: "管理", en: "Manage" },
"settings.storage_hint": { zh: "会话、索引、缓存占了多少空间", en: "How much space sessions, indexes and caches take" },
"settings.export_and_import": { zh: "导出与导入…", en: "Export and import…" },
"settings.config_in_one_file": { zh: "把模型、扩展、记忆等配置加密导出成一个文件", en: "Export models, extensions and memory to one encrypted file" },
"settings.back_up_to_the_cloud": { zh: "登录后把配置备份到云端", en: "Back up your configuration to the cloud once signed in" },
"settings.size_of_requests_sent": { zh: "发给模型服务商的请求有多大", en: "How large the requests sent to model providers are" },
"settings.other_machines": { zh: "其他机器", en: "Other machines" },
"settings.network_hint": { zh: "代理和网络诊断", en: "Proxy and network diagnostics" },
"settings.remote_hint": { zh: "连接其他机器上的 Reasonix", en: "Connect to Reasonix on other machines" },
"settings.remote_machines": { zh: "远程机器", en: "Remote machines" },
"settings.phone_hint": { zh: "用手机访问这台电脑上的会话,含通知设置", en: "Reach this computer's sessions from a phone, with notification settings" },
"settings.set_up_phone_access": { zh: "设置手机访问", en: "Set up phone access" },
"settings.account_hint": { zh: "社区发帖、崩溃跟进时用;与模型密钥无关", en: "Used for community posts and crash follow-up; unrelated to model keys" },
"settings.sign_in": { zh: "登录", en: "Sign in" },
"settings.account_info": { zh: "账号信息", en: "Account" },
"settings.version_and_diagnostics": { zh: "版本与诊断", en: "Version and diagnostics" },
"settings.versions_hint": { zh: "当前版本、可用更新、回退", en: "Current version, available updates, rolling back" },
"settings.versions_and_updates": { zh: "版本与更新", en: "Versions and updates" },
"settings.logs_hint": { zh: "出问题时附上的日志位置", en: "Where the logs to attach when something goes wrong are" },
"settings.trace_hint": { zh: "把执行轨迹导出给开发者排查", en: "Export execution traces for a developer to look into" },
"settings.about_note": { zh: "社区与贡献者、赞助在这里展示,不是开关。", en: "The community, contributors and sponsorship are shown here; they are not switches." },
"settings.look_at": { zh: "查看", en: "View" },
"settings.set_up_trace": { zh: "导出轨迹", en: "Export traces" },
"settings.about": { zh: "关于", en: "About" },
"settings.preview_office_in_workbench": { zh: "在工作台里预览 Word、Excel、PPT", en: "Preview Word, Excel and PowerPoint in the workbench" },
"settings.divider_before_new_answer": { zh: "回到会话时,在新回答前画一条分隔线", en: "A divider before the new answer when you return to a session" },
@@ -251,28 +251,22 @@ export const SETTINGS: Messages = {
"settings.how_long_before_a_page_sleeps": { zh: "网页在后台闲置多久后休眠", en: "How long a background page idles before it sleeps" },
"settings.hint_text_in_the_composer": { zh: "输入框里的提示文字", en: "The hint text inside the composer" },
"settings.buttons_shown_on_row_hover": { zh: "会话行悬停时显示的按钮", en: "Buttons shown when you hover a session row" },
"settings.which_entries_the_left_rail_shows": { zh: "左侧导航显示哪些入口、什么顺序", en: "Which entries the left rail shows, and in what order" },
"settings.which_entries_the_left_rail_shows": { zh: "选择图标栏与侧栏工具卡片显示的入口和顺序", en: "Choose the entries and order for the icon rail and sidebar tools card" },
"settings.finished_sessions_stay_in_running": { zh: "会话结束后在进行中列表里再留多久", en: "How long a finished session stays in the running list" },
"settings.theme_card": { zh: "主题", en: "Theme" },
"settings.text_card": { zh: "文字", en: "Text" },
"settings.accent_and_overall_tone": { zh: "强调色和整体色调", en: "Accent colour and overall tone" },
"settings.chat_background_image_or_video": { zh: "对话背景图片或视频", en: "The conversation's background image or video" },
"settings.choose_wallpaper": { zh: "选择壁纸", en: "Choose wallpaper" },
"settings.frosted_glass_glow_effects": { zh: "毛玻璃、光晕等视觉效果", en: "Frosted glass, glow and other effects" },
"settings.interface_and_code_typeface": { zh: "界面和代码字体", en: "Interface and code typefaces" },
"settings.change": { zh: "更换", en: "Change" },
"settings.overall_text_size": { zh: "整体文字大小", en: "Overall text size" },
"settings.adjust": { zh: "调整", en: "Adjust" },
"settings.weight_of_text": { zh: "文字粗细", en: "Weight of text" },
"settings.contrast_between_text_and_background": { zh: "文字和底色的对比", en: "Contrast between text and background" },
"settings.view_and_edit": { zh: "查看与修改", en: "View and edit" },
"settings.all_shortcuts_incl_global_and_esc": { zh: "所有快捷键,含全局显示 / 隐藏、Esc 停止", en: "All shortcuts, including global show / hide and Esc to stop" },
"settings.adjust_navigation": { zh: "调整导航", en: "Adjust navigation" },
"settings.remember_window_position_and_size": { zh: "记住窗口位置和大小、标题栏样式", en: "Remember window position and size, and the title bar style" },
"settings.process_card": { zh: "过程", en: "Process" },
"settings.files_and_previews_card": { zh: "文件与预览", en: "Files and previews" },
"settings.notices_card": { zh: "提示", en: "Notices" },
"settings.older_turns_fold_to_a_line": { zh: "较早的对话折叠成一行", en: "Earlier turns fold into one line" },
"settings.generated_files_as_clickable_cards": { zh: "生成的文件显示成可以点开的卡片", en: "Generated files appear as cards you can open" },
"settings.tabs_and_navigation_card": { zh: "标签与导航", en: "Tabs and navigation" },
"settings.while_running_card": { zh: "运行中", en: "While running" },
+1 -1
View File
@@ -111,7 +111,7 @@ export const USAGE: Messages = {
"usage.spend": { zh: "支出", en: "Spend" },
"usage.spend_2": { zh: "花费", en: "Spend" },
"usage.start_date": { zh: "开始日期", en: "Start date" },
"usage.subscription_plan_estimated_at_the": { zh: "订阅套餐(按价格折算的估算)", en: "Subscription plan (estimated at the prices above)" },
"usage.subscription_plan_estimated_at_the": { zh: "订阅套餐(估算)", en: "Subscription estimate" },
"usage.subscription_use_priced_at_api": { zh: "按 API 价格估算的订阅用量,不是实际扣费", en: "Subscription use priced at API rates; not a charge" },
"usage.subscription_use_priced_at_api_2": { zh: "订阅用量按 API 价格估算,不是实际扣费,也不计入上面的实付", en: "Subscription use priced at API rates. It is not a charge and is not part of the paid figure above." },
"usage.the_backend_system_one_asks": { zh: "system_one 询问的后端", en: "The backend system_one asks" },
@@ -170,8 +170,6 @@ export const WORKBENCH: Messages = {
"workbench.will_send": { zh: "将发送", en: "Will send" },
"workbench.you_do_not_have_permission": { zh: "没有权限读取这个文件。", en: "You do not have permission to read this file." },
"workbench.changes_history_and_files": { zh: "改动、提交历史和文件", en: "Changes, history and files" },
"workbench.find_a_file": { zh: "文件…", en: "Find a file…" },
"workbench.find_a_file_by_name": { zh: "按名字找项目里的文件", en: "Find a file in the project by name" },
"workbench.new_tab_label": { zh: "新标签", en: "New tab" },
"workbench.line_n": { zh: "第 {n} 行", en: "Line {n}" },
"workbench.unsaved": { zh: "未保存", en: "Unsaved" },
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fromHistory, initialState, reduce, type Item, type SessionEvent } from "./session";
import { thoughtStartedAt } from "./say";
beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(1_700_000_000_000); });
afterEach(() => vi.useRealTimers());
const ev = (kind: string, fields: Record<string, unknown> = {}) => ({ kind, ...fields }) as SessionEvent;
const tool = { id: "call-1", name: "bash", issuer: "model" };
const says = (items: Item[]) => items.filter((item): item is Extract<Item, { t: "say" }> => item.t === "say");
const thinking = () => [ev("turn_started"), ev("reasoning", { text: "Inspect first. " })].reduce(reduce, initialState);
describe("one assistant response across interleaved events", () => {
it("stops thinking when tool arguments start, then settles the original card with the message frame", () => {
let state = thinking();
const id = says(state.items)[0].id;
vi.advanceTimersByTime(1200);
state = reduce(state, ev("tool_dispatch", { tool: { ...tool, partial: true } }));
expect(says(state.items)[0]).toMatchObject({ id, done: false, thoughtMs: 1200 });
expect(thoughtStartedAt(id)).toBeUndefined();
vi.advanceTimersByTime(30_000);
state = reduce(state, ev("text", { text: "I will inspect the files." }));
state = reduce(state, ev("message", { reasoning: "Inspect first.", text: "I will inspect the files.", thoughtMs: 1100 }));
expect(says(state.items)).toHaveLength(1);
expect(says(state.items)[0]).toMatchObject({ id, done: true, thoughtMs: 1100, reasoning: "Inspect first." });
state = reduce(state, ev("tool_dispatch", { tool }));
state = reduce(state, ev("tool_result", { tool: { ...tool, output: "files" } }));
state = reduce(state, ev("reasoning", { text: "Now edit." }));
expect(says(state.items)).toHaveLength(2);
expect(says(state.items)[1]).toMatchObject({ done: false, reasoning: "Now edit." });
});
it("keeps the active response across notices, pending input and child tool events", () => {
let state = thinking();
state = reduce(state, ev("notice", { text: "A background task finished." }));
state = reduce(state, ev("__user", { text: "Also check styles", pending: true }));
state = reduce(state, ev("tool_dispatch", { tool: { ...tool, parentId: "worker" } }));
vi.advanceTimersByTime(800);
state = reduce(state, ev("reasoning", { text: "Then compare." }));
expect(says(state.items)).toHaveLength(1);
expect(says(state.items)[0]).toMatchObject({ reasoning: "Inspect first. Then compare.", done: false });
expect(says(state.items)[0].thoughtMs).toBeUndefined();
state = reduce(state, ev("text", { text: "Compared." }));
expect(says(state.items)[0].thoughtMs).toBe(800);
});
it("seals the response at full dispatch even when its message frame was missed", () => {
let state = thinking();
vi.advanceTimersByTime(900);
state = reduce(state, ev("tool_dispatch", { tool }));
expect(says(state.items)[0]).toMatchObject({ done: true, thoughtMs: 900 });
state = reduce(state, ev("tool_result", { tool }));
state = reduce(state, ev("reasoning", { text: "The next round." }));
expect(says(state.items)).toHaveLength(2);
});
it("keeps an explicit zero measurement from the host over a local estimate", () => {
let state = thinking();
vi.advanceTimersByTime(900);
state = reduce(state, ev("message", { reasoning: "Inspect first.", thoughtMs: 0 }));
expect(says(state.items)[0].thoughtMs).toBe(0);
});
});
describe("canonical idle state after an interrupted event stream", () => {
it("seals the thought once without inventing a turn outcome", () => {
let state = thinking();
vi.advanceTimersByTime(2400);
state = reduce(state, ev("__running", { running: false }));
expect(says(state.items)[0]).toMatchObject({ done: true, thoughtMs: 2400 });
expect(state.terminal).toBeNull();
const settled = state;
vi.advanceTimersByTime(1_800_000);
state = reduce(state, ev("__running", { running: false }));
expect(state).toBe(settled);
expect(says(state.items)[0].thoughtMs).toBe(2400);
});
it("also seals a stream whose turn-start event was missed", () => {
let state = reduce(initialState, ev("reasoning", { text: "Already streaming." }));
expect(state.running).toBe(false);
state = reduce(state, ev("__running", { running: false }));
expect(says(state.items)[0].done).toBe(true);
});
it("does not close an ongoing response or start a clock on history", () => {
const state = thinking();
expect(reduce(state, ev("__running", { running: true }))).toBe(state);
const history = fromHistory([{ role: "assistant", content: "Done.", reasoning: "Checked.", thoughtMs: 1600 }]);
const restored = reduce(state, { kind: "__restore", ...history });
const idle = reduce(restored, ev("__running", { running: false }));
expect(says(idle.items)).toHaveLength(1);
expect(says(idle.items)[0]).toMatchObject({ done: true, thoughtMs: 1600 });
expect(thoughtStartedAt(says(idle.items)[0].id)).toBeUndefined();
});
});
+25 -17
View File
@@ -12,23 +12,37 @@ export function thoughtStartedAt(id: string): number | undefined {
return thoughtSince.get(id);
}
const isWaitingLine = (item: Item) => item.t === "user" && !!item.pending;
function openSayIndex(items: Item[]): number {
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i];
if (item.t === "say") return item.done ? -1 : i;
if (item.t === "user" && !item.pending) return -1;
}
return -1;
}
// A partial tool dispatch ends thinking but its response still owes a message frame.
export function stopThought(items: Item[]): Item[] {
const at = openSayIndex(items);
if (at < 0) return items;
const item = items[at] as Extract<Item, { t: "say" }>;
if (!item.reasoning || item.thoughtMs !== undefined) return items;
const thoughtMs = Math.max(0, Date.now() - (thoughtSince.get(item.id) ?? Date.now()));
thoughtSince.delete(item.id);
const next = items.slice();
next[at] = { ...item, thoughtMs };
return next;
}
export function appendText(items: Item[], text: string, field: "text" | "reasoning", source?: string, model?: string): Item[] {
// A line typed while the model is still writing waits in the transcript after
// the open message without being part of it, so the message continues past it.
let at = items.length - 1;
while (at >= 0 && isWaitingLine(items[at])) at--;
const at = openSayIndex(items);
if (field === "text") items = stopThought(items);
const last = items[at];
if (last && last.t === "say" && !last.done) {
const next = items.slice();
// Thinking runs until the first answer token, so the clock is the gap
// between the two streams, not the length of either.
const stop = field === "text" && last.thoughtMs === undefined && last.reasoning;
next[at] = {
...last,
[field]: (last[field] ?? "") + text,
...(stop ? { thoughtMs: Date.now() - (thoughtSince.get(last.id) ?? Date.now()) } : null),
};
return next;
}
@@ -69,16 +83,10 @@ export function sealSay(items: Item[], all = false, cut?: ThoughtCut): Item[] {
// agree. A stream that lost a chunk is therefore repaired here, never
// preserved; an empty field is not content and overwrites nothing.
export function foldMessage(items: Item[], ev: WireEvent): Item[] {
let at = -1;
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].t === "say" && !(items[i] as Extract<Item, { t: "say" }>).done) {
at = i;
break;
}
}
const at = openSayIndex(items);
// The kernel's measure wins over this window's: it is the one the transcript
// keeps, so a reopened turn says the same thing.
const measured = ev.thoughtMs || undefined;
const measured = ev.thoughtMs;
if (at < 0) {
if (!ev.text && !ev.reasoning) return items;
return [...items, { t: "say", id: nextId(), text: ev.text ?? "", reasoning: ev.reasoning, done: true, source: ev.source, thoughtMs: measured }];
+6 -3
View File
@@ -22,7 +22,7 @@ export { currentStep, stepDone, stepLabel };
import { askAnnounced, askEngaged, askFromCall, promptOpen, prompted, sealByReceipt } from "./prompts";
import { stampPlan } from "./plan_card";
import { nameTurnStart, othersSteer } from "./turn_start";
import { appendText, foldMessage, sealSay } from "./say";
import { appendText, foldMessage, sealSay, stopThought } from "./say";
import { nextId } from "./ids";
import { worked } from "./worked";
import { chipLabel, IDLE, RUNNING } from "./chip";
@@ -349,7 +349,10 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
// /status's own answer, because the record cannot end a turn: the kernel
// commits a text-only round before it decides whether the turn continues,
// so only the kernel saying idle can close one. Equal answers fold to s.
if (ev.kind === "__running") return s.running === ev.running ? s : { ...s, running: ev.running };
if (ev.kind === "__running") {
const items = ev.running ? s.items : sealSay(s.items, true);
return s.running === ev.running && items === s.items ? s : { ...s, running: ev.running, items };
}
// A wait only text or reasoning could end outlived every turn whose first
// packet was a tool call: the retry line and its clock stayed up for the rest
// of the turn, over calls that were running fine.
@@ -400,7 +403,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
case "tool_dispatch":
return ev.tool
? { ...s, doing: ev.tool.name, executions: noteTool(s.executions, ev.tool, false), items: foldTool(s.items, ev.tool, true) }
? { ...s, doing: ev.tool.name, executions: noteTool(s.executions, ev.tool, false), items: foldTool(ev.tool.parentId ? s.items : ev.tool.partial ? stopThought(s.items) : sealSay(s.items), ev.tool, true) }
: s;
case "tool_progress":
+69 -78
View File
@@ -63,6 +63,13 @@ and selected states, while long titles truncate within the remaining width.
hierarchy to weight alone. `.side .lbl` is listed there for specificity: the
right column's same-named rule comes later in the file.
## Select alignment
Styleable native selects center their value on the cross axis. Their fixed
control height and centered background arrow must share the same text center,
including settings rows and enlarged interface fonts. The browser still owns
the picker and keyboard navigation.
## Wallpaper and sky
- `body::before` is the theme's picture, when a pack ships one. It sits behind
@@ -97,6 +104,9 @@ and selected states, while long titles truncate within the remaining width.
## Title bar and chrome
- Navigation selection uses the theme's tinted surface and readable accent
ink, with a side marker and a light inset border. Theme packs use the same
treatment to distinguish page position from solid primary-action buttons.
- `-webkit-app-region` is Chromium's drag property. Inside the shell this row
*is* the title bar, so it has to be the drag handle, and every control on it
opts back out. Membership is what the markup says is interactive, not a list of the
@@ -218,6 +228,12 @@ and selected states, while long titles truncate within the remaining width.
## Composer shelf
- Send and stop use 18px Lucide icons inside their circular hit targets. Stop
keeps its filled square on every screen size; a tinted surface distinguishes
it without an outline competing with the symbol.
- Model choices reserve the first line for the model name. Protocol and image
capability share the second line; the capability badge and connection status
keep their width, while long names and endpoints truncate inside the menu.
- A mode is this turn's capability, not a preference, which is why it lives on
the composer and not in settings. A pill is its label's width or it is nothing:
shrinking one squeezed the shelf's own controls, and Chinese wraps.
@@ -853,81 +869,35 @@ what exists.
mixed text. The "get a key" entry sits at the label's right end with tracking
zeroed, because the label's .06em is for Latin small labels.
## The settings sheet
## Full-screen settings
- One title bar exists — `.chrome` — and it owns the window buttons and the drag
region; this layer starts below it, so the window buttons still work while
settings is open.
- The backdrop dims and softens but stays thin: the sheet over it is glass, and
what shows through glass has to be light.
- A floating layer fades in rather than sliding: a full-bleed blurred backdrop
shows its edge the moment it moves. The sheet is conditionally rendered, so it
had an entrance and no way out — it simply stopped existing. A view transition
gives it both ends. Only the named element takes part; without naming the root
explicitly, every swap also cross-fades the whole window.
- The change preview uses the same pair — a dimmed, blurred backdrop and a
raised surface starting below the title bar — for the same reason, including
the same view transition. The diff inside it drops the top margin it carries
for use inside a card, where it is one element among several; here it is the
only content.
- A very faint gilt line under the header says this is the window's own thing,
not content.
- The header's expand/restore and close buttons share a 32px hit-region height,
matching the search control's minimum height. Inline-flex centres SVG and text
content independently of font fallback; native window controls retain their own sizing.
- The search entry sits beside the title. That position used to hold one
explanation addressed to two of the three tiers; that sentence now lives on
each settings block (`.apply`). Results are laid under the header without
repainting settings itself: looking for something should not take away the
page you are reading.
- Search results give the setting name a full-width first row. Section and
scope/apply metadata share a wrapping second row; neither can consume the
name's width. Results retain their content height inside the scrolling list.
- What changing a setting costs is a fact, not a warning: one colour for all
three tiers, only lighter than body text. What deserves colour is a present
state such as "cannot be changed while a turn is running".
- A search landing flashes once: it is navigation feedback, not a changed
setting.
- The nav column is 224px because of the Chinese names: "Tools and permissions"
needs 138px at that width while a row leaves the name 99.6px. The extra 16px
does not close the gap but decides how many characters of the value survive
beside it. It is named `.navsec`, not `.navgrp`: the rail already owns that
name for its columns of buttons.
- The nav doubles as a readout: the current value hangs at the right, so opening
settings shows at a glance what this session runs under — which was the only
reason the separate status bar existed.
- The value gives up its width first. Chinese names fit this column and English
ones do not, and a row that truncates the name says nothing. The name's basis
is its content width rather than 0: at 0 the row never "overflows", the name
simply takes what is left and is clipped, and the value is never asked to
yield. The value's shrink factor is 99, not 1: with both shrinkable the browser
splits the overflow in proportion to their bases and takes a slice off the name
too — 38px off "工具与权限" in the English build.
- A bar props the selected row up from the left. In a column of equal pills a
fill alone gives out on one side or the other.
- `.nv` is real information, not decoration: `--ghost` at 10.5px measures 2:1 and
cannot be read.
- The settings column hugs the nav rather than centring: with only a left rail,
centring suspends the content as an island. 760px is a reading measure for
prose; the extensions page is a table of five columns and is the one page
widened, or its descriptions are clipped.
- Changing section is one navigation: what changes is the column, not each block
arriving separately. Four blocks each flying in is four announcements of "here
is something new" when nothing happened — you walked to another page. The
section mark is the section's mark, not its state: it appears on every page, so
tinting it amber says every page needs you.
- A state is said in the vocabulary of the separation it modifies: with no line
to tint, hover lifts the fill and focus rings.
- The advanced fold hides three complete settings blocks, not a note, so its
summary is drawn at a settings row's weight. Collapsed, `.ds` says whether
anything inside has been changed: hiding a setting that is in force leaves the
page disagreeing with the window.
- The page heading says where you are; block headings stay strong enough to scan
the individual decisions. A command named in a note is set in the interface's
own mono, because a bare `<code>` inherits the browser's default family.
- Settings fills the application window and owns a draggable header with a
labelled return action and native window controls. The header remains outside
the settings scroller and search results, inside its focus boundary.
- Desktop navigation stays 240px wide. Content uses the remaining width with
fixed 24px side gutters; every section card fills one column without a cap.
Phone navigation becomes a horizontal strip with 14px content gutters.
- Categories keep their editors expanded. Pill navigation scrolls below the
sticky index without replacing editors or discarding drafts. Leaving a
category retains the existing unsaved-change guard.
- Page, card and group headings use distinct sizes; groups use h4 beneath card
h3 headings. Descriptions wrap and related controls share a card.
- `settings-fullscreen.mjs` checks every category in both schemes and widths,
including enlarged text; `settings-page-column.mjs` checks pill landing.
- Search overlays the content column while preserving its editors. Results use
a full-width name followed by wrapping category, scope and effect metadata.
- Scope and effect are facts with neutral styling. Landing briefly marks the
located setting; changing category resets only the content scroller.
- Navigation labels keep their content width before secondary counts yield.
Search aliases never become separate settings or controls.
## Settings rows
Log paths and account notes wrap within their rows, including long unbroken
paths at enlarged text sizes. Their content never relies on the card gutters
to absorb horizontal overflow.
- One option per row, shared with the ask card as one control: two copies drift
apart. `.prow`'s only differences are its own disabled and danger states.
- Risk is present before you click, not red after you select: with "allow
@@ -1597,7 +1567,7 @@ A sub-agent row, by the list's own width:
## Dialogs centre on the window
- A veil (settings, feedback, a full screenshot, a checkpoint preview, a
- A veil (feedback, a full screenshot, a checkpoint preview, a
sub-agent transcript) starts below the title bar so the bar stays usable.
- Centred in that box alone, a dialog would sit half a bar below the window's
middle, which reads as off balance against the whole window.
@@ -1605,14 +1575,13 @@ A sub-agent row, by the list's own width:
bar, so the box it centres in is symmetric about the window's middle. A border
rather than padding because padding on these veils is set per breakpoint, and
percentage heights inside then leave room for it on their own.
- The phone layout (`data-fold~="scene"`) shows settings full screen, so it is
excluded.
- The phone layout (`data-fold~="scene"`) is excluded. Settings owns a full
screen at every width and is outside the dialog-centering selector.
## Settings and feedback veils
## Feedback veil
- `.prefs` and `.fbk-veil` use the same flat, 93% page-colour scrim in both
themes, not a blurred backdrop. Settings stays visually separate from live
content without filtering that content on every frame.
- `.fbk-veil` uses a flat, 93% page-colour scrim in both themes without
filtering the live content on every frame.
- A backdrop filter over the whole window is redone every frame while anything
beneath it moves; a running turn's spinners are enough.
- The scrim is opaque enough that what shows through reads as dimmed, which is
@@ -1756,3 +1725,25 @@ A sub-agent row, by the list's own width:
- Appearance → Conversation display offers “Show collapsed plan progress,” off by default and persisted on this machine. Changing it immediately updates mounted plan cards.
- A nonempty collapsed plan draws the expanded plan's `.prog2` track below its summary, using completed steps over total steps from `stepDone`. Pending and running steps do not count as complete.
- Opening the card replaces the optional track with the existing expanded progress. Goal-only, empty and hidden cards have no progress track; collapse, Escape, outside dismissal and focus return remain unchanged.
## Provider form fields
- A compound input gives its editable text the space left after the unit label.
Only standalone setting-row and threshold controls use a fixed numeric width.
- Provider inputs and selects share a 32px height. Model names occupy their own
row; capacity and price fields fill the remaining grid width. Container queries
reduce columns using the form's available width and scaled text size.
- Billing choices use short names, with their meaning explained beneath the
picker. The provider form's selects fill their column and reserve arrow space.
- `provider-fields.mjs` checks full numeric values, inheritance hints, currency
and billing choices at wide, intermediate and phone widths, both languages,
both schemes and enlarged text.
## Sidebar tool card
- The footer keeps Settings and Feedback as labelled rows aligned at the left.
Enabled shortcuts appear in a card with library icons and names, preserving
their saved order and visibility. Hiding every shortcut removes the chevron.
- Hover waits briefly before opening and closing so the pointer can cross to
the card. Click and keyboard activation also work; Escape restores focus.
The portaled card follows its anchor and stays inside the viewport.
+41 -39
View File
@@ -104,7 +104,7 @@
}
:root[data-boot] .app { animation: m-card 1.2s cubic-bezier(.4, 0, .2, 1) both }
:root[data-boot] .chrome::after {
content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none;
background: linear-gradient(105deg, transparent 34%,
@@ -122,7 +122,7 @@
:root[data-boot] .chrome::after { display: none }
}
[data-titlebar="app"] .chrome { -webkit-app-region: drag }
[data-titlebar="app"] .chrome
*:not(:is(button, a[href], input, select, textarea, summary, .picker,
@@ -160,7 +160,7 @@
display: grid; place-items: center; width: 44px; border: 0; background: transparent;
color: var(--faint); cursor: pointer; transition: background var(--m-line), color var(--m-line);
}
.winctl .wc svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 1.1 }
.winctl .wc svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2 }
.winctl .wc:hover { background: var(--overlay); color: var(--text) }
.winctl .wc.close:hover { background: var(--err); color: #fff }
.winctl .wc:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px }
@@ -180,7 +180,7 @@
font-weight: var(--w-med); color: var(--muted);
flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.picker { position: relative; flex: none }
.isolab { flex: none; white-space: nowrap; font: var(--w-med) var(--fs-micro) var(--ui); letter-spacing: var(--track-cap); color: var(--muted); padding: 1.5px 6px; border-radius: var(--r-pill); background: var(--overlay) }
.isolab[hidden] { display: none }
@@ -331,7 +331,7 @@
}
.mode:hover .lb, .mode[aria-expanded="true"] .lb { color: var(--muted) }
.mode:hover .vl, .mode[aria-expanded="true"] .vl { color: var(--text) }
.mode .ic { display: grid; place-items: center; width: 12px; height: 12px; flex: none }
.mode .ic svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round }
.mode.danger { color: var(--err-ink); border-color: color-mix(in srgb, var(--err) 45%, var(--border)) }
@@ -342,7 +342,7 @@
background: color-mix(in srgb, var(--err) 12%, transparent);
animation: m-pulse var(--t-live) var(--ease) infinite;
}
.sep { width: 1px; height: 14px; flex: none; margin-inline: -1px; background: var(--border); opacity: .75 }
.side .block { transition: opacity var(--m-panel) , filter var(--m-panel) }
@@ -362,7 +362,7 @@
.thbtn svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round }
.pip { width: 6px; height: 6px; border-radius: 50%; background: var(--ghost); flex: none }
.rmark { width: 15px; height: 15px; flex: none; display: block; color: var(--ghost); transition: color var(--m-panel) }
.rmark path {
fill: none; stroke: currentColor; stroke-width: 1.7;
@@ -374,7 +374,7 @@
.pane[data-run="running"] .rmark path { stroke-dashoffset: 0 }
.pane[data-run="halt"] .rmark { color: var(--accent-ink) }
.pane[data-run="done"] .rmark { color: var(--ok) }
.queue {
@@ -433,7 +433,7 @@
width: 5px; height: 5px; flex: none; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 11%, transparent);
}
.qi .pill {
margin-left: 0; padding: 2px 7px; border-radius: var(--r-xs);
font: var(--w-med) var(--fs-micro) var(--mono); letter-spacing: .03em;
@@ -473,7 +473,7 @@
}
.qi .x:hover, .qi .x:focus-visible { color: var(--text); background: var(--overlay) }
.call[data-pending] > .c { opacity: .7 }
.app {
@@ -597,7 +597,7 @@
.ws .where .tag { flex: none; color: var(--accent-ink) }
.ws[data-iso="on"] .where .tag { color: var(--ok-ink) }
.main { position: relative; display: flex; flex-direction: column; min-height: 0; background: var(--surface) }
.vtabs { display: flex; align-items: center; gap: 2px; min-width: 0 }
@@ -657,10 +657,11 @@
.btab { max-width: 220px; overflow: hidden; text-overflow: ellipsis }
.bnav { display: flex; align-items: center; gap: 4px }
.bbtn {
flex: none; width: 28px; height: 28px; border: 0; border-radius: var(--r-sm);
background: none; color: var(--faint); cursor: pointer; font: var(--w-med) var(--fs-ui) var(--ui);
flex: none; display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--r-sm);
background: none; color: var(--faint); cursor: pointer;
}
.bbtn:hover { color: var(--text); background: var(--overlay) }
.bbtn:hover:not(:disabled) { color: var(--text); background: var(--overlay) }
.bbtn:disabled { opacity: .3; cursor: default }
.bbtn:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px }
.baddr { flex: 1; min-width: 0 }
.baddr input {
@@ -827,7 +828,6 @@
.call[data-k="host"] > .c .hl .nm { font-weight: var(--w-reg); color: var(--muted) }
.call[data-k="say"] > .g .sym { background: none; box-shadow: none; color: var(--text) }
.call[data-k="say"] > .g .sym svg { fill: currentColor }
.call[data-k="say"] > .g {
background: linear-gradient(color-mix(in srgb, var(--border) 88%, transparent) 0 0)
@@ -856,7 +856,7 @@
.hl { display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap; transition: opacity var(--m-exit) }
.hl[data-swap] { opacity: 0 }
.enterbox[data-enter] .hl .cost { animation: m-panel var(--t-card) var(--ease-out) both }
.call > .g .sym {
transition: opacity var(--t-card) var(--ease-out),
box-shadow var(--t-card) var(--ease-out);
@@ -1178,12 +1178,12 @@
.comp details { margin-top: var(--y-close) }
.comp-l { margin-top: 5px; display: flex; flex-direction: column; gap: 3px }
.comp-l .row { display: flex; gap: 8px; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint) }
.comp-l .row .x { color: var(--del); flex: none; width: 10px }
.comp-l .row .k { color: var(--ok); flex: none; width: 10px; font-size: var(--fs-micro) }
.comp-l .row .x { color: var(--del); flex: none; width: 16px }
.comp-l .row .k { color: var(--ok); flex: none; width: 16px }
.steps { margin-top: 6px; display: flex; flex-direction: column; gap: 4px }
.steps .s { display: flex; align-items: baseline; gap: 9px; font-size: var(--fs-ui); color: var(--muted) }
.steps .s .b { font: var(--w-reg) var(--fs-micro)/1.5 var(--mono); width: 10px; flex: none; color: var(--ghost) }
.steps .s .b { display: inline-flex; align-items: center; justify-content: center; font: var(--w-reg) var(--fs-micro)/1.5 var(--mono); width: 16px; flex: none; color: var(--ghost) }
.steps .s[data-done] { color: var(--faint) }
.steps .s[data-done] .b { color: var(--ok) }
.steps .s[data-now] { color: var(--text) }
@@ -1364,7 +1364,7 @@
.intake[data-tone="ref"] { color: var(--net) }
.intake[data-tone="insert"] { color: var(--ok-ink) }
.intake[data-tone="reject"] { color: var(--faint) }
.shots {
list-style: none; margin: 0; display: flex; flex-wrap: nowrap; align-items: center; gap: 7px;
padding: 0 2px 8px; overflow-x: auto; overscroll-behavior-inline: contain; scrollbar-width: thin;
@@ -1556,7 +1556,7 @@
.go { display: inline-flex; align-items: center; gap: 7px; flex: none }
.compose .row > * { position: relative; z-index: 8; animation: m-card var(--m-panel) both }
.compose .row [data-pending] { pointer-events: none; opacity: .5; transition: opacity var(--m-line) }
.compose .row:has([data-pending])::after {
@@ -1575,7 +1575,7 @@
.policy .vl .polrisk { color: var(--err-ink); font-weight: var(--w-med) }
.policy .vl .polwarn { margin-right: 4px; font-style: normal; display: inline-grid; vertical-align: -1.5px }
.policy .vl .polwarn svg, .cachemoved i svg {
.policy .vl .polwarn svg, .cachemoved > .studio-icon {
width: 12px; height: 12px; fill: none; stroke: currentColor;
stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round;
}
@@ -1587,7 +1587,7 @@
color: var(--err-ink);
box-shadow: var(--lit), 0 0 0 1px color-mix(in srgb, var(--err) 42%, transparent);
}
.polmenu {
min-width: 296px; padding: 13px 14px 12px; left: auto; right: 0;
display: flex; flex-direction: column; gap: 16px;
@@ -1793,7 +1793,7 @@
display: flex; align-items: baseline; gap: 5px; margin: 8px 0 0;
font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--warn-ink);
}
.cachemoved i { font-style: normal; display: inline-grid; vertical-align: -1.5px }
.cachemoved > .studio-icon { align-self: center }
.bar { display: flex; height: 3px; overflow: hidden; background: var(--hair); gap: 1px }
.bar i { display: block; transition: flex-grow var(--t-live) var(--ease); position: relative }
@@ -1985,7 +1985,7 @@
.mode .vl, .mode.danger::before, .compose .row > *, .call[data-running] > .g .sym { animation: none !important }
.gutter[data-edge]::after { animation: m-pulse var(--t-card) var(--ease) }
.compose .row:has([data-pending])::after { animation: none; background: var(--net); opacity: .5 }
.scroll { scroll-behavior: auto }
.btn:active, .navbtn:active,
@@ -2109,7 +2109,7 @@
animation: m-panel var(--t-panel) var(--ease-spring) both;
}
@supports (view-transition-name: none) {
:root { view-transition-name: none }
.prefs { view-transition-name: prefsveil; animation: none }
@@ -2336,7 +2336,7 @@
.advset:not(.appearance-advanced) > .grp:last-child { border-bottom: 0 }
.grp-hd { display: flex; align-items: baseline; gap: 12px }
.grp > .grp-hd h3, .grp > h3.grp-hd { margin: 0; font: var(--w-semi) var(--fs-lead)/1.35 var(--ui); color: var(--text); letter-spacing: -.005em }
.grp > .grp-hd :is(h3, h4), .grp > h3.grp-hd { margin: 0; font: var(--w-semi) var(--fs-lead)/1.35 var(--ui); color: var(--text); letter-spacing: -.005em }
.grp .now {
margin-left: auto; flex: none; padding: 2px 7px; border-radius: var(--r-pill);
background: var(--overlay); font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint);
@@ -2771,7 +2771,7 @@
.addp-head strong { display: block; font: var(--w-semi) var(--fs-lead) var(--ui); color: var(--text) }
.addp-head p { margin: 4px 0 0; font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); color: var(--muted) }
.addp-close {
flex: none; width: 28px; height: 28px; border: 0; border-radius: var(--r-xs);
flex: none; width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: var(--r-xs);
background: transparent; color: var(--faint); font: var(--w-reg) var(--fs-title)/1 var(--ui); cursor: pointer;
}
.addp-close:hover { background: var(--overlay); color: var(--text) }
@@ -3296,7 +3296,7 @@
.probes { margin-top: 14px; display: flex; flex-direction: column }
.probe { display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap; padding: 7px 0; border-top: 1px solid var(--hair) }
.probe:last-of-type { border-bottom: 1px solid var(--hair) }
.probe .mk { flex: none; width: 14px; font-style: normal; color: var(--err) }
.probe .mk { flex: none; width: 16px; font-style: normal; color: var(--err) }
.probe[data-ok] .mk { color: var(--ok) }
.probe .lb { flex: none; min-width: 68px; font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--muted) }
.probe .dt { flex: 1; min-width: 0; font: var(--w-reg) var(--fs-ui) var(--mono); color: var(--text); word-break: break-all }
@@ -3575,7 +3575,7 @@
.acct-who { display: flex; align-items: baseline; gap: 9px }
.acct-who .nm { font: var(--w-med) var(--fs-ui) var(--ui); color: var(--text) }
.acct-who .em { font: var(--w-reg) var(--fs-ui) var(--mono); color: var(--muted) }
.acct-note { font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); color: var(--muted); margin: 0 }
.acct-note { font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); color: var(--muted); margin: 0; overflow-wrap: anywhere }
.acct-note[data-err] { color: var(--err-ink) }
.bk { display: flex; flex-direction: column; gap: 12px }
@@ -3821,7 +3821,7 @@
}
.movemenu button:hover { background: var(--float-hi); color: var(--text) }
.movemenu button[aria-checked="true"] { color: var(--text) }
.movemenu button[aria-checked="true"]::after { content: " ·" }
.movemenu button[aria-checked="true"] { display: flex; align-items: center; justify-content: space-between; gap: 8px }
.drawnby { margin-top: 6px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--ghost) }
@@ -3894,7 +3894,7 @@
.oobe[data-play] .oobe-sub { opacity: 0; animation: m-card .8s var(--ease-out) 2.1s forwards }
@keyframes oobebrand { from { opacity: 0; transform: translateY(10px); filter: blur(6px) }
to { opacity: 1; transform: none; filter: none } }
.oobe-lines { position: relative; height: 96px; width: 100%; margin-top: 4px }
.oobe-line { position: absolute; inset: 0; display: grid; place-items: center; opacity: 0 }
.oobe-line b { display: block; font: var(--w-reg) calc(30px * var(--fs-scale, 1))/1.35 var(--ui); color: #fff }
@@ -4020,7 +4020,7 @@
[data-vt="tab"]::view-transition-new(tabbody) { animation: m-card var(--m-card) both }
[data-vt="pane"]::view-transition-old(pane) { animation: m-exit var(--t-card) var(--ease-in) both }
[data-vt="pane"]::view-transition-new(pane) { animation: m-card var(--m-panel) both }
.panetabs {
position: relative;
flex: none; display: flex; align-items: stretch; gap: 2px;
@@ -4045,7 +4045,8 @@
.ptab-ws, .ptab-host { flex: 0 1 auto; min-width: 0; max-width: 40%; overflow: hidden; text-overflow: ellipsis }
.ptab-ws { font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--ghost) }
.ptab-host { font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--net) }
.ptab-host::before { content: "◦"; margin-right: 2px; opacity: .7 }
.ptab-host { display: inline-flex; align-items: center; gap: 3px }
.ptab-host .studio-icon { width: 12px; height: 12px; opacity: .7 }
.ptab .pip { flex: none; background: var(--ghost) }
.ptab[data-run="running"] .pip { background: var(--net); animation: m-pulse var(--t-live) ease-in-out infinite }
@@ -4208,8 +4209,9 @@
.movess-cols h3 { margin: 0 0 4px; font: var(--w-med) var(--fs-meta) var(--ui); color: var(--text) }
.movess-cols ul { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 3px }
.movess-cols li { position: relative; padding-left: 16px; font: var(--w-reg) var(--fs-meta)/1.6 var(--ui); color: var(--muted) }
.movess-cols section[data-part="travels"] li::before { content: "✓"; position: absolute; left: 0; color: var(--ok-ink) }
.movess-cols section[data-part="stays"] li::before { content: "×"; position: absolute; left: 2px; color: var(--warn-ink) }
.movess-cols li .studio-icon { position: absolute; left: 0; top: 3px; width: 12px; height: 12px }
.movess-cols section[data-part="travels"] .studio-icon { color: var(--ok-ink) }
.movess-cols section[data-part="stays"] .studio-icon { color: var(--warn-ink) }
.movess .picknote { margin: 10px 0 0 }
.movess-act { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px }
.movess-empty { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 14px 0 }
@@ -4328,6 +4330,7 @@
.rmtcands { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 10px 0 2px }
.rmtcands .cap { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--ghost) }
.rmtcands .rmtcand {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 9px; border: 1px dashed var(--border); border-radius: var(--r-pill);
background: transparent; color: var(--faint); cursor: pointer;
font: var(--w-reg) var(--fs-meta) var(--mono);
@@ -5184,7 +5187,7 @@
.prefs-pagetitle .desc { display: inline-flex; gap: 0; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
.prefs-pagetitle .desc .what + .count::before { content: "·"; margin: 0 6px; }
.prefs-pagetitle .group { color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); }
.prefs-pagetitle .group::after { content: "›"; margin-left: 8px; }
.prefs-pagetitle .group { display: inline-flex; align-items: center; gap: 8px; }
.prefs-pagetitle h2 { margin: 0; color: var(--text); font: var(--w-semi) var(--fs-title)/1.3 var(--ui); }
.prefs-card { margin-bottom: 16px; border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--surface); overflow: hidden; }
.prefs-card > header { padding: 16px 20px 12px; }
@@ -5199,7 +5202,7 @@
.prefs-card > .grp > .grp-hd, .prefs-card > .grp > .hint, .prefs-card > .grp > .apply { grid-column: 1; }
.prefs-card > .grp:has(> .grp-items > :is(.seg, select)) > .grp-items { grid-column: 2; grid-row: 1 / span 3; margin-top: 0; align-items: flex-end; text-align: right; }
.prefs-card > .grp:has(> .grp-items > :is(.seg, select)) > .grp-hd .now { display: none; }
.prefs-card > .grp .grp-hd h3 { font: var(--w-semi) var(--fs-ui)/1.4 var(--ui); letter-spacing: 0; }
.prefs-card > .grp .grp-hd :is(h3, h4) { font: var(--w-semi) var(--fs-ui)/1.4 var(--ui); letter-spacing: 0; }
.prefs-card > .grp .hint { margin: 2px 0 0; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
.prefs-card > .grp .apply { margin: 4px 0 0; padding: 0; border: 0; color: var(--faint); font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); }
.prefs-sheet { position: relative; display: grid; grid-template-columns: 240px minmax(0, 1fr); grid-template-rows: auto auto minmax(0, 1fr); }
@@ -5208,7 +5211,6 @@
.prefs-hd h1 { grid-column: 1; grid-row: 1; padding: 18px 18px 0; background: var(--surface); }
.prefs-find { grid-column: 1; grid-row: 2; max-width: none; margin: 0; border-radius: var(--r-md); border-width: 0 0 0 0; box-shadow: inset 0 0 0 1px var(--border); background: var(--raised); }
.prefs-sheet > .prefs-hd > .prefs-find { margin: 0; align-self: start; }
.prefs-actions { grid-column: 2; grid-row: 1; justify-self: end; display: flex; align-items: center; gap: 8px; padding: 12px 14px; }
.prefs-nav { grid-column: 1; grid-row: 3; }
.prefs-main { grid-column: 2; grid-row: 2 / span 2; }
.prefs-found { top: 96px; left: 12px; right: auto; width: min(460px, calc(100% - 24px)); border-top: 1px solid var(--border); border-radius: var(--r-md); }
@@ -2,9 +2,9 @@
display: inline-flex; align-items: center; gap: 3px; padding: 0 5px; border: 1px solid var(--border); border-radius: 999px; background: transparent; cursor: pointer;
color: var(--muted); font: var(--w-reg) var(--fs-micro)/1.5 var(--mono);
}
.chrome-context-parts .chrome-context-more::after { content: "›"; transition: transform var(--t-line); }
.chrome-context-parts .chrome-context-more .studio-icon { width: 12px; height: 12px; transition: transform var(--t-line); }
.chrome-context-parts .chrome-context-more[aria-expanded="true"] { color: var(--text); background: var(--overlay); }
.chrome-context-parts .chrome-context-more[aria-expanded="true"]::after { transform: rotate(90deg); }
.chrome-context-parts .chrome-context-more[aria-expanded="true"] .studio-icon { transform: rotate(90deg); }
.chrome-context-parts .chrome-context-more:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.chrome-context-items { list-style: none; margin: 6px 0 0; padding: 0 0 0 8px; border-left: 1px solid var(--border); display: grid; grid-template-columns: minmax(0, 1fr); gap: 3px; }
.chrome-context-items li { min-width: 0; display: flex; align-items: center; gap: 8px; color: var(--muted); font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); }
@@ -1,3 +1,3 @@
:root:not([data-fold~="scene"]) :is(.prefs, .fbk-veil, .image-preview, .cpv, .agent-tx-scrim) {
:root:not([data-fold~="scene"]) :is(.fbk-veil, .image-preview, .cpv, .agent-tx-scrim) {
border-bottom: var(--chrome-h, 39px) solid transparent;
}
@@ -4,5 +4,6 @@ const CSS = import.meta.glob("./dialogcenter.css", { query: "?raw", import: "def
const css = CSS["./dialogcenter.css"]!;
it("keeps the transparent bottom border that centres a dialog under the title bar", () => {
expect(css).toMatch(/:is\(\.prefs, \.fbk-veil, \.image-preview, \.cpv, \.agent-tx-scrim\)\s*\{\s*border-bottom:\s*var\(--chrome-h, 39px\) solid transparent;/);
expect(css).not.toContain(".prefs");
expect(css).toMatch(/:is\(\.fbk-veil, \.image-preview, \.cpv, \.agent-tx-scrim\)\s*\{\s*border-bottom:\s*var\(--chrome-h, 39px\) solid transparent;/);
});
+1
View File
@@ -66,6 +66,7 @@ select[data-size="sm"] { height: 24px; padding: 0 26px 0 8px; font-size: var(--f
@supports (appearance: base-select) {
select:not([multiple]), select:not([multiple])::picker(select) { appearance: base-select; }
select:not([multiple]) { align-items: center; }
select:not([multiple])::picker-icon { display: none; }
select:not([multiple])::picker(select) {
box-sizing: border-box; margin-block: 4px; padding: 4px; max-height: min(320px, 50vh);
+1 -1
View File
@@ -2,7 +2,7 @@
.modlist li { display: grid; gap: 2px; padding: 8px 10px; border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--surface) }
.modlist .modhead { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 12px }
.modlist b { font-weight: var(--w-med); overflow-wrap: anywhere }
.modlist .modstate { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted) }
.modlist .modstate { display: inline-flex; align-items: center; gap: 4px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted) }
.modlist li[data-state="running"] .modstate { color: var(--ok, var(--text)) }
.modlist li[data-state="disabled"] .modstate { color: var(--danger, var(--text)) }
.modlist li[data-state="restarting"] .modstate { color: var(--warn, var(--text)) }
@@ -20,11 +20,6 @@ const SOURCES = import.meta.glob(["../**/*.tsx", "!../**/*.test.*"], {
// welcome screen is the one deliberate exception — its STUDIO line and tagline
// are set in the terminal's own face because that is the product's vernacular.
const MARKS: Record<string, string> = {
x: "× — the close mark, sized to hold its cell beside ✓",
"ptab-x": "× — same mark on a pane tab",
ah: "› — the affordance arrow",
"mode-toggle": "Aa and .* — the glyphs of the search flags they switch, which are the flags' own notation",
prompt: "› — the composer's own prompt character",
esc: "Esc — a keycap, which is lettering on a key rather than prose",
"oobe-brand": "the lockup's STUDIO line, deliberately set in the terminal's face",
"oobe-sub": "the lockup's tagline, set with it",
@@ -37,8 +32,8 @@ const MARKS: Record<string, string> = {
// while the work is outstanding. Never widen this to land a change.
const MIXED = new Set([
"c", "cnt", "cost", "cur", "d", "delta", "dt", "fail", "k",
"lb", "meta", "mk", "n", "nm", "ntimes", "path", "pill", "pt", "r", "rmtcand", "rt",
"sc", "st", "sym", "sz", "tag", "u", "uspan", "v",
"lb", "meta", "n", "nm", "ntimes", "path", "pill", "pt", "r", "rmtcand", "rt",
"sc", "st", "sz", "tag", "u", "uspan", "v",
]);
const TRANSLATORS = ["t", "tx", "plural"];
@@ -93,8 +93,6 @@
.chrome-context-foot .compact-now > .btn { height: 24px; padding: 0 10px; border: 1px solid transparent; border-radius: var(--r-pill); background: transparent; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1 var(--ui); }
.chrome-context-foot .compact-now > .btn:hover { background: var(--overlay); color: var(--text); }
.chrome-context-card .chrome-context-more { display: inline-flex; align-items: center; gap: 5px; height: auto; padding: 0; border: 0; background: none; cursor: pointer; }
.chrome-context-card .chrome-context-more::after { content: ""; width: 5px; height: 5px; border-top: 1.45px solid var(--faint); border-right: 1.45px solid var(--faint); transform: rotate(45deg); }
.chrome-context-card .chrome-context-more[aria-expanded="true"]::after { transform: rotate(135deg); }
.chrome-context-card:not(.studio-overview-card) > .chrome-context-items { margin: 2px 14px 4px; padding: 2px 0 2px 10px; gap: 2px; }
@media (pointer: coarse) {
.chrome-context-card:not(.studio-overview-card) > .chrome-context-parts > div { height: 36px; }
@@ -51,4 +51,8 @@ describe("a select's popup list", () => {
it("gives every option a finger-sized row on a touch screen", () => {
expect(block()).toMatch(/@media \(pointer: coarse\)\s*\{[^}]*select:not\(\[multiple\]\) option\s*\{[^}]*min-height:\s*40px/);
});
it("keeps the chosen text centred in the box, which the engine's own layout leaves at the top", () => {
expect(rule(block(), "select:not([multiple])")).toMatch(/align-items:\s*center/);
});
});
+19 -24
View File
@@ -108,8 +108,8 @@ body { background: var(--page); }
.navgrp { gap: 3px; }
.navbtn { width: 36px; height: 36px; border-radius: var(--r-sm); }
.navbtn:hover { background: var(--overlay); box-shadow: none; }
.navbtn[aria-current] { color: var(--text); background: var(--overlay); }
.navbtn[aria-current]::before { left: -5px; top: 10px; bottom: 10px; background: var(--text); }
.navbtn[aria-current] { color: var(--accent-ink); background: var(--accent-wash); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 18%, var(--border)); }
.navbtn[aria-current]::before { left: -5px; top: 10px; bottom: 10px; background: var(--accent-ink); }
.navhint { left: calc(100% + 8px); }
.rail {
background: color-mix(in srgb, var(--page) 94%, var(--surface));
@@ -453,13 +453,13 @@ body { background: var(--page) !important; }
/* Prototype sidebar */
.studio-icon {
width: 15px;
height: 15px;
width: 16px;
height: 16px;
display: block;
flex: 0 0 auto;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
@@ -596,13 +596,9 @@ body { background: var(--page) !important; }
}
.flow .call[data-k="say"] > .g .sym {
width: 21px; height: 21px; margin-top: 0; border-radius: var(--r-sm);
background: var(--accent); color: transparent; box-shadow: none; font-size: 0;
background: var(--accent); color: var(--accent-fg); box-shadow: none;
}
.flow .call[data-k="say"] > .g .sym::after {
content: "R"; width: 100%; height: 100%; display: grid; place-items: center;
color: var(--accent-fg); font: var(--w-med) var(--fs-micro)/1 var(--ui);
}
.flow .call[data-k="say"] > .g .sym svg { display: none; }
.flow .call[data-k="say"] > .g .sym svg { width: 15px; height: 15px; display: block; }
.flow .call[data-k="say"][data-quiet] > .g,
.flow .call[data-k="say"][data-quiet] > .c > .hl { display: none; }
.flow .call[data-k="say"] > .c { padding: 0 0 var(--y-say); }
@@ -1117,7 +1113,7 @@ body { background: var(--page) !important; }
border: 0; border-radius: var(--r-sm); background: transparent; color: var(--faint); cursor: pointer;
}
.studio-browser-toolbar > button:hover:not(:disabled) { color: var(--text); background: var(--float); }
.studio-browser-toolbar svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.35; stroke-linecap: round; stroke-linejoin: round; }
.studio-browser-toolbar svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.studio-browser-toolbar {
min-height: 46px; flex: none; display: flex; align-items: center; gap: 4px; padding: 8px 9px;
border-bottom: 1px solid var(--hair); background: var(--surface);
@@ -1133,6 +1129,12 @@ body { background: var(--page) !important; }
.studio-browser-address input { width: 100%; min-width: 0; padding: 0; border: 0; outline: 0; background: transparent; color: var(--text); font: var(--w-reg) var(--fs-micro)/1 var(--ui); }
.studio-browser-toolbar .studio-browser-go { width: auto; padding: 0 8px; color: #adb9d8; font: var(--w-med) var(--fs-micro)/1 var(--ui); }
.studio-browser-frame { width: 100%; min-height: 0; flex: 1; border: 0; background: var(--page); }
.studio-browser-start { display: grid; place-items: center; padding: 32px; color: var(--text); text-align: center; }
.studio-browser-start > div { width: min(520px, 100%); }
.studio-browser-start .browser-start-mark { box-sizing: content-box; padding: 10px; margin: 0 auto 18px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface); color: var(--muted); }
.studio-browser-start h1 { margin: 0 0 8px; font-size: var(--fs-title); }
.studio-browser-start p { margin: 0; color: var(--faint); font-size: var(--fs-ui); line-height: 1.7; }
.studio-browser-start .browser-start-hint { margin-top: 22px; padding: 11px 14px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface); text-align: left; }
.studio-browser-note,
.studio-browser-error { flex: none; margin: 0; padding: 7px 12px; border-top: 1px solid var(--hair); background: var(--surface); color: var(--ghost); font: var(--w-reg) var(--fs-micro)/1.45 var(--ui); }
.studio-browser-note { display: flex; align-items: center; gap: 10px; }
@@ -1301,7 +1303,7 @@ body { background: var(--page) !important; }
:root[data-theme="light"] .pane,
:root[data-theme="light"] .pane > .scroll { background: var(--reading-surface, var(--page)) !important; }
:root[data-theme="light"] .flow .call[data-k="say"] > .g .sym { background: var(--accent); }
:root[data-theme="light"] .flow .call[data-k="say"] > .g .sym::after { color: var(--accent-fg); }
:root[data-theme="light"] .flow .call[data-k="say"] > .g .sym { color: var(--accent-fg); }
:root[data-theme="light"] .flow .call[data-k="say"] > .c > .hl .nm { color: var(--muted); }
:root[data-theme="light"] .flow .call[data-k="say"] > .c > .out > .txt { color: var(--text); }
:root[data-theme="light"] .flow .call[data-k="say"] .copy[data-icon]:hover { color: var(--text); background: var(--overlay); border-color: var(--border); }
@@ -2024,7 +2026,7 @@ button, input, textarea { font-size: var(--fs-ui); }
.portmenu .mi .dot { display: none; }
.portmenu .mi[data-on] { background: var(--hair); }
.portmenu .mi[data-on]::before { display: none; }
.portmenu .mi[data-on]::after { content: "✓"; align-self: center; margin-left: 8px; color: var(--text); font: var(--w-med) var(--fs-micro)/1 var(--ui); }
.portmenu .menu-current { align-self: center; margin-left: 8px; color: var(--text); }
.portmenu .mi.head { gap: 8px; min-height: 34px; align-items: center; padding-bottom: 8px; margin-bottom: 3px; border-bottom: 1px solid var(--border); border-radius: 0; }
.portmenu .mi.head .lb { color: var(--text); font-size: var(--fs-micro); }
.portmenu .mi.head .rt { color: var(--ghost); font: var(--w-reg) var(--fs-micro)/1 var(--ui); }
@@ -2074,14 +2076,9 @@ button, input, textarea { font-size: var(--fs-ui); }
.studio-effort-menu .studio-effort-strength i:nth-child(3) { height: 10px; }
.studio-effort-menu .studio-effort-strength i:nth-child(4) { height: 13px; }
.studio-effort-menu .studio-effort-strength i[data-on] { background: #9a9f94; }
.studio-effort-menu .mi[data-recommended] .studio-effort-strength::before {
content: "✦";
color: var(--faint);
font: var(--w-reg) var(--fs-micro)/1 var(--ui);
}
.studio-effort-menu .mi[data-recommended] .studio-effort-strength i { display: none; }
.studio-effort-menu .studio-effort-strength .studio-icon { color: var(--faint); }
.studio-effort-menu .mi[data-strength="0"] .studio-effort-strength { visibility: hidden; }
.studio-effort-menu .mi[data-on]::after { margin-left: 0; }
.studio-effort-menu .menu-current { margin-left: 0; }
.polmenu {
width: 310px;
@@ -2534,7 +2531,7 @@ button, input, textarea { font-size: var(--fs-ui); }
.flow .ask-hint,
.flow .opt .ds { color: var(--muted); }
.flow .call[data-k="say"] > .g .sym { background: var(--text); }
.flow .call[data-k="say"] > .g .sym::after { color: var(--page); }
.flow .call[data-k="say"] > .g .sym { color: var(--page); }
.flow .ask { background: var(--raised) !important; border-color: var(--border) !important; }
.tool-disclosure > summary.hl:hover,
@@ -3542,8 +3539,6 @@ button, input, textarea { font-size: var(--fs-ui); }
:root[data-pack] .chrome-context-card section > button { color: var(--link); }
:root[data-pack] .studio-brand-accent { color: var(--brand); }
:root[data-pack] .navbtn[aria-current],
:root[data-pack] .navbtn[aria-current]::before { background: var(--accent); }
:root[data-pack] .btn.send:not(:disabled) { background: var(--accent); color: var(--accent-fg); }
:root[data-pack] .studio-effort-menu .studio-effort-strength i[data-on] { background: var(--accent); }
:root[data-pack] .compose:hover::after,
@@ -1,23 +1,20 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import "./testkit";
import { MockPort } from "../port/mock";
import type { AccountState, AgentPort } from "../port/port";
import { AccountSettings } from "./AccountSettings";
import { SettingDeepContext } from "./SettingDeep";
afterEach(cleanup);
function draw(state: AccountState | null) {
const go = vi.fn();
const port = new MockPort() as unknown as AgentPort;
render(
<SettingDeepContext.Provider value={{ pages: true, open: null, section: "账号与关于", go }}>
<>
<AccountSettings port={port} homePort={port} state={state} unread="" reload={() => {}} />
</SettingDeepContext.Provider>,
</>,
);
return go;
}
it("is account, version and diagnostics, and about: one row per setting", () => {
@@ -27,16 +24,16 @@ it("is account, version and diagnostics, and about: one row per setting", () =>
});
it("offers sign-in when signed out and the account when signed in", () => {
const go = draw({ signedIn: false });
fireEvent.click(screen.getByRole("button", { name: "登录" }));
expect(go).toHaveBeenLastCalledWith("account");
draw({ signedIn: false });
expect(document.querySelector('[data-action="account.sign-in"]')).toBeTruthy();
cleanup();
draw({ signedIn: true, user: { handle: "demo", email: "[email protected]", label: "demo" } });
expect(screen.getByRole("button", { name: "账号信息" })).toBeTruthy();
expect(screen.getByText("[email protected]")).toBeTruthy();
expect(document.querySelector('[data-action="account.sign-out"]')).toBeTruthy();
});
it("opens versions and logs from their pills", () => {
const go = draw({ signedIn: false });
fireEvent.click(screen.getByRole("button", { name: "版本与更新" }));
expect(go).toHaveBeenLastCalledWith("versions");
it("shows versions and diagnostics inline", async () => {
draw({ signedIn: false });
await waitFor(() => expect(document.querySelector("#set-versions .vnow")).toBeTruthy());
expect(document.querySelector('#set-logs .grp-items')).toBeTruthy();
});
@@ -22,15 +22,15 @@ export function AccountSettings({ port, homePort, state, unread, reload }: Props
return (
<>
<SectionCard title={t("settings.account")}>
<Group id="account" pill={t(state?.signedIn ? "settings.account_info" : "settings.sign_in")} title={t("settings.account")} hint={t("settings.account_hint")}>
<Group id="account" title={t("settings.account")} hint={t("settings.account_hint")}>
<Account port={port} state={state} unread={unread} reload={reload} />
</Group>
</SectionCard>
<SectionCard title={t("settings.version_and_diagnostics")}>
<Group id="versions" pill={t("settings.versions_and_updates")} title={t("common.version")} hint={t("settings.versions_hint")}>
<Group id="versions" title={t("common.version")} hint={t("settings.versions_hint")}>
<Versions port={homePort} />
</Group>
<Group id="logs" pill={t("safety.details")} title={t("about.logs_and_abnormal_exit_records")} hint={t("settings.logs_hint")}>
<Group id="logs" title={t("about.logs_and_abnormal_exit_records")} hint={t("settings.logs_hint")}>
<Logs port={homePort} />
</Group>
<TraceExport port={port} />
@@ -38,10 +38,10 @@ export function AccountSettings({ port, homePort, state, unread, reload }: Props
<SectionCard title={t("settings.about")}>
<p className="srow-desc">{t("settings.about_note")}</p>
<AuthorLine port={homePort} />
<Group id="community" pill={t("settings.look_at")} title={t("common.community_and_contributors")} hint={t("about.join_the_community_for_help")}>
<Group id="community" title={t("common.community_and_contributors")} hint={t("about.join_the_community_for_help")}>
<Community port={homePort} />
</Group>
<Group id="sponsor" pill={t("settings.look_at")} title={t("about.support_the_project")}>
<Group id="sponsor" title={t("about.support_the_project")}>
<Sponsor port={homePort} />
</Group>
</SectionCard>
+2 -1
View File
@@ -1,3 +1,4 @@
import { StudioIcon } from "./StudioIcon";
import { useCallback, useEffect, useState } from "react";
import { t } from "../i18n";
import type { Protocol, ProviderEntry, ProviderPreset, ProviderProbe } from "../port/port";
@@ -200,7 +201,7 @@ export function AddProvider({
<strong>{t("models.add_a_model_source")}</strong>
<p>{t("models.use_the_service_documentation_connection")}</p>
</div>
<button className="addp-close" onClick={onCancel} disabled={busy || testing} aria-label={t("common.cancel")}>×</button>
<button className="addp-close" onClick={onCancel} disabled={busy || testing} aria-label={t("common.cancel")}><StudioIcon name="close" /></button>
</div>
{baseUrl.trim() === "" && <PresetGallery load={loadPresets} onPick={setPreset} disabled={busy || checkingModel !== ""} />}
<div className="fields">
@@ -5,16 +5,15 @@ import "./testkit";
import { MockPort } from "../port/mock";
import type { AgentPort } from "../port/port";
import { AgentSettings } from "./AgentSettings";
import { SettingDeepContext } from "./SettingDeep";
afterEach(cleanup);
function draw() {
const go = vi.fn();
render(
<SettingDeepContext.Provider value={{ pages: true, open: null, section: "Agent 行为", go }}>
<AgentSettings port={new MockPort() as unknown as AgentPort} hub={{ tree: async () => [] }} persona={false} onChanged={() => {}} />
</SettingDeepContext.Provider>,
<>
<AgentSettings port={new MockPort() as unknown as AgentPort} hub={{ tree: async () => [] }} persona={false} onPersona={go} onChanged={() => {}} />
</>,
);
return go;
}
@@ -28,23 +27,20 @@ it("is four cards of single rows, plan mode and the display switches living else
for (const id of ["plan-mode", "changed-files", "fold-work", "feedback-level-notices"]) expect(document.querySelector(`[data-setting="${id}"]`), id).toBeNull();
});
it("opens the presets page from both the persona row and the presets row", () => {
it("opens the persona editor while keeping presets inline", async () => {
const go = draw();
fireEvent.click(screen.getByRole("button", { name: "编辑人设" }));
expect(go).toHaveBeenLastCalledWith("agent-presets");
fireEvent.click(screen.getByRole("button", { name: "管理预设" }));
expect(go).toHaveBeenLastCalledWith("agent-presets");
expect(go).toHaveBeenCalledExactlyOnceWith();
await waitFor(() => expect(document.querySelector('[data-action="agent-preset.reload"]')).toBeTruthy());
});
it("opens the hooks and the scheduled-runs pages from the automation card", async () => {
const go = draw();
const card = [...document.querySelectorAll<HTMLElement>(".prefs-card")].find((c) => c.querySelector("header h3")?.textContent === "自动化")!;
await waitFor(() => expect([...card.querySelectorAll("button")].filter((b) => b.textContent === "管理")).toHaveLength(2));
const buttons = [...card.querySelectorAll("button")].filter((b) => b.textContent === "管理");
fireEvent.click(buttons[0]);
expect(go).toHaveBeenLastCalledWith("hooks");
fireEvent.click(buttons[1]);
expect(go).toHaveBeenLastCalledWith("scheduled-runs");
expect(card.textContent).toContain("在工具调用前后运行你自己的命令");
expect(card.textContent).toContain("到点让 Agent 只读地跑一遍并留下报告");
it("exposes hooks and scheduled runs directly in the automation card", async () => {
draw();
await waitFor(() => {
for (const id of ["hooks", "scheduled-runs"]) {
const region = document.querySelector(`#set-${id}`)!;
expect(region.querySelector(".grp-items")).toBeTruthy();
expect(region.querySelector("button")).toBeTruthy();
}
});
});
+8 -11
View File
@@ -1,4 +1,4 @@
import { useContext, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { t } from "../i18n";
import type { HubPort } from "../port/hub";
import type { AgentPort } from "../port/port";
@@ -13,7 +13,6 @@ import { PlanApproval } from "./PlanApproval";
import { ProgressWatch } from "./ProgressWatch";
import { SectionCard } from "./SectionCard";
import { SessionRef } from "./SessionRef";
import { SettingDeep, SettingDeepContext } from "./SettingDeep";
import { SubagentClaims } from "./SubagentClaims";
import { TurnNarrationSetting } from "./TurnNarrationSetting";
@@ -21,6 +20,7 @@ interface Props {
port: AgentPort;
hub: Pick<HubPort, "tree">;
persona: boolean;
onPersona: () => void;
onChanged: () => void;
}
@@ -37,8 +37,7 @@ function useAutomationCounts(port: AgentPort) {
return counts;
}
export function AgentSettings({ port, hub, persona, onChanged }: Props) {
const deep = useContext(SettingDeepContext);
export function AgentSettings({ port, hub, persona, onPersona, onChanged }: Props) {
const counts = useAutomationCounts(port);
const hooksHint = t("settings.hooks_hint") + (counts.hooks ? ` · ${t("settings.n_hooks", { n: counts.hooks })}` : "");
const runsHint = t("settings.scheduled_runs_hint") + (counts.schedules
@@ -47,19 +46,17 @@ export function AgentSettings({ port, hub, persona, onChanged }: Props) {
return (
<>
<SectionCard title={t("settings.persona_and_presets")}>
<div className="srow" data-setting="persona">
<div className="srow" id="set-persona" data-setting="persona">
<div className="srow-meta">
<span className="srow-name">{t("persona.system_persona")}</span>
<span className="srow-desc">{t("settings.persona_hint")}</span>
<ApplyNote id="persona" />
</div>
<div className="srow-ctl">
<button type="button" className="spill" data-action="settings.open-page" data-target="agent-presets" onClick={() => deep.go("agent-presets")}>{t("settings.edit_persona")}</button>
<button type="button" className="spill" data-action="settings.edit-persona" onClick={() => onPersona()}>{t("settings.edit_persona")}</button>
</div>
</div>
<SettingDeep id="agent-presets" title={t("persona.agent_presets")} hint={t("settings.presets_hint")} act={t("settings.manage_presets")}>
<AgentPresets port={port} focusPersona={persona} />
</SettingDeep>
<AgentPresets port={port} focusPersona={persona} />
</SectionCard>
<SectionCard title={t("settings.plans_and_questions")}>
<PlanApproval port={port} />
@@ -73,10 +70,10 @@ export function AgentSettings({ port, hub, persona, onChanged }: Props) {
<PeersSection port={port} hub={hub} />
</SectionCard>
<SectionCard title={t("settings.automation")}>
<Group id="hooks" title={t("settings.hooks")} hint={hooksHint} pill={t("settings.manage_storage")}>
<Group id="hooks" title={t("settings.hooks")} hint={hooksHint}>
<Hooks port={port} onChanged={onChanged} />
</Group>
<ScheduledRuns port={port} hint={runsHint} pill={t("settings.manage_storage")} />
<ScheduledRuns port={port} hint={runsHint} />
</SectionCard>
</>
);
+2 -3
View File
@@ -1,3 +1,4 @@
import { StudioIcon } from "./StudioIcon";
import { useBranchSession } from "./useBranchSession";
import { CompositionNewContext } from "./CompositionNewContext";
import { keepSame } from "./stable";
@@ -722,9 +723,7 @@ export function App({ hub }: { hub: HubPort }) {
})}
{runtimes.length === 0 && (
<div className="panes-empty">
<span className="mk" aria-hidden="true">
⌘
</span>
<span className="mk" aria-hidden="true"><StudioIcon name="chat" /></span>
<p className="t">{t("app.no_session_open")}</p>
{!treeRead ? null : tree.length === 0 ? (
<>
+17 -30
View File
@@ -1,9 +1,9 @@
import { StudioIcon } from "./StudioIcon";
import { ApplyNote } from "./Group";
import { SettingDeep, SettingDeepContext } from "./SettingDeep";
import { useCallback, useContext, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
import { useCallback, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
import { effectsMode, onEffectsChange, setEffectsMode } from "../state/prefs";
import type { AgentPort, Appearance as Look, ThemePack } from "../port/port";
import { SIZE_STEPS, ZOOM_PRESETS } from "./zoom";
import { ZOOM_PRESETS } from "./zoom";
import { chord } from "./keys";
import { host } from "../port/host";
import { MONO_FAMILIES, TEXT_STEPS, UI_FAMILIES, installed, readSizeOf, readSteps } from "./look";
@@ -14,7 +14,7 @@ import { useCrop } from "./useCrop";
import { WindowSection } from "./WindowSection";
import { GraphicsSection } from "./GraphicsSection";
import { SectionCard } from "./SectionCard";
import { PillButton, SegChoice, SettingRow } from "./SettingRow";
import { SegChoice, SettingRow } from "./SettingRow";
import { Switch } from "./Switch";
import { ThemeModeRow } from "./ThemeModeRow";
import { ThemeImport } from "./ThemeImport";
@@ -80,7 +80,6 @@ export const WALLPAPER_ACCEPT = "image/png,image/jpeg,image/webp,image/avif,imag
export function Appearance({ port, theme, onTheme, contrast, onContrast, weight, onWeight, reloadThemes, look, onLook }: Props) {
const { packs, unread, load } = useThemeInventory(port);
const effects = useSyncExternalStore(onEffectsChange, effectsMode);
const deep = useContext(SettingDeepContext);
// Activating repaints through App's own theme effect, so this only refreshes
// the list and asks App to re-read which pack is active.
@@ -178,7 +177,6 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
const uiFonts = useMemo(() => installed(UI_FAMILIES), []);
const monoFonts = useMemo(() => installed(MONO_FAMILIES), []);
return (
<>
{(failed || unread) && (
@@ -188,11 +186,10 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
)}
<SectionCard title={t("settings.theme_card")}>
<ThemeModeRow theme={theme} onTheme={onTheme} />
<SettingDeep id="scheme" title={t("settings.palette")} hint={t("settings.accent_and_overall_tone")}
act={packs.find((p) => p.active)?.name ?? t("common.default")}>
<section className="grp" id="set-scheme" data-setting="scheme">
<div className="grp-hd">
<h3>{t("look.interface_theme")}</h3>
<h4>{t("look.interface_theme")}</h4>
<span className="now">{t("look.in_effect_now")}</span>
</div>
<p className="hint">{t("look.choose_the_workbench_surfaces_and")}</p>
@@ -202,7 +199,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
{featured.map(cell)}
</div>
{illustrated.length > 0 && (
<details className="theme-more">
<details className="theme-more" open>
<summary>
<span>
<b>{t("look.more_installed_themes")}</b>
@@ -232,12 +229,11 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
</div>
<ApplyNote id="scheme" />
</section>
</SettingDeep>
<SettingDeep id="wallpaper" title={t("look.wallpaper")} hint={t("settings.chat_background_image_or_video")} act={t("settings.choose_wallpaper")}>
<section className="grp" id="set-wallpaper" data-setting="wallpaper">
<div className="grp-hd">
<h3>{t("look.wallpaper")}</h3>
<h4>{t("look.wallpaper")}</h4>
{look.wallpaper && (
<button className="now nowbtn" data-action="wallpaper.remove" onClick={dropPaper}>
{t("common.remove")}
@@ -259,9 +255,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
/>
<button className="paperpick" data-busy={busy ? "" : undefined} onClick={() => file.current?.click()}>
<span className="plus" aria-hidden="true">
<svg viewBox="0 0 16 16">
<path d="M8 3.7v8.6M3.7 8h8.6" />
</svg>
<StudioIcon name="plus" />
</span>
{t(look.wallpaper ? "look.choose_another" : "look.choose_an_image_or_video")}
</button>
@@ -382,22 +376,16 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
</SettingDeep>
<SettingRow id="effects" title={t("look.visual_effects")} desc={t("settings.frosted_glass_glow_effects")}>
<Switch on={effects === "full"} label={t("look.visual_effects")} data-action="appearance.effects" data-value={effects}
onClick={() => setEffectsMode(effects === "full" ? "reduced" : "full")} />
</SettingRow>
{deep.pages && (
<SettingRow id="advanced-look" title={t("look.advanced_look")} desc={t("look.import_themes_custom_colours")}>
<PillButton action="settings.open-page" target="scheme" onClick={() => deep.go("scheme")}>{t("look.advanced_look")}</PillButton>
</SettingRow>
)}
</SectionCard>
<SectionCard title={t("settings.text_card")}>
<SettingDeep id="font" title={t("look.typeface")} hint={t("settings.interface_and_code_typeface")} act={t("settings.change")}>
<section className="grp" id="set-font" data-setting="font">
<div className="grp-hd">
<h3>{t("look.typeface")}</h3>
<h4>{t("look.typeface")}</h4>
</div>
<p className="hint">{t("look.enter_a_typeface_name_or")}</p>
<div className="grp-items">
@@ -420,11 +408,10 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
</div>
<ApplyNote id="font" />
</section>
</SettingDeep>
<SettingDeep id="size" title={t("look.size")} hint={t("settings.overall_text_size")} act={t("settings.adjust")} ctl={<SegChoice<string> label={t("look.interface_size")} value={String(look.zoom || 1)} action="appearance.zoom" onPick={(v) => set({ zoom: Number(v) })} options={SIZE_STEPS.map(([v, name]) => [String(v), t(name)] as const)} />}>
<section className="grp" id="set-size" data-setting="size">
<div className="grp-hd">
<h3>{t("look.size")}</h3>
<h4>{t("look.size")}</h4>
</div>
<p className="hint">{t("look.interface_scales_spacing_and_controls")}</p>
{host().inShell() && <p className="hint">{t("look.keyboard_up_larger_down_smaller", { up: chord("+"), down: chord("-"), reset: chord("0") })}</p>}
@@ -508,7 +495,6 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
<ApplyNote id="size" />
</section>
</SettingDeep>
<SettingRow id="weight" title={t("look.text_weight")} desc={t("settings.weight_of_text")}>
<SegChoice<string> label={t("look.text_weight")} value={weight} action="appearance.weight" onPick={onWeight}
options={WEIGHTS.map(([id, name]) => [id, t(name)] as const)} />
@@ -519,9 +505,9 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
</SettingRow>
</SectionCard>
<SectionCard title={t("common.window")}>
<SettingDeep id="window" title={t("common.window")} hint={t("settings.remember_window_position_and_size")} act={t("settings.change")}>
<WindowSection port={port} />
</SettingDeep>
<GraphicsSection />
</SectionCard>
</>
@@ -552,6 +538,7 @@ function FontPick({
<span className="tx">{label}</span>
<input
className="fontown"
aria-label={label}
list={list}
value={value}
placeholder={options.length ? t("look.default_n_available_on_this", { n: options.length }) : t("common.default")}
@@ -9,7 +9,9 @@ afterEach(cleanup);
describe("BrowserPanel", () => {
it("opens a typed address when Enter is pressed", async () => {
const user = userEvent.setup();
render(<BrowserPanel id="b0" scheme="light" onAddress={vi.fn()} onExternal={vi.fn()} />);
const external = vi.fn();
render(<BrowserPanel id="b0" scheme="light" onAddress={vi.fn()} onExternal={external} />);
expect(screen.getByTitle("Reasonix 内置 Browser").querySelector(".lucide-globe")).not.toBeNull();
const address = screen.getByRole("textbox", { name: "网页地址" });
await user.clear(address);
@@ -17,6 +19,17 @@ describe("BrowserPanel", () => {
expect((address as HTMLInputElement).value).toBe("https://example.com/docs");
expect(screen.getByTitle("Reasonix 内置 Browser").getAttribute("src")).toBe("https://example.com/docs");
const toolbar = document.querySelector(".studio-browser-toolbar")!;
const outside = toolbar.querySelector<HTMLButtonElement>('[data-action="browser.external"]')!;
expect(outside.querySelector(".lucide-external-link")).not.toBeNull();
await user.click(outside);
expect(external).toHaveBeenCalledWith("https://example.com/docs");
const frame = screen.getByTitle("Reasonix 内置 Browser");
await user.click(toolbar.querySelector('[data-action="browser.reload"]')!);
expect(screen.getByTitle("Reasonix 内置 Browser")).not.toBe(frame);
await user.click(toolbar.querySelector('[data-action="browser.back"]')!);
expect((address as HTMLInputElement).value).toBe("reasonix://start");
expect(outside.disabled).toBe(true);
});
it("keeps invalid addresses in the field and explains the error", async () => {
+21 -28
View File
@@ -1,4 +1,5 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import { useEffect, useLayoutEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import { ArrowLeft, ArrowRight, ExternalLink, Globe, RotateCw } from "lucide-react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { AgentPort, BrowserTab } from "../port/port";
@@ -28,20 +29,14 @@ function normalise(value: string): string | null {
}
}
function startPage(scheme: "light" | "dark"): string {
const light = scheme === "light";
const page = light ? "#f6f7f4" : "#171817";
const text = light ? "#242622" : "#e9eae5";
const muted = light ? "#747970" : "#90958d";
const surface = light ? "#ffffff" : "#1c1d1b";
const border = light ? "#dfe1db" : "#2c2f2b";
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>
:root{color-scheme:${scheme};font-family:Inter,"Noto Sans SC",system-ui,sans-serif;background:${page};color:${text}}
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:32px;background:${page}}
main{width:min(520px,100%);text-align:center}.mark{width:42px;height:42px;margin:0 auto 18px;display:grid;place-items:center;border:1px solid ${border};border-radius:12px;background:${surface};color:#7188c4;font-size:22px}
h1{margin:0 0 8px;font-size:18px;font-weight:620;letter-spacing:-.02em}p{margin:0;color:${muted};font-size:13px;line-height:1.7}
.hint{margin-top:22px;padding:11px 14px;border:1px solid ${border};border-radius:9px;background:${surface};color:${muted};font-size:12px;text-align:left}
</style></head><body><main><div class="mark">◎</div><h1>${t("common.built_in_browser")}</h1><p>${t("workbench.enter_an_address_above_to")}</p><div class="hint">${t("workbench.some_sites_do_not_allow")}</div></main></body></html>`;
function BrowserStartPage({ scheme }: { scheme: "light" | "dark" }) {
return <div className="studio-browser-frame studio-browser-start" title={t("workbench.reasonix_built_in_browser")} style={{ colorScheme: scheme }}>
<div><Globe className="browser-start-mark" size={26} aria-hidden="true" />
<h1>{t("common.built_in_browser")}</h1>
<p>{t("workbench.enter_an_address_above_to")}</p>
<p className="browser-start-hint">{t("workbench.some_sites_do_not_allow")}</p>
</div>
</div>;
}
interface ManualProps {
@@ -63,7 +58,6 @@ export function ManualBrowserPanel({ id, hidden, onAddress, onExternal, scheme }
const [loading, setLoading] = useState(false);
const current = history[index] ?? START;
const internal = current === START;
const srcDoc = useMemo(() => startPage(scheme), [scheme]);
useEffect(() => {
onAddress(id, internal ? "" : new URL(current).hostname);
}, [id, current, internal, onAddress]);
@@ -113,32 +107,31 @@ export function ManualBrowserPanel({ id, hidden, onAddress, onExternal, scheme }
<section className="studio-browser" hidden={hidden} aria-label={t("workbench.built_in_browser")}>
<form className="studio-browser-toolbar" onSubmit={submit} data-action-submit="browser.navigate">
<button type="button" data-action="browser.back" onClick={() => move(index - 1)} disabled={index === 0} aria-label={t("workbench.back")} title={t("workbench.back")}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m9.5 3-5 5 5 5M5 8h7" /></svg>
<ArrowLeft size={16} />
</button>
<button type="button" data-action="browser.reload" onClick={() => setReload((value) => value + 1)} aria-label={t("common.re_freeze")} title={t("common.re_freeze")}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M12.7 6A5 5 0 1 0 13 9.5M12.7 6V2.8M12.7 6H9.5" /></svg>
<RotateCw size={16} />
</button>
<label className="studio-browser-address" data-invalid={invalid ? "" : undefined}>
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="5.3" /><path d="M2.8 8h10.4M8 2.7c1.5 1.5 2.2 3.2 2.2 5.3S9.5 11.8 8 13.3C6.5 11.8 5.8 10.1 5.8 8S6.5 4.2 8 2.7Z" /></svg>
<Globe size={16} />
<input data-action-change="browser.address" data-action-keydown="browser.navigate" value={input} onKeyDown={commitAddress} onChange={(event) => { setInput(event.target.value); setInvalid(false); }} aria-label={t("workbench.web_address")} spellCheck={false} autoCapitalize="off" />
</label>
<button type="submit" className="studio-browser-go" aria-label={t("workbench.open_page")}>{t("common.open")}</button>
<button type="button" data-action="browser.external" onClick={() => !internal && onExternal(current)} disabled={internal} aria-label={t("workbench.open_in_external_browser")} title={t("workbench.open_in_external_browser")}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M9 3h4v4M13 3 7.5 8.5" /><path d="M7 4H3v9h9V9" /></svg>
<ExternalLink size={16} />
</button>
</form>
{invalid && <p className="studio-browser-error" role="alert">{t("workbench.enter_a_valid_http_or")}</p>}
<iframe
{internal ? <BrowserStartPage scheme={scheme} /> : <iframe
key={`${current}:${reload}`}
className="studio-browser-frame"
title={t("workbench.reasonix_built_in_browser")}
src={internal ? undefined : current}
srcDoc={internal ? srcDoc : undefined}
src={current}
sandbox="allow-scripts allow-forms allow-popups"
referrerPolicy="no-referrer"
onLoad={() => setLoading(false)}
onError={() => setLoading(false)}
/>
/>}
{/* Not a hint at an icon somewhere else: a site that refuses to be framed
shows the shell's own "refused to connect", and the way out has to be
beside it. */}
@@ -312,10 +305,10 @@ function AgentBrowserView({ tabs, shown, showTabs = true, onOpenDocument }: { ta
))}
</div>}
<div className="bnav">
<button className="bbtn" data-action="browser.control" data-target={current?.id} data-value="back" aria-label={t("workbench.back")} onClick={() => control("back")}>←</button>
<button className="bbtn" data-action="browser.control" data-target={current?.id} data-value="forward" aria-label={t("workbench.forward")} onClick={() => control("forward")}>→</button>
<button className="bbtn" data-action="browser.control" data-target={current?.id} data-value="reload" aria-label={t("common.reload")} onClick={() => control("reload")}>↻</button>
<button className="bbtn" data-action="browser.external" disabled={!documentPath && !/^https?:\/\//i.test(current?.url ?? "")} aria-label={documentPath ? t("workbench.open_this_file_with_the") : t("workbench.open_this_page_in_the")} title={documentPath ? t("workbench.open_this_file_with_the") : t("workbench.open_this_page_in_the")} onClick={() => (documentPath ? openDocument() : current && void host().openExternal(current.url))}>↗</button>
<button className="bbtn" data-action="browser.control" data-target={current?.id} data-value="back" aria-label={t("workbench.back")} title={t("workbench.back")} onClick={() => control("back")}><ArrowLeft size={16} /></button>
<button className="bbtn" data-action="browser.control" data-target={current?.id} data-value="forward" aria-label={t("workbench.forward")} title={t("workbench.forward")} onClick={() => control("forward")}><ArrowRight size={16} /></button>
<button className="bbtn" data-action="browser.control" data-target={current?.id} data-value="reload" aria-label={t("common.reload")} title={t("common.reload")} onClick={() => control("reload")}><RotateCw size={16} /></button>
<button className="bbtn" data-action="browser.external" disabled={!documentPath && !/^https?:\/\//i.test(current?.url ?? "")} aria-label={documentPath ? t("workbench.open_this_file_with_the") : t("workbench.open_this_page_in_the")} title={documentPath ? t("workbench.open_this_file_with_the") : t("workbench.open_this_page_in_the")} onClick={() => (documentPath ? openDocument() : current && void host().openExternal(current.url))}><ExternalLink size={16} /></button>
<form className="baddr" data-action="browser.navigate" data-target={current?.id} onSubmit={go}>
<input data-action="browser.navigate" data-target={current?.id} value={address} spellCheck={false} aria-label={t("workbench.address")} onChange={(event) => { setAddress(event.target.value); setRefused(""); }} />
</form>
@@ -1,3 +1,4 @@
import { StudioIcon } from "./StudioIcon";
import { useState } from "react";
import { t } from "../i18n";
import type { CapabilityScope } from "../port/port";
@@ -27,7 +28,7 @@ export function ScopePill({
<span className="scopeanchor">
<button type="button" className="spill who" data-action="ext.scope" aria-expanded={open} onClick={() => setOpen(!open)}>
{scope.label || scope.name}
<i className="cv" aria-hidden>▾</i>
<i className="cv" aria-hidden><StudioIcon name="down" /></i>
</button>
{open && (
<ScopePicker
@@ -10,16 +10,13 @@ import { OfficePreviewSection } from "./OfficePreviewSection";
import { PlanProgressSection } from "./PlanProgressSection";
import { PresentFiles } from "./PresentFiles";
import { SectionCard } from "./SectionCard";
import { SettingDeep } from "./SettingDeep";
export function ChatDisplaySettings({ port }: { port: AgentPort }) {
return (
<>
<SectionCard title={t("settings.process_card")}>
<FoldWorkSetting port={port} />
<SettingDeep id="folding" title={t("chat.transcript_folding")} hint={t("settings.older_turns_fold_to_a_line")} act={t("safety.details")}>
<section className="prefs-card"><Folding /></section>
</SettingDeep>
<Folding />
<PlanProgressSection />
</SectionCard>
<SectionCard title={t("settings.files_and_previews_card")}>
+6 -5
View File
@@ -1,3 +1,4 @@
import { StudioIcon } from "./StudioIcon";
import { useCallback, useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import { host as hostBridge } from "../port/host";
@@ -93,7 +94,7 @@ export function Chrome({ port, status, workspace: ws, title, steer, account, hos
disabled={!canOpenProject}
onClick={openProject}
>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.5 4.5h4l1.2 1.4h5.8v6.6h-11z" /></svg>
<StudioIcon name="folder" />
</button>
<span className="crumb-proj" title={root}>
{project}
@@ -126,7 +127,7 @@ export function Chrome({ port, status, workspace: ws, title, steer, account, hos
<div className="r" ref={group}>
{gathered && (
<button ref={trigger} className="thbtn r-more" data-action="chrome.more" aria-haspopup="true" aria-controls="chrome-fold" aria-expanded={more} aria-label={t("common.more")} onClick={() => setMore((v) => !v)}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8h.01M8 8h.01M12.5 8h.01" /></svg>
<StudioIcon name="more" />
</button>
)}
<div className="r-fold" id="chrome-fold" data-open={more ? "" : undefined}>
@@ -140,7 +141,7 @@ export function Chrome({ port, status, workspace: ws, title, steer, account, hos
aria-keyshortcuts={ariaChord("f")}
title={t("common.find_in_this_conversation")} shortcut={chord("F")}
>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M7.2 3.1a4.1 4.1 0 1 0 0 8.2 4.1 4.1 0 0 0 0-8.2M10.3 10.3 13 13" /></svg>
<StudioIcon name="search" />
</TooltipButton>
)}
{hub && onError && <PhonePop hub={hub} />}
@@ -152,9 +153,9 @@ export function Chrome({ port, status, workspace: ws, title, steer, account, hos
title={theme === "dark" ? t("app.switch_to_light_theme") : t("app.switch_to_dark_theme")}
>
{theme === "dark" ? (
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M12.9 9.7A5.3 5.3 0 0 1 6.3 3.1a5.3 5.3 0 1 0 6.6 6.6Z" /></svg>
<StudioIcon name="sun" />
) : (
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="2.7" /><path d="M8 1.7v1.4M8 12.9v1.4M1.7 8h1.4M12.9 8h1.4M3.5 3.5l1 1M11.5 11.5l1 1M3.5 12.5l1-1M11.5 4.5l1-1" /></svg>
<StudioIcon name="moon" />
)}
</TooltipButton>
{port && <ViewSwitch view={view} rows={rows} surfaces={surfaces} onView={onView} />}
+2 -1
View File
@@ -1,3 +1,4 @@
import { StudioIcon } from "./StudioIcon";
import { useCallback, useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import type { AgentPort, Completion, CompletionItem } from "../port/port";
@@ -291,7 +292,7 @@ export function CompletionMenu({ id, items, active, kind, query, kb, onPick, onH
{before}
{hit && <b className="hit">{hit}</b>}
{after}
{it.descend && <i className="ah" aria-hidden="true">›</i>}
{it.descend && <i className="ah" aria-hidden="true"><StudioIcon name="chevron" /></i>}
</span>
{it.hint && <span className="ds">{it.hint}</span>}
{it.args?.map((a) => (
@@ -63,10 +63,13 @@ describe("the composer's tool row on a phone", () => {
});
describe("the stop button", () => {
it("carries the board's pause mark as well as the square, and the stylesheet picks one", () => {
it.each(["", PHONE])("keeps the same stop and send symbols when the layout is %s", (fold) => {
document.documentElement.dataset.fold = fold;
const box = draw(true);
const stop = box.querySelector(".btn.stop")!;
expect(stop.querySelector(".ic-stop")).not.toBeNull();
expect(stop.querySelector(".ic-pause")).not.toBeNull();
const stop = screen.getByRole("button", { name: "停下" });
expect(stop.getAttribute("data-action")).toBe("session.stop");
expect(stop.querySelectorAll("svg")).toHaveLength(1);
expect(stop.querySelector(".lucide-square")).not.toBeNull();
expect(box.querySelector('.btn.send .lucide-arrow-up')).not.toBeNull();
});
});
+12 -12
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { createElement, useCallback, useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { ArrowUp } from "lucide-react";
import { useFolded } from "./usefold";
import { StopButton } from "./StopButton";
import { createPortal } from "react-dom";
@@ -27,7 +28,8 @@ import { useFitHeight } from "./fitHeight";
import { onComposerHintChange, showsComposerHint, useRunningMessageIntent, useSendShortcut } from "../state/prefs";
import { runningSendIntent, sendAria, sendHint, sendsOnKey } from "./sendkey";
import { countLines, pasteIsLong, planTone, planVerb } from "./intake";
import { kindOf, nameOf, previewURL } from "./chipfile";
import { nameOf, previewURL } from "./chipfile";
import { fileAppearance } from "./document/FileTypeIcon";
import { useIntake } from "./useIntake";
import type { Dropped } from "./filedrop";
import type { Quote } from "./cards/SayCard";
@@ -479,7 +481,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
<span aria-hidden="true" style={c.url ? { backgroundImage: `url(${c.url})` } : undefined} />
</ImagePreview>
) : (
<span className="glyph" aria-hidden="true">{kindOf(c.a?.path ?? c.name)}</span>
<span className="glyph" aria-hidden="true">{createElement(fileAppearance(c.a?.path ?? c.name).icon, { size: 20, strokeWidth: 1.8 })}</span>
)}
<span className="meta">
<span className="nm" title={c.a?.path ?? c.name}>{c.name}</span>
@@ -503,7 +505,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
</>
) : c.k === "session" ? (
<>
<span className="glyph" aria-hidden="true">{"#"}</span>
<span className="glyph" aria-hidden="true"><StudioIcon name="history" /></span>
<span className="meta">
<span className="nm" title={c.title}>{c.title}</span>
<span className="sz">{t("composer.past_conversation_read_only_reference")}</span>
@@ -511,7 +513,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
</>
) : (
<>
<span className="glyph" aria-hidden="true">{c.k === "quote" ? "❝" : "TXT"}</span>
<span className="glyph" aria-hidden="true"><StudioIcon name={c.k === "quote" ? "quote" : "file"} /></span>
<span className="meta">
<span className="nm" title={c.body}>{chipName(c)}</span>
<button
@@ -532,7 +534,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
aria-label={t("common.remove_name", { name: chipName(c) })}
onClick={() => discard(c)}
>
×
<StudioIcon name="close" />
</button>
</li>
))}
@@ -552,7 +554,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
{queueStrip}
<div className="fmain">
<span className="prompt" aria-hidden="true">
›
<StudioIcon name="chevron" />
</span>
<textarea
data-action-keydown="session.send" data-window-keys=""
@@ -709,7 +711,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
{ value: "plan", label: "Plan", desc: t("composer.plan_steps_before_changing_files") },
]}
onPick={(value) => change("plan", () => port.setPlanMode(value === "plan"))}
label={<><StudioIcon name="agent" /><span className="studio-sr-label">{t("common.plan")}</span><span>{status?.plan ? "Plan" : "Agent"}</span><StudioIcon name="down" /></>}
label={<><StudioIcon name={status?.plan ? "tasks" : "agent"} /><span className="studio-sr-label">{t("common.plan")}</span><span>{status?.plan ? "Plan" : "Agent"}</span><StudioIcon name="down" /></>}
/>
</div>
{git?.repo && (worktree.sources || status?.workspaceRoot) ? <WorktreeChip port={port} workspace={workspace} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} changeCount={countElsewhere ? 0 : changeCount} sources={worktree.sources ?? { available: false, sourceRoot: status?.workspaceRoot ?? "", sources: [] }} canCreate={fresh && !!worktree.sources?.available} selection={worktree.selection} branch={git.branch} busy={submitting || running} onSelect={worktree.setSelection} refresh={worktree.refresh} /> : <BranchChip port={port} git={git} changeCount={changeCount} countElsewhere={countElsewhere} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} />}
@@ -763,7 +765,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
</div>
{phone && (
<button type="button" className="mode plain turn-more" data-action="composer.more-tools" aria-expanded={toolsOpen} aria-label={t("composer.more_tools")} onClick={() => setToolsOpen((on) => !on)}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8h.01M8 8h.01M12.5 8h.01" /></svg>
<StudioIcon name="more" />
</button>
)}
</div>
@@ -780,9 +782,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
onClick={send}
>
<span className="ic" aria-hidden="true">
<svg viewBox="0 0 16 16">
<path d="M8 12.8V3.4M4.2 7.2 8 3.4l3.8 3.8" />
</svg>
<ArrowUp />
</span>
<span>{t(submitting ? "common.sending" : running ? runningIntent === "followup" ? "composer.queue" : "common.steer" : "common.send")}</span>
</button>
@@ -1,24 +1,21 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import "./testkit";
import { MockHub } from "../port/mock_hub";
import { MockPort } from "../port/mock";
import type { AgentPort } from "../port/port";
import { ConnectSettings } from "./ConnectSettings";
import { SettingDeepContext } from "./SettingDeep";
afterEach(cleanup);
function draw(remote: boolean) {
const go = vi.fn();
const port = new MockPort() as unknown as AgentPort;
render(
<SettingDeepContext.Provider value={{ pages: true, open: null, section: "连接", go }}>
<>
<ConnectSettings port={port} networkPort={port} host="" hub={new MockHub()} onError={() => {}} remote={remote} />
</SettingDeepContext.Provider>,
</>,
);
return go;
}
it("is a network card and an other-machines card of one row per setting", () => {
@@ -27,12 +24,10 @@ it("is a network card and an other-machines card of one row per setting", () =>
for (const id of ["network", "remote", "phone"]) expect(document.querySelector(`[data-setting="${id}"]`), id).toBeTruthy();
});
it("opens each page from its pill", () => {
const go = draw(true);
for (const [name, id] of [["详细设置", "network"], ["远程机器", "remote"], ["设置手机访问", "phone"]] as const) {
fireEvent.click(screen.getByRole("button", { name }));
expect(go).toHaveBeenLastCalledWith(id);
}
it("expands network and machine controls in their cards", async () => {
draw(true);
expect(await screen.findByRole("radiogroup", { name: "代理模式" })).toBeTruthy();
for (const id of ["remote", "phone"]) expect(document.querySelector(`#set-${id} .grp-items`)).toBeTruthy();
});
it("draws no machines card where remote access is not offered", () => {
@@ -23,16 +23,16 @@ export function ConnectSettings({ port, networkPort, host, hub, onError, remote
return (
<>
<SectionCard title={t("common.network")}>
<Group id="network" pill={t("safety.details")} title={t("common.network")} hint={t("settings.network_hint")}>
<Group id="network" title={t("common.network")} hint={t("settings.network_hint")}>
<Network port={networkPort} host={host} />
</Group>
</SectionCard>
{remote && (
<SectionCard title={t("settings.other_machines")}>
<Group id="remote" pill={t("settings.remote_machines")} title={t("settings.remote")} hint={t("settings.remote_hint")}>
<Group id="remote" title={t("settings.remote")} hint={t("settings.remote_hint")}>
<Remotes hub={hub} onError={onError} />
</Group>
<Group id="phone" pill={t("settings.set_up_phone_access")} title={t("remote.phone_access")} hint={t("settings.phone_hint")}>
<Group id="phone" title={t("remote.phone_access")} hint={t("settings.phone_hint")}>
<PhoneAccess hub={hub} onError={onError} />
<PhoneNotifications hub={hub} port={port} onError={onError} />
{remoteDeviceId() && <PhoneReminders host={remoteDeviceId()!} />}
@@ -1,3 +1,4 @@
import { StudioIcon } from "./StudioIcon";
import { useState } from "react";
import { t } from "../i18n";
import type { ContextBreakdown, McpEntry } from "../port/port";
@@ -39,7 +40,7 @@ export function ContextSummaryCard({ context, mcp, percent, onManage, compact: a
<div key={key}>
<i data-part={key} /><span>{label}</span>
{holdersOf(key).length > 0
? <button type="button" className="chrome-context-more" data-action="context.largest" data-value={key} aria-expanded={listed === key} aria-label={t("composer.show_the_largest_items_in", { name: label })} onClick={() => setListed(listed === key ? null : key)}>{compact(value)}</button>
? <button type="button" className="chrome-context-more" data-action="context.largest" data-value={key} aria-expanded={listed === key} aria-label={t("composer.show_the_largest_items_in", { name: label })} onClick={() => setListed(listed === key ? null : key)}>{compact(value)}<StudioIcon name="chevron" /></button>
: <b>{compact(value)}</b>}
</div>
))}</div>
@@ -1,25 +1,22 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import "./testkit";
import { MockHub } from "../port/mock_hub";
import { MockPort } from "../port/mock";
import type { AgentPort } from "../port/port";
import { DataSettings } from "./DataSettings";
import { SettingDeepContext } from "./SettingDeep";
afterEach(cleanup);
function draw(signedIn = false) {
const go = vi.fn();
const port = new MockPort() as unknown as AgentPort;
render(
<SettingDeepContext.Provider value={{ pages: true, open: null, section: "数据", go }}>
<>
<DataSettings port={port} homePort={port} localPort={port} hub={new MockHub()} workspace="/w" cwd="/w" busy="" signedIn={signedIn}
memories={7} onIsolate={() => {}} onChanged={() => {}} onRecovered={() => {}} />
</SettingDeepContext.Provider>,
onIsolate={() => {}} onChanged={() => {}} onRecovered={() => {}} />
</>,
);
return go;
}
it("is three cards: memory and usage, session storage, backup, each setting one row", async () => {
@@ -31,12 +28,13 @@ it("is three cards: memory and usage, session storage, backup, each setting one
expect(document.querySelector('[data-setting="backup"]')).toBeNull();
});
it("opens the memory, usage and storage pages from pills, with the memory count", () => {
const go = draw();
for (const [name, id] of [["打开记忆(7)", "memory"], ["查看用量", "usage"], ["管理", "storage"], ["导出与导入…", "backup-file"]] as const) {
fireEvent.click(screen.getByRole("button", { name }));
expect(go).toHaveBeenLastCalledWith(id);
}
it("expands memory, usage, storage and export controls together", async () => {
draw();
await waitFor(() => {
for (const id of ["memory", "usage", "storage", "backup-file"]) {
expect(document.querySelector(`#set-${id} .grp-items`), id).toBeTruthy();
}
});
});
it("offers the cloud backup only when signed in", () => {
@@ -21,17 +21,16 @@ interface Props {
cwd?: string;
busy: string;
signedIn: boolean;
memories?: number;
onIsolate: () => void;
onChanged: () => void;
onRecovered: () => void;
}
export function DataSettings({ port, homePort, localPort, hub, workspace, cwd, busy, signedIn, memories, onIsolate, onChanged, onRecovered }: Props) {
export function DataSettings({ port, homePort, localPort, hub, workspace, cwd, busy, signedIn, onIsolate, onChanged, onRecovered }: Props) {
return (
<>
<SectionCard title={t("data.memory_and_usage")} hint={t("data.what_the_agent_remembers_and")}>
<Group id="memory" pill={memories === undefined ? t("settings.open_memory") : `${t("settings.open_memory")}(${memories})`}
<Group id="memory"
title={t("data.memory")}
hint={t("settings.what_it_remembers")}
>
@@ -40,15 +39,15 @@ export function DataSettings({ port, homePort, localPort, hub, workspace, cwd, b
<UsageSettings port={homePort} onChanged={onChanged} />
</SectionCard>
<SectionCard title={t("data.session_storage")} hint={t("data.where_sessions_and_indexes_live")}>
<Group id="storage" pill={t("settings.manage_storage")} title={t("common.storage")} hint={t("settings.storage_hint")}><Storage port={homePort} hub={hub} workspace={workspace} onRecovered={onRecovered} /></Group>
<Group id="storage" title={t("common.storage")} hint={t("settings.storage_hint")}><Storage port={homePort} hub={hub} workspace={workspace} onRecovered={onRecovered} /></Group>
<AutoArchive port={port} />
<SessionDirSettings cwd={cwd} busy={busy} onIsolate={onIsolate} />
<LegacySessionsSection />
</SectionCard>
<SectionCard title={t("data.backup")} hint={t("data.take_your_configuration_to_another")}>
<Group id="backup-file" pill={t("settings.export_and_import")} title={t("data.export_and_import_config")} hint={t("settings.config_in_one_file")}><BackupFile port={port} /></Group>
<Group id="backup-file" title={t("data.export_and_import_config")} hint={t("settings.config_in_one_file")}><BackupFile port={port} /></Group>
{signedIn && (
<Group id="backup" pill={t("safety.details")} title={t("data.cloud_backup")} hint={t("settings.back_up_to_the_cloud")}>
<Group id="backup" title={t("data.cloud_backup")} hint={t("settings.back_up_to_the_cloud")}>
<Backup port={port} />
</Group>
)}
@@ -55,6 +55,7 @@ describe("how a connection is billed", () => {
it("says what the subscription figures mean", async () => {
await open(entry());
expect(screen.getByRole("option", { name: "订阅套餐(估算)" })).toBeTruthy();
expect(screen.getByText("订阅套餐的用量按上面的价格折算成估算值,单独显示,不会计入实际花费。")).toBeTruthy();
});
});
+7 -14
View File
@@ -1,3 +1,4 @@
import { StudioIcon } from "./StudioIcon";
import { useEffect, useRef } from "react";
import { t } from "../i18n";
import type { FindScope } from "../state/find";
@@ -89,9 +90,7 @@ function Bar({
role="search"
data-missed={missed ? "" : undefined}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M7.2 3.1a4.1 4.1 0 1 0 0 8.2 4.1 4.1 0 0 0 0-8.2M10.3 10.3 13 13" />
</svg>
<StudioIcon name="search" />
<input
ref={box}
type="search"
@@ -140,9 +139,7 @@ function Bar({
title={t("chat.previous_match")}
onClick={() => onStep(-1)}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M4.4 9.6 8 6l3.6 3.6" />
</svg>
<StudioIcon name="up" />
</button>
<button
className="st"
@@ -153,9 +150,7 @@ function Bar({
title={t("chat.next_match")}
onClick={() => onStep(1)}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M4.4 6.4 8 10l3.6-3.6" />
</svg>
<StudioIcon name="down" />
</button>
<span className="sep" aria-hidden="true" />
<button
@@ -167,7 +162,7 @@ function Bar({
title={t("common.match_case")}
onClick={() => setMode({ caseSensitive: !caseSensitive })}
>
Aa
<StudioIcon name="matchCase" />
</button>
<button
className="mode-toggle"
@@ -178,7 +173,7 @@ function Bar({
title={t("chat.read_the_query_as_a")}
onClick={() => setMode({ regex: !regex })}
>
.*
<StudioIcon name="regex" />
</button>
<button
className="st"
@@ -188,9 +183,7 @@ function Bar({
title={t("chat.close_find")}
onClick={onClose}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M4.5 4.5 11.5 11.5M11.5 4.5 4.5 11.5" />
</svg>
<StudioIcon name="close" />
</button>
</div>
{missed ? (
-10
View File
@@ -1,10 +0,0 @@
// Settings, drawn as a gear. A circle with rays is the sun, which is what the
// light-or-dark control and the appearance page already are.
export const GEAR_PATH = "M6.36 3.28 L6.71 1.53 L9.29 1.53 L9.64 3.28 L10.18 3.50 L11.67 2.51 L13.49 4.33 L12.50 5.82 L12.72 6.36 L14.47 6.71 L14.47 9.29 L12.72 9.64 L12.50 10.18 L13.49 11.67 L11.67 13.49 L10.18 12.50 L9.64 12.72 L9.29 14.47 L6.71 14.47 L6.36 12.72 L5.82 12.50 L4.33 13.49 L2.51 11.67 L3.50 10.18 L3.28 9.64 L1.53 9.29 L1.53 6.71 L3.28 6.36 L3.50 5.82 L2.51 4.33 L4.33 2.51 L5.82 3.50Z";
export const GearMark = (
<>
<path d={GEAR_PATH} />
<circle cx="8" cy="8" r="2" />
</>
);
+3 -6
View File
@@ -1,22 +1,20 @@
import { t } from "../i18n";
import { SETTING_AT, type SettingScope } from "./prefsnav";
import type { ReactNode } from "react";
import { SettingDeep } from "./SettingDeep";
// id is required, and that is the whole enforcement: a settings block cannot
// be written without naming itself, and a name the catalogue does not carry
// fails the gate. So "which setting is this, and when does changing it take
// effect" has no default and no way to be left unanswered.
export function Group({
id, title, hint, now, action, pill, inline, children,
id, title, hint, now, action, inline, children,
}: {
id: string; title: string; hint?: string; now?: string; action?: ReactNode; pill?: string; inline?: boolean; children?: ReactNode;
id: string; title: string; hint?: string; now?: string; action?: ReactNode; inline?: boolean; children?: ReactNode;
}) {
return (
<SettingDeep id={id} title={title} now={now} hint={hint} act={pill}>
<section className="grp" id={`set-${id}`} data-setting={id} data-inline={inline ? "" : undefined}>
<div className="grp-hd">
<h3>{title}</h3>
<h4>{title}</h4>
<ScopeTag id={id} />
{now && <span className="now">{now}</span>}
{action}
@@ -28,7 +26,6 @@ export function Group({
as three alarms. */}
<ApplyNote id={id} />
</section>
</SettingDeep>
);
}
@@ -129,7 +129,7 @@ function ImageViewer({
title={t("chat.zoom_in")}
onClick={() => zoom(1.25)}
>
<StudioIcon name="plus" />
<StudioIcon name="zoomIn" />
</button>
<button
type="button"
@@ -139,7 +139,7 @@ function ImageViewer({
title={t("chat.zoom_out")}
onClick={() => zoom(0.8)}
>
<StudioIcon name="minus" />
<StudioIcon name="zoomOut" />
</button>
<button
type="button"
+1 -1
View File
@@ -29,7 +29,7 @@ const KEYS: Array<[string, string]> = [
export function KeysSheet() {
return (
<Group
id="keys" pill={t("settings.view_and_edit")}
id="keys"
title={t("settings.keyboard_shortcuts")}
hint={t("settings.all_shortcuts_incl_global_and_esc")}
>
@@ -1,31 +1,29 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import "./testkit";
import { MockPort } from "../port/mock";
import type { AgentPort } from "../port/port";
import { Appearance } from "./Appearance";
import { ChatDisplaySettings } from "./ChatDisplaySettings";
import { OpsSettings } from "./OpsSettings";
import { SettingDeepContext } from "./SettingDeep";
afterEach(cleanup);
const frame = (node: React.ReactNode, go = vi.fn()) =>
render(<SettingDeepContext.Provider value={{ pages: true, open: null, section: "x", go }}>{node}</SettingDeepContext.Provider>);
const frame = (node: React.ReactNode) =>
render(<>{node}</>);
const titles = () => [...document.querySelectorAll(".prefs-card > header h3")].map((h) => h.textContent);
const has = (id: string) => !!document.querySelector(`[data-setting="${id}"]`);
it("appearance is theme, text and window cards: pills for what is an editor, rows for what is a value", async () => {
const go = vi.fn();
it("expands appearance editors in theme, text and window cards", async () => {
const port = new MockPort() as unknown as AgentPort;
frame(<Appearance port={port} theme="auto" onTheme={() => {}} contrast="" onContrast={() => {}} weight="" onWeight={() => {}}
reloadThemes={() => {}} look={{}} onLook={() => {}} />, go);
reloadThemes={() => {}} look={{}} onLook={() => {}} />);
expect(titles().slice(0, 3)).toEqual(["主题", "文字", "窗口"]);
for (const id of ["mode", "scheme", "wallpaper", "effects", "font", "size", "weight", "contrast", "window"]) await waitFor(() => expect(has(id), id).toBe(true));
expect(has("advanced-appearance")).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "选择壁纸" }));
expect(go).toHaveBeenLastCalledWith("wallpaper");
expect(screen.getByRole("combobox", { name: "界面" })).toBeTruthy();
expect(screen.getByRole("slider", { name: "界面大小微调" })).toBeTruthy();
});
it("conversation display is process, files and previews, and notices", async () => {
@@ -34,6 +34,8 @@ describe("market votes", () => {
const port = portWith({ signedIn: true, value: 1, upCount: 3, downCount: 1, approvalRate: 0.75, canVote: true });
render(<MarketVote port={port} pkg={pkg} />);
await waitFor(() => expect(up().getAttribute("aria-pressed")).toBe("true"));
expect(up().querySelector("svg.lucide-thumbs-up[aria-hidden=true]")).not.toBeNull();
expect(screen.getByRole("button", { name: /踩/ }).querySelector("svg.lucide-thumbs-down")).not.toBeNull();
await userEvent.click(up());
expect(port.voteMarket).toHaveBeenLastCalledWith("acme/kit", 0);
await waitFor(() => expect(up().getAttribute("aria-pressed")).toBe("false"));
+3 -2
View File
@@ -1,3 +1,4 @@
import { StudioIcon } from "./StudioIcon";
import { useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import { ACCOUNT_SIGNIN_DISABLED, reason } from "../i18n/kernel";
@@ -93,7 +94,7 @@ export function MarketVote({ port, pkg, onSignIn }: { port: AgentPort; pkg: Mark
title={mine === 1 ? t("ext.withdraw_upvote") : t("ext.upvote")}
onClick={() => void cast(1)}
>
<span aria-hidden="true">▲</span> {t("ext.upvote")} <span className="n">{up}</span>
<span aria-hidden="true"><StudioIcon name="thumbsUp" /></span> {t("ext.upvote")} <span className="n">{up}</span>
</button>
<button
className="act"
@@ -104,7 +105,7 @@ export function MarketVote({ port, pkg, onSignIn }: { port: AgentPort; pkg: Mark
title={mine === -1 ? t("ext.withdraw_downvote") : t("ext.downvote")}
onClick={() => void cast(-1)}
>
<span aria-hidden="true">▼</span> {t("ext.downvote")} <span className="n">{down}</span>
<span aria-hidden="true"><StudioIcon name="thumbsDown" /></span> {t("ext.downvote")} <span className="n">{down}</span>
</button>
</div>
{vote && !signedIn && !off && (
@@ -73,7 +73,7 @@ export function MemoryDetail({ m, edit, setEdit, busy, galaxies, onSave, onConfi
</Button>
<Button variant="ghost" data-action="memory.cancel" data-target={m.name} onClick={() => setEdit(null)}>{t("common.cancel")}</Button>
<Button variant="ghost" data-action="memory.forget" data-target={m.name} disabled={busy} onClick={onForget}>
{t(busy ? "…" : "common.archived_2")}
{busy && <Icon name="loading" size={15} />}{t("common.archived_2")}
</Button>
<span className="hint">{t("memory.saving_records_a_new_revision")}</span>
</div>
@@ -120,8 +120,8 @@ export function MemoryDetail({ m, edit, setEdit, busy, galaxies, onSave, onConfi
<Icon name="edit" size={13} />{t("common.edit")}
</Button>
<Button variant="ghost" iconOnly data-action="memory.forget" data-target={m.name} disabled={busy} onClick={onForget}
aria-label={t(busy ? "…" : "common.archived_2")} title={t("common.archived_2")}>
<Icon name="archive" size={15} />
aria-busy={busy} aria-label={t("common.archived_2")} title={t("common.archived_2")}>
<Icon name={busy ? "loading" : "archive"} size={15} />
</Button>
</div>
{m.usedLastTurn && m.why && <p className="why-used">{t("memory.surfaced_last_turn_because_of", { why: m.why })}</p>}
@@ -161,7 +161,7 @@ export function History({ list, current, busy, onRestore }: {
<span className="at">{m.updatedAt || m.createdAt}</span>
<span className="ds" title={m.title}>{m.title}</span>
<Button variant="ghost" data-action="memory.restore" data-target={m.name} disabled={busy} onClick={() => onRestore(m.revision ?? 1)}>
{t(busy ? "…" : "memory.restore_this_one")}
{busy && <Icon name="loading" size={15} />}{t("memory.restore_this_one")}
</Button>
<pre>{clipBody(m.body)}</pre>
</div>
+8 -6
View File
@@ -1,3 +1,4 @@
import { StudioIcon } from "./StudioIcon";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { t } from "../i18n";
@@ -35,7 +36,7 @@ export interface MenuItem {
header?: boolean;
disabled?: boolean;
// A letter chip leading the row or heading, standing in for a vendor mark.
mono?: { letter: string; tone: number };
providerIcon?: { tone: number };
// A vendor slug; when artwork exists for it, it replaces the letter chip.
mark?: string;
// A switch rather than a choice: picking it flips this state, and the row
@@ -333,7 +334,7 @@ export function MenuRow({ it, id, current, lead, onTake }: {
data-recommended={it.recommended ? "" : undefined}
onClick={() => onTake(it.value)}
>
{it.mono || hasVendorMark(it.mark) ? <Lead it={it} /> : <span className="dot" />}
{it.providerIcon || hasVendorMark(it.mark) ? <Lead it={it} /> : <span className="dot" />}
<span className="tx">
<span className="studio-item-line">
<span className="lb">{it.label}</span>
@@ -344,7 +345,7 @@ export function MenuRow({ it, id, current, lead, onTake }: {
</span>
{it.strength != null && (
<span className="studio-effort-strength" aria-hidden="true">
{[1, 2, 3, 4].map((level) => <i key={level} data-on={level <= (it.strength ?? 0) ? "" : undefined} />)}
{it.recommended ? <StudioIcon name="spark" /> : [1, 2, 3, 4].map((level) => <i key={level} data-on={level <= (it.strength ?? 0) ? "" : undefined} />)}
</span>
)}
{it.toggle !== undefined && <span className="studio-item-switch" aria-hidden="true" />}
@@ -353,17 +354,18 @@ export function MenuRow({ it, id, current, lead, onTake }: {
{it.status.tone === "warning" ? <CircleAlert aria-hidden="true" /> : it.status.tone === "checking" ? <LoaderCircle aria-hidden="true" /> : <CircleDot aria-hidden="true" />}
{it.status.label}
</span>}
{it.value === current && <StudioIcon name="check" className="menu-current" />}
</button>
);
}
function Lead({ it }: { it: MenuItem }) {
if (hasVendorMark(it.mark)) return <VendorMark slug={it.mark} />;
return it.mono ? <Mono {...it.mono} /> : null;
return it.providerIcon ? <ProviderIcon {...it.providerIcon} /> : null;
}
function Mono({ letter, tone }: { letter: string; tone: number }) {
return <i className="mi-mono" data-tone={tone} style={{ "--tone": `var(--cat-${tone + 1})` } as React.CSSProperties} aria-hidden="true">{letter}</i>;
function ProviderIcon({ tone }: { tone: number }) {
return <i className="mi-mono" data-tone={tone} style={{ "--tone": `var(--cat-${tone + 1})` } as React.CSSProperties} aria-hidden="true"><StudioIcon name="server" /></i>;
}
// Group order is kept rather than ranked by relevance: a list that reorders
+8 -7
View File
@@ -1,3 +1,4 @@
import { StudioIcon, type StudioIconName } from "./StudioIcon";
import { useEffect, useState, type KeyboardEvent } from "react";
import { t } from "../i18n";
import { reason, say } from "../i18n/kernel";
@@ -10,12 +11,12 @@ import { MODS_SHOWN } from "./modsgate";
const BUDGET_MAX = 55000;
// The words and the mark for each state: a state never rests on colour alone.
const STATE: Record<ModState, { mark: string; word: string }> = {
inactive: { mark: "○", word: "ext.not_running" },
starting: { mark: "◐", word: "ext.starting" },
running: { mark: "●", word: "common.running" },
restarting: { mark: "↻", word: "ext.restarting" },
disabled: { mark: "⊘", word: "common.suspended" },
const STATE: Record<ModState, { icon: StudioIconName; word: string }> = {
inactive: { icon: "inactive", word: "ext.not_running" },
starting: { icon: "loading", word: "ext.starting" },
running: { icon: "running", word: "common.running" },
restarting: { icon: "refresh", word: "ext.restarting" },
disabled: { icon: "disabled", word: "common.suspended" },
};
/** Claude Code mods: modules an installed package ships, run in a process of
@@ -104,7 +105,7 @@ function ModHostBlock({ port }: { port: AgentPort }) {
<li key={m.plugin} data-state={m.state}>
<div className="modhead">
<b>{m.plugin}</b>
<span className="modstate"><span aria-hidden="true">{STATE[m.state].mark}</span> {t(STATE[m.state].word)}</span>
<span className="modstate"><StudioIcon name={STATE[m.state].icon} /> {t(STATE[m.state].word)}</span>
</div>
{m.code && <div className="ds">{say({ code: m.code, error: m.detail })}</div>}
{m.restarts > 0 && <div className="ds">{t("ext.restarted_n_times", { n: m.restarts })}</div>}
+2 -1
View File
@@ -1,3 +1,4 @@
import { StudioIcon } from "./StudioIcon";
import { useMemo, useState } from "react";
import { t } from "../i18n";
import type { ProviderModelCheck, ProviderModelCheckReason, ProviderModelCheckStatus } from "../port/port";
@@ -181,7 +182,7 @@ export function ModelChoice({
real matches is how you add a model when you meant to pick one. */}
{naming && (
<button className="mnew" onClick={add}>
<span className="k">+</span>
<span className="k"><StudioIcon name="plus" /></span>
<span className="tx">
<span className="lb">{t("composer.add_unlisted_model_name", { name: q.trim() })}</span>
<span className="ds">{t("composer.saves_the_exact_model_id")}</span>
@@ -1,45 +1,26 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import "./testkit";
import { ModelSettings } from "./ModelSettings";
import { SettingDeepContext } from "./SettingDeep";
import { MockPort } from "../port/mock";
import type { AgentPort } from "../port/port";
afterEach(cleanup);
const entry = (name: string, baseUrl: string) => ({ name, baseUrl, kind: "openai", keyEnv: name.toUpperCase() });
function draw(go = vi.fn()) {
const port = {
providers: async () => [entry("a", "https://api.one.test/v1"), entry("b", "https://api.two.test/v1")],
compaction: async () => ({ context_window: 128000, ratio: 0.9, trigger: 115200 }),
providerHealthSettings: async () => ({ enabled: false }),
saveProviderHealthSettings: async (enabled: boolean) => ({ enabled }),
} as unknown as AgentPort;
render(
<SettingDeepContext.Provider value={{ pages: true, open: null, section: "模型", go }}>
<ModelSettings port={port} onChanged={() => {}} onFailed={() => {}}
usage={{} as never} providers={{} as never} />
</SettingDeepContext.Provider>,
);
return go;
}
it("is two cards of four rows: services and health, uses and context", async () => {
draw();
it("expands services, uses and context controls in two full cards", async () => {
const port = new MockPort() as unknown as AgentPort;
const models = await port.models();
const onChanged = vi.fn();
const onFailed = vi.fn();
render(<ModelSettings port={port} onChanged={onChanged} onFailed={onFailed}
usage={{ models, roles: null, busy: "", protocol: {}, onMain: vi.fn(), onRole: vi.fn() }}
providers={{ port, onChanged, onFailed, protocol: {}, onProtocol: vi.fn(), activeKindFor: (account) => account.kinds[0] }} />);
expect(screen.getByRole("heading", { name: "服务" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "用途与上下文" })).toBeTruthy();
expect(document.querySelectorAll(".prefs-card > .srow, .prefs-card > [data-setting]")).toHaveLength(4);
await waitFor(() => expect(screen.getByText("已接入 2 个服务;地址、密钥、模型列表在这里改")).toBeTruthy());
expect(screen.getByText("上下文到多少开始压缩;默认窗口的 90%")).toBeTruthy();
});
it("opens each page from its row's pill", async () => {
const go = draw();
await waitFor(() => expect(screen.getByRole("button", { name: "90%" })).toBeTruthy());
for (const [name, id] of [["管理服务", "providers"], ["按用途设置", "model"], ["90%", "context"]] as const) {
fireEvent.click(screen.getByRole("button", { name }));
expect(go).toHaveBeenLastCalledWith(id);
}
await waitFor(() => {
expect(document.querySelector('#set-providers button')).toBeTruthy();
expect(document.querySelector('#set-model select')).toBeTruthy();
expect(document.querySelector('#set-context .cmp')).toBeTruthy();
});
});
+13 -23
View File
@@ -1,12 +1,12 @@
import { useEffect, useState, type ComponentProps } from "react";
import type { ComponentProps } from "react";
import { t } from "../i18n";
import type { AgentPort, CompactionSettings } from "../port/port";
import type { AgentPort } from "../port/port";
import { Compaction } from "./Compaction";
import { Group } from "./Group";
import { ModelUsage } from "./ModelUsage";
import { ProviderHealthSetting } from "./ProviderHealthSetting";
import { Providers, groupAccounts } from "./Providers";
import { Providers } from "./Providers";
import { SectionCard } from "./SectionCard";
import { SettingDeep } from "./SettingDeep";
interface Props {
port: AgentPort;
@@ -17,31 +17,21 @@ interface Props {
}
export function ModelSettings({ port, usage, providers, onChanged, onFailed }: Props) {
const [services, setServices] = useState<number | null>(null);
const [compaction, setCompaction] = useState<CompactionSettings | null>(null);
useEffect(() => {
port.providers().then((list) => setServices(groupAccounts(list).length)).catch(() => setServices(null));
port.compaction().then(setCompaction).catch(() => setCompaction(null));
}, [port]);
const share = compaction && compaction.context_window > 0 ? Math.round(compaction.ratio * 100) : null;
return (
<>
<SectionCard title={t("models.services")}>
<SettingDeep id="providers" title={t("common.model_services")} act={t("models.manage_services")}
hint={services === null ? undefined : t("models.services_connected_n", { n: services })}>
<section className="prefs-card"><Providers {...providers} /></section>
</SettingDeep>
<Group id="providers" title={t("common.model_services")}>
<Providers {...providers} />
</Group>
<ProviderHealthSetting port={port} onChanged={onChanged} onFailed={onFailed} />
</SectionCard>
<SectionCard title={t("models.uses_and_context")}>
<SettingDeep id="model" title={t("settings.models_by_use")} act={t("models.set_by_use")}
hint={t("models.which_model_for_each_use")}>
<section className="prefs-card"><ModelUsage {...usage} /></section>
</SettingDeep>
<SettingDeep id="context" title={t("settings.context_maintenance")} act={share === null ? "—" : `${share}%`}
hint={share === null ? undefined : t("models.compacts_from_this_share", { p: share })}>
<section className="prefs-card"><Compaction port={port} /></section>
</SettingDeep>
<Group id="model" title={t("settings.models_by_use")} hint={t("models.which_model_for_each_use")}>
<ModelUsage {...usage} />
</Group>
<Group id="context" title={t("settings.context_maintenance")}>
<Compaction port={port} />
</Group>
</SectionCard>
</>
);
@@ -54,17 +54,17 @@ export function MoveSessionDialog({ session, label, projects, busy, error, note,
<section data-part="travels">
<h3>{t("session.travels")}</h3>
<ul>
<li>{t("session.the_conversation_itself")}</li>
<li>{t("session.the_title_pin_and_archive")}</li>
<li>{t("session.paths_recorded_in_the_conversation")}</li>
<li><StudioIcon name="check" />{t("session.the_conversation_itself")}</li>
<li><StudioIcon name="check" />{t("session.the_title_pin_and_archive")}</li>
<li><StudioIcon name="check" />{t("session.paths_recorded_in_the_conversation")}</li>
</ul>
</section>
<section data-part="stays">
<h3>{t("session.stays_behind")}</h3>
<ul>
<li>{t("session.checkpoints_rewind_snapshots")}</li>
<li>{t("session.background_task_logs")}</li>
<li>{t("session.queued_messages")}</li>
<li><StudioIcon name="close" />{t("session.checkpoints_rewind_snapshots")}</li>
<li><StudioIcon name="close" />{t("session.background_task_logs")}</li>
<li><StudioIcon name="close" />{t("session.queued_messages")}</li>
</ul>
</section>
</div>
@@ -8,7 +8,7 @@ const props = { at: null, onGo: vi.fn(), onFiles: vi.fn(), onHome: vi.fn(), onRa
afterEach(cleanup);
const sectionMark = (section: Section) => render(<svg>{ICON[section]}</svg>).container.querySelector("svg")?.innerHTML;
const sectionMark = (section: Section) => render(<>{ICON[section]}</>).container.querySelector("svg")?.innerHTML;
describe("the icon rail draws the mark of the section it opens", () => {
it.each([
@@ -18,7 +18,7 @@ describe("the icon rail draws the mark of the section it opens", () => {
["记忆", "memory"],
["远程", "remote"],
["账号", "account"],
["设置", "advanced"],
["设置", "common"],
] as const)("%s shows the %s section mark", (label, section) => {
const { container } = render(<Nav {...props} />);
const btn = container.querySelector(`button[aria-label="${label}"]`);
+1 -6
View File
@@ -5,9 +5,6 @@ import { ICON } from "./prefsnav";
import { StudioIcon } from "./StudioIcon";
import { UpdateDot } from "./UpdateDot";
// Settings entries share their destination's mark with prefsnav.
const CHAT = "M3.2 4.4a1.2 1.2 0 0 1 1.2-1.2h7.2a1.2 1.2 0 0 1 1.2 1.2v5a1.2 1.2 0 0 1-1.2 1.2H7.2L4.4 13v-2.4a1.2 1.2 0 0 1-1.2-1.2Z";
// Every entry goes somewhere that exists. A rail of icons that open nothing is
// the cheapest thing to draw and the most expensive thing to keep.
const HOME: [Mark, string, string | null] = ["chat", "common.session", null];
@@ -46,9 +43,7 @@ export function Nav({ at, onGo, onFiles, onHome, onRail, onFeedback, onNew, feed
aria-current={(sec ?? null) === at ? "page" : undefined}
onClick={() => (key === "files" ? onFiles() : sec === null ? (at === null ? onRail() : onHome()) : onGo(key === "ext" ? "extensions" : key === "usage" ? "spend" : sec || undefined))}
>
{key === "files" ? <StudioIcon name="file" aria-hidden="true" /> : <svg viewBox="0 0 16 16" aria-hidden="true">
{key === "chat" ? <path d={CHAT} /> : ICON[key]}
</svg>}
{key === "files" ? <StudioIcon name="file" /> : key === "chat" ? <StudioIcon name="chat" /> : ICON[key === "advanced" ? "common" : key]}
{key === "advanced" && <UpdateDot />}
<span className="navhint" role="tooltip">{t(label)}</span>
</button>
@@ -1,3 +1,4 @@
import { StudioIcon } from "./StudioIcon";
import { useSyncExternalStore } from "react";
import { t } from "../i18n";
import { navLayout, onNavLayoutChange, ordered, resetNavLayout, setNavLayout } from "../state/navEntries";
@@ -21,14 +22,14 @@ export function NavEntriesSection() {
};
const changed = layout.order.length > 0 || layout.hidden.length > 0;
return (
<Group id="naventries" pill={t("settings.adjust_navigation")} title={t("settings.navigation_entries")} hint={t("settings.which_entries_the_left_rail_shows")}>
<Group id="naventries" title={t("settings.navigation_entries")} hint={t("settings.which_entries_the_left_rail_shows")}>
{list.map((key, i) => (
<div className="lrow" key={key}>
<span className="tx">
<span className="lb">{t(NAMES.get(key)!)}</span>
</span>
<button className="btn sm" data-action="appearance.nav-entry-move" data-value={`${key}:up`} disabled={i === 0} aria-label={t("settings.move_up_name", { name: t(NAMES.get(key)!) })} onClick={() => move(key, -1)}>↑</button>
<button className="btn sm" data-action="appearance.nav-entry-move" data-value={`${key}:down`} disabled={i === list.length - 1} aria-label={t("settings.move_down_name", { name: t(NAMES.get(key)!) })} onClick={() => move(key, 1)}>↓</button>
<button className="btn sm" data-action="appearance.nav-entry-move" data-value={`${key}:up`} disabled={i === 0} aria-label={t("settings.move_up_name", { name: t(NAMES.get(key)!) })} onClick={() => move(key, -1)}><StudioIcon name="arrow" /></button>
<button className="btn sm" data-action="appearance.nav-entry-move" data-value={`${key}:down`} disabled={i === list.length - 1} aria-label={t("settings.move_down_name", { name: t(NAMES.get(key)!) })} onClick={() => move(key, 1)}><StudioIcon name="arrowDown" /></button>
<Switch data-action="appearance.nav-entry-visible" data-value={key} on={!hidden.has(key)} label={t("settings.show_name", { name: t(NAMES.get(key)!) })} onClick={() => toggle(key)} />
</div>
))}
+2 -1
View File
@@ -1,3 +1,4 @@
import { StudioIcon } from "./StudioIcon";
import { useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import type { AgentPort, NetworkProbe, NetworkSettings } from "../port/port";
@@ -203,7 +204,7 @@ function NetworkInput({ port, host }: { port: AgentPort; host: string }) {
<div className="probes" role="region" aria-label={t("models.test_results_for_saved_settings")}>
{probes.map((p, i) => (
<div className="probe" key={i} data-ok={p.ok ? "" : undefined}>
<i className="mk">{p.ok ? "✓" : "×"}</i>
<i className="mk"><StudioIcon name={p.ok ? "check" : "close"} /></i>
<span className="lb">{STEP_LABEL[p.step] ? t(STEP_LABEL[p.step]) : p.step}</span>
<span className="dt">{say(p.code ? { code: p.code, params: p.params } : null, p.detail)}</span>
<span className="ms">{p.durationMs ? `${p.durationMs}ms` : ""}</span>
@@ -18,11 +18,6 @@ function fakePort(answer: NotifyPrefs | null) {
return { port: { notifyPrefs: async () => held, setNotifyPrefs: set } as unknown as AgentPort, set };
}
const expand = async (view: ReturnType<typeof render>) => {
await waitFor(() => expect(view.container.querySelector('[data-action="notify.more"]')).not.toBeNull());
(view.container.querySelector('[data-action="notify.more"]') as HTMLElement).click();
};
const box = async (answer: NotifyPrefs | null) => {
const { port, set } = fakePort(answer);
const view = render(<Notifications port={port} />);
@@ -38,7 +33,6 @@ describe("the notifications block", () => {
it("keeps the five occasions as branches of the one switch", async () => {
const { view } = await box(OWED);
await expand(view);
await waitFor(() => expect(view.container.querySelector('[data-setting="notify"]')).not.toBeNull());
const branches = [...view.container.querySelectorAll(".lrow.subrow")];
expect(branches.length).toBe(5);
@@ -49,7 +43,6 @@ describe("the notifications block", () => {
it("writes what the person asked for and keeps what the host answered", async () => {
const { view, set } = await box(OWED);
await expand(view);
await waitFor(() => expect(view.container.querySelector('[data-action="notify.enabled"]')).not.toBeNull());
(view.container.querySelector('[data-action="notify.enabled"]') as HTMLElement).click();
await waitFor(() => expect(set).toHaveBeenCalledWith({ ...OWED, enabled: true }));
@@ -58,7 +51,6 @@ describe("the notifications block", () => {
it("writes the feedback switch without touching the others", async () => {
const { view, set } = await box({ ...OWED, enabled: true });
await expand(view);
await waitFor(() => expect(view.container.querySelector('[data-action="notify.feedback-reply"]')).not.toBeNull());
(view.container.querySelector('[data-action="notify.feedback-reply"]') as HTMLElement).click();
await waitFor(() => expect(set).toHaveBeenCalledWith({ ...OWED, enabled: true, feedbackReply: false }));
@@ -66,7 +58,6 @@ describe("the notifications block", () => {
it("writes the disk-space switch without touching the others", async () => {
const { view, set } = await box({ ...OWED, enabled: true });
await expand(view);
await waitFor(() => expect(view.container.querySelector('[data-action="notify.disk-low"]')).not.toBeNull());
expect(view.container.querySelector('[data-action="notify.disk-low"]')?.getAttribute("aria-checked")).toBe("true");
(view.container.querySelector('[data-action="notify.disk-low"]') as HTMLElement).click();
@@ -75,7 +66,6 @@ describe("the notifications block", () => {
it("offers the chime as a switch of its own, off by default, that wakes the branches without the toast", async () => {
const { view, set } = await box(OWED);
await expand(view);
await waitFor(() => expect(view.container.querySelector('[data-action="notify.sound"]')).not.toBeNull());
expect(view.container.querySelector('[data-action="notify.sound"]')?.getAttribute("aria-checked")).toBe("false");
(view.container.querySelector('[data-action="notify.sound"]') as HTMLElement).click();
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import type { AgentPort, NotifyPrefs } from "../port/port";
import { t } from "../i18n";
import { PillButton, SettingRow } from "./SettingRow";
import { SettingRow } from "./SettingRow";
import { Switch } from "./Switch";
/** What this machine is allowed to interrupt for. A kernel with no window
@@ -9,7 +9,6 @@ import { Switch } from "./Switch";
* land on the server's desktop, not on the watcher's. */
export function Notifications({ port }: { port: AgentPort }) {
const [prefs, setPrefs] = useState<NotifyPrefs | null>(null);
const [more, setMore] = useState(false);
useEffect(() => {
let live = true;
@@ -36,10 +35,8 @@ export function Notifications({ port }: { port: AgentPort }) {
return (
<>
<SettingRow id="notify" title={t("common.notifications")} desc={t("settings.a_system_notification_when_a")}>
<PillButton action="notify.more" onClick={() => setMore(!more)}>{t(more ? "common.collapse" : "settings.details")}</PillButton>
<Switch data-action="notify.enabled" on={prefs.enabled} label={t("settings.send_system_notifications")} onClick={() => flip({ enabled: !prefs.enabled })} />
</SettingRow>
{more && (
<div className="srow-more">
<div className="lrow">
<span className="tx">
@@ -86,7 +83,6 @@ export function Notifications({ port }: { port: AgentPort }) {
<Switch data-action="notify.disk-low" on={prefs.diskLow} busy={!active} label={t("settings.when_disk_space_is_low")} onClick={() => flip({ diskLow: !prefs.diskLow })} />
</div>
</div>
)}
</>
);
}
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff. Show more