mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-09 01:59:43 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
513d19db20 | ||
|
|
f396db7c20 | ||
|
|
49e64b6d13 | ||
|
|
bccfb5d684 | ||
|
|
bd898c3f30 | ||
|
|
29230c68bf | ||
|
|
c12faae30a | ||
|
|
44e3a19f8d | ||
|
|
1738c499df | ||
|
|
9ee11c6b1f |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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; }
|
||||
@@ -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); }
|
||||
|
||||
@@ -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,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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
};
|
||||
@@ -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." },
|
||||
};
|
||||
@@ -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" },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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.
|
||||
@@ -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;/);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
@@ -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"
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}"]`);
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
Reference in new issue
Block a user