mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge branch 'studio' into codex/goal-usage-delegation-scope-14025
This commit is contained in:
259 files changed
+9452
-562
No files matched your search
@@ -700,6 +700,38 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows explicit skill root budget)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^(TestProfileRootBudgetLifetime|TestProfileBudgetPreparationUsesTurnAdmission|TestEffectExplicitSkillRootBudget)$' ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/explicit-skill-root-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/session/control", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["TestProfileRootBudgetLifetime", "TestProfileRootBudgetLifetime/new_task",
|
||||
"TestProfileRootBudgetLifetime/same_scope", "TestProfileRootBudgetLifetime/new_scope",
|
||||
"TestProfileRootBudgetLifetime/reset", "TestProfileRootBudgetLifetime/new_session",
|
||||
"TestProfileRootBudgetLifetime/inherited", "TestProfileRootBudgetLifetime/context_zero",
|
||||
"TestProfileBudgetPreparationUsesTurnAdmission", "TestEffectExplicitSkillRootBudget",
|
||||
"TestEffectExplicitSkillRootBudget/profile", "TestEffectExplicitSkillRootBudget/preview",
|
||||
"TestEffectExplicitSkillRootBudget/slash", "TestEffectExplicitSkillRootBudget/chips",
|
||||
"TestEffectExplicitSkillRootBudget/goal", "TestEffectExplicitSkillRootBudget/context_zero",
|
||||
"TestEffectExplicitSkillRootBudget/unconfigured"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/explicit-skill-root-budget.jsonl"
|
||||
|
||||
- name: upload Windows explicit skill root budget inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: explicit-skill-root-budget-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/explicit-skill-root-budget.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows root triage budget)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
@@ -761,6 +793,34 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows local cost quote)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(LocalBudgetUsesOccurrenceQuote|LocalCost.*|UsageQuote.*|EffectLocalCostUsesOccurrenceQuote)$' ./internal/contract/event ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/local-cost-quote.jsonl"
|
||||
jq -se '
|
||||
["TestLocalBudgetUsesOccurrenceQuote", "TestLocalBudgetUsesOccurrenceQuote/peak", "TestLocalBudgetUsesOccurrenceQuote/offpeak",
|
||||
"TestLocalCostKeepsOriginalCurrencyBuckets", "TestLocalCostFixedPointThresholdAndUnknown",
|
||||
"TestUsageQuoteUsesOneOwnerAndOccurrence", "TestUsageQuoteLegacyAndNilSinks",
|
||||
"TestEffectLocalCostUsesOccurrenceQuote", "TestEffectLocalCostUsesOccurrenceQuote/peak",
|
||||
"TestEffectLocalCostUsesOccurrenceQuote/offpeak", "TestEffectLocalCostUsesOccurrenceQuote/relay"] as $required |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
all(.[]; .Action != "fail" and .Action != "skip") and
|
||||
(($required - $passed) | length == 0) and
|
||||
((["reasonix/internal/contract/event", "reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages) | length == 0)
|
||||
' "$RUNNER_TEMP/local-cost-quote.jsonl"
|
||||
|
||||
- name: upload Windows local cost quote inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: local-cost-quote-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/local-cost-quote.jsonl
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows estimated usage quotes)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
|
||||
@@ -72,6 +72,38 @@ go run ./cmd/reasonix serve # from the repository root
|
||||
cd desktop/frontend-next && pnpm install && pnpm dev # :5273, proxies API paths
|
||||
```
|
||||
|
||||
## Global show/hide shortcut
|
||||
|
||||
In the desktop shell, **Settings → Appearance → Global show / hide shortcut**
|
||||
offers one opt-in binding, disabled by default.
|
||||
|
||||
1. Click **Record shortcut**, then press a modifier and key combination.
|
||||
Escape cancels recording.
|
||||
2. Review the platform-native label, then **Apply shortcut** to register it.
|
||||
3. **Clear shortcut** disables it immediately.
|
||||
|
||||
You can also edit the Electron accelerator directly (up to 128 characters).
|
||||
The shell requires Control, Command, Alt or Super, even through IPC or when
|
||||
restored from disk. Shift may accompany one of them but cannot qualify alone.
|
||||
|
||||
The shortcut hides a focused Studio window; otherwise it restores, shows and
|
||||
focuses the existing window. It does not change in-app shortcuts or tray settings.
|
||||
|
||||
The primary Electron instance owns the binding and stores it in its local shell
|
||||
profile. Changes apply immediately; invalid or unavailable bindings leave the
|
||||
previous saved and registered binding intact.
|
||||
|
||||
Startup registration failures are shown in settings rather than reported as
|
||||
success. An unavailable binding may be occupied, reserved by the OS, denied
|
||||
permission, or unsupported by the desktop.
|
||||
|
||||
Native Wayland environments may not support registration with this Electron
|
||||
backend; no portal authorization or cross-platform success is implied.
|
||||
|
||||
The binding exists only while Studio is running. It cannot launch a stopped
|
||||
application and no external daemon is installed. Browser and network viewers
|
||||
cannot configure a remote machine's global shortcut.
|
||||
|
||||
## Building and testing
|
||||
|
||||
```sh
|
||||
@@ -79,6 +111,21 @@ make studio-test # go test ./... for this modul
|
||||
cd frontend-next && pnpm typecheck && pnpm test && pnpm build
|
||||
```
|
||||
|
||||
The Linux/X11 and macOS shortcut smoke drives the real Studio window, chord
|
||||
capture, and OS key events. After building the host and frontend, run
|
||||
`pnpm shortcut-live` in `electron/`. macOS uses System Events and requires
|
||||
Accessibility/Automation permission for the launching process.
|
||||
|
||||
Use isolated `REASONIX_HOME` and `XDG_CONFIG_HOME` directories; the smoke seeds
|
||||
a test provider at an unreachable loopback port.
|
||||
|
||||
- On Linux/X11, `REASONIX_XDOTOOL` names `xdotool`.
|
||||
- On Linux/X11, `REASONIX_XMESSAGE` optionally names `xmessage` for the separate-app focus check.
|
||||
- `REASONIX_SCREENSHOTS` optionally names the screenshot output directory.
|
||||
|
||||
This test requires a working display. Windows and Wayland are not covered by
|
||||
this native smoke; successful builds do not establish native shortcut behavior.
|
||||
|
||||
Packaging runs through electron-builder; `release-studio.yml` drives it on all
|
||||
three platforms. Linux ships a `.deb` because that is what makes Studio
|
||||
self-updating there. See `docs/STUDIO_RELEASE.md`.
|
||||
@@ -121,3 +121,77 @@
|
||||
${EndIf}
|
||||
Pop $R9
|
||||
!macroend
|
||||
|
||||
# Closing a running copy before an install, an update or an uninstall. The
|
||||
# uninstaller runs from inside the install folder, so the process counting skips
|
||||
# this process by id: matching on the folder alone would find the uninstaller
|
||||
# itself and end it.
|
||||
!macro rxScript
|
||||
FileOpen $0 "$PLUGINSDIR\rxclose.ps1" w
|
||||
FileWrite $0 "param([string]$$Dir, [string]$$Mode, [int]$$Self)$\r$\n"
|
||||
FileWrite $0 "$$names = @('${APP_EXECUTABLE_FILENAME}', 'reasonix-studio-host.exe', 'cua-driver.exe')$\r$\n"
|
||||
FileWrite $0 "$$hit = @(Get-CimInstance -ClassName Win32_Process | ? { $$_.ProcessId -ne $$Self -and (($$_.Path -and $$_.Path.StartsWith($$Dir, 'CurrentCultureIgnoreCase')) -or (-not $$_.Path -and $$names -contains $$_.Name)) })$\r$\n"
|
||||
FileWrite $0 "if ($$Mode -eq 'find') { if ($$hit.Count -gt 0) { exit 0 } else { exit 1 } }$\r$\n"
|
||||
FileWrite $0 "$$hit | % { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }$\r$\n"
|
||||
FileClose $0
|
||||
!macroend
|
||||
|
||||
!macro rxFind
|
||||
nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\rxclose.ps1" "$INSTDIR" find $R2`
|
||||
Pop $R0
|
||||
!macroend
|
||||
|
||||
!macro rxKill
|
||||
nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\rxclose.ps1" "$INSTDIR" kill $R2`
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
!macro rxKillElevated
|
||||
nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -C "Start-Process -FilePath powershell -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','$PLUGINSDIR\rxclose.ps1','$INSTDIR','kill','$R2' -Verb RunAs -Wait -WindowStyle Hidden"`
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
!macro customCheckAppRunning
|
||||
InitPluginsDir
|
||||
System::Call 'kernel32::GetCurrentProcessId() i .R2'
|
||||
!insertmacro rxScript
|
||||
!insertmacro rxFind
|
||||
${if} $R0 == 0
|
||||
${ifNot} ${isUpdated}
|
||||
${andIfNot} ${Silent}
|
||||
DetailPrint "$(appClosing)"
|
||||
${endIf}
|
||||
StrCpy $R1 0
|
||||
rxCloseLoop:
|
||||
IntOp $R1 $R1 + 1
|
||||
!insertmacro rxKill
|
||||
Sleep 1000
|
||||
!insertmacro rxFind
|
||||
${if} $R0 != 0
|
||||
Goto rxClosed
|
||||
${endIf}
|
||||
${if} $R1 == 4
|
||||
${andIfNot} ${Silent}
|
||||
DetailPrint "Asking Windows for permission to close the elevated copy of ${PRODUCT_NAME}..."
|
||||
!insertmacro rxKillElevated
|
||||
Sleep 1000
|
||||
!insertmacro rxFind
|
||||
${if} $R0 != 0
|
||||
Goto rxClosed
|
||||
${endIf}
|
||||
${endIf}
|
||||
${if} $R1 < 6
|
||||
Goto rxCloseLoop
|
||||
${endIf}
|
||||
StrCmp $LANGUAGE 2052 rxZh
|
||||
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "${PRODUCT_NAME} is still running and the installer could not close it.$\r$\n$\r$\nRight-click its icon in the system tray (click the ^ if it is hidden) and choose Quit, or press Ctrl+Shift+Esc, end every process named $\"${PRODUCT_NAME}$\" (run Task Manager as administrator if the app was started as administrator), then click Retry." /SD IDCANCEL IDRETRY rxRetry
|
||||
Quit
|
||||
rxZh:
|
||||
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "${PRODUCT_NAME} 仍在运行,安装程序无法自动关闭它。$\r$\n$\r$\n请在任务栏右下角的托盘图标(看不到就点 ^)上右键,选择“退出”;或按 Ctrl+Shift+Esc 打开任务管理器,结束所有名为“${PRODUCT_NAME}”的进程(如果它是以管理员身份启动的,请用管理员身份打开任务管理器),然后点“重试”。" /SD IDCANCEL IDRETRY rxRetry
|
||||
Quit
|
||||
rxRetry:
|
||||
StrCpy $R1 0
|
||||
Goto rxCloseLoop
|
||||
rxClosed:
|
||||
${endIf}
|
||||
!macroend
|
||||
@@ -10,11 +10,12 @@
|
||||
"start": "electron .",
|
||||
"build:host": "node packaging/build-host.js",
|
||||
"build:linux-payload": "node packaging/linux-payload.js",
|
||||
"test": "node --test test/unit.mjs test/linux-packaging.test.mjs",
|
||||
"test": "node --test test/unit.mjs test/linux-packaging.test.mjs test/globalshortcut.test.mjs",
|
||||
"build:app": "electron-builder --dir",
|
||||
"build:dist": "electron-builder",
|
||||
"smoke": "electron test/smoke.js",
|
||||
"browser-live": "electron test/browser_live.js",
|
||||
"shortcut-live": "electron test/globalshortcut_live.js",
|
||||
"browser-resize-live": "electron test/browser_resize_live.js",
|
||||
"browser-fit-live": "electron test/browser_fit_live.js",
|
||||
"browser-fit-pages": "electron test/browser_fit_pages.js",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"use strict";
|
||||
|
||||
const PREF_KEY = "shell-global-shortcut";
|
||||
const MAX_BINDING_LENGTH = 128;
|
||||
|
||||
function createGlobalShortcut({ globalShortcut, binding = "", save, windowOf, showWindow }) {
|
||||
let state = { binding, status: binding ? "unavailable" : "disabled" };
|
||||
let closed = false;
|
||||
const press = () => {
|
||||
if (closed) return;
|
||||
const win = windowOf();
|
||||
if (win && !win.isDestroyed() && win.isVisible() && !win.isMinimized() && win.isFocused()) win.hide();
|
||||
else showWindow();
|
||||
};
|
||||
function register(value) {
|
||||
try {
|
||||
return globalShortcut.register(value, press) ? "" : "unavailable";
|
||||
} catch {
|
||||
return "invalid";
|
||||
}
|
||||
}
|
||||
function apply(value, persist) {
|
||||
if (closed) return { ...state, error: "quitting" };
|
||||
if (typeof value !== "string" || value.length > MAX_BINDING_LENGTH) return { ...state, error: "invalid" };
|
||||
const next = value.trim();
|
||||
if (next && !next.split("+").slice(0, -1).some(part => /^(CommandOrControl|CmdOrCtrl|Command|Cmd|Control|Ctrl|Alt|Option|AltGr|Super|Meta)$/i.test(part))) {
|
||||
return { ...state, error: "invalid" };
|
||||
}
|
||||
if (next === state.binding && state.status === "registered") return { ...state };
|
||||
if (next) {
|
||||
const error = register(next);
|
||||
if (error) return { ...state, error };
|
||||
}
|
||||
try {
|
||||
if (persist) save(next);
|
||||
} catch {
|
||||
if (next) globalShortcut.unregister(next);
|
||||
return { ...state, error: "save" };
|
||||
}
|
||||
if (state.status === "registered" && state.binding !== next) globalShortcut.unregister(state.binding);
|
||||
state = { binding: next, status: next ? "registered" : "disabled" };
|
||||
return { ...state };
|
||||
}
|
||||
return {
|
||||
start() {
|
||||
const result = apply(binding, false);
|
||||
if (result.error) state = { binding, status: result.error };
|
||||
return { ...state };
|
||||
},
|
||||
read: () => ({ ...state }),
|
||||
apply: value => apply(value, true),
|
||||
close() {
|
||||
closed = true;
|
||||
if (state.status === "registered") globalShortcut.unregister(state.binding);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function registerGlobalShortcutIPC(ipcMain, fromWindow, shortcutOf) {
|
||||
const authorized = event => fromWindow(event) && event.senderFrame === event.sender.mainFrame;
|
||||
ipcMain.handle("shell:global-shortcut", event => authorized(event) ? shortcutOf()?.read() ?? { binding: "", status: "unavailable" } : null);
|
||||
ipcMain.handle("shell:set-global-shortcut", (event, value) => authorized(event) ? shortcutOf()?.apply(value) ?? { binding: "", status: "unavailable" } : null);
|
||||
}
|
||||
|
||||
module.exports = { PREF_KEY, MAX_BINDING_LENGTH, createGlobalShortcut, registerGlobalShortcutIPC };
|
||||
@@ -1,5 +1,5 @@
|
||||
"use strict";
|
||||
const { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, powerSaveBlocker, screen, session, shell } = require("electron");
|
||||
const { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, nativeTheme, powerSaveBlocker, screen, session, shell } = require("electron");
|
||||
const { relaunchForOzonePlatform } = require("./ozone");
|
||||
|
||||
// Before the instance lock: this process must hold nothing its relaunch needs.
|
||||
@@ -31,7 +31,8 @@ const { BrowserProtocol } = require("./browserprotocol");
|
||||
const { BrowserViews } = require("./browserviews");
|
||||
const { startBrowserRelay } = require("./browserrelay");
|
||||
const { groundFor, themeSourceFor } = require("./ground");
|
||||
const { loadPrefs, pageSleepAfterMs, prefsFile, registerPrefs } = require("./prefs");
|
||||
const { loadPrefs, savePrefs, pageSleepAfterMs, prefsFile, registerPrefs } = require("./prefs");
|
||||
const { PREF_KEY: SHORTCUT_PREF, createGlobalShortcut, registerGlobalShortcutIPC } = require("./globalshortcut");
|
||||
const { stateFile, openWindow } = require("./windowstate");
|
||||
const { accelerationOff, shouldDisableGpu, graphicsHandler } = require("./graphics");
|
||||
const { processMemoryHandler } = require("./procmem");
|
||||
@@ -90,6 +91,7 @@ let browserRelay = null;
|
||||
let reload = null;
|
||||
let powerGuard = null;
|
||||
let logs = null;
|
||||
let windowShortcut = null;
|
||||
let handshaken = false;
|
||||
|
||||
let starting = null;
|
||||
@@ -407,6 +409,7 @@ nativeTheme.on("updated", () => {
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:graphics", (event) => graphicsHandler(app, fromWindow, gpuLaunch)(event));
|
||||
registerGlobalShortcutIPC(ipcMain, fromWindow, () => windowShortcut);
|
||||
const showTextContextMenu = textContextMenuHandler(Menu, fromWindow, () => origin, uiLang);
|
||||
ipcMain.handle("text:context-menu", showTextContextMenu);
|
||||
ipcMain.handle("shell:process-memory", (event) => processMemoryHandler(app, fromWindow)(event));
|
||||
@@ -554,7 +557,18 @@ if (!primary) {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
if (!primary) return;
|
||||
if (!primary || quitting) return;
|
||||
windowShortcut = createGlobalShortcut({
|
||||
globalShortcut,
|
||||
binding: savedPrefs[SHORTCUT_PREF] ?? "",
|
||||
save: binding => {
|
||||
const file = prefsFile(app.getPath("userData"));
|
||||
savePrefs(file, { ...loadPrefs(file), [SHORTCUT_PREF]: binding });
|
||||
},
|
||||
windowOf: () => win,
|
||||
showWindow,
|
||||
});
|
||||
windowShortcut.start();
|
||||
installApplicationMenu(uiLang);
|
||||
boot().catch((err) => {
|
||||
console.error("reasonix-studio:", err.message);
|
||||
@@ -601,6 +615,7 @@ module.exports = { current: () => ({ win, tray, client, origin }) };
|
||||
const drainCua = createQuitGate(() => cua, () => app.quit(), error => logs.shell.line(`CUA cleanup: ${error.message}`));
|
||||
app.on("before-quit", event => {
|
||||
quitting = true;
|
||||
windowShortcut?.close();
|
||||
logs.shell.line("shell: quitting");
|
||||
drainCua(event);
|
||||
browserRelay?.stop();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { PREF_KEY: SHORTCUT_PREF } = require("./globalshortcut");
|
||||
|
||||
// The page's own preferences, kept past the origin that stored them. The kernel
|
||||
// listens on a fresh loopback port each launch and localStorage is keyed by
|
||||
@@ -20,13 +21,12 @@ function pageSleepAfterMs(prefs) {
|
||||
return prefs && prefs["rx-page-sleep"] === "on" ? PAGE_SLEEP_MS : Infinity;
|
||||
}
|
||||
|
||||
// Anything that is not one of the page's own string preferences is dropped, in
|
||||
// both directions: a hand-edited file or a page bug cannot widen what is kept.
|
||||
// Only page string preferences and the shell-owned shortcut survive a disk read.
|
||||
function pick(value) {
|
||||
const out = {};
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return out;
|
||||
for (const [key, v] of Object.entries(value)) {
|
||||
if (key.startsWith(PREFIX) && typeof v === "string") out[key] = v;
|
||||
if ((key.startsWith(PREFIX) || key === SHORTCUT_PREF) && typeof v === "string") out[key] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -54,12 +54,15 @@ function savePrefs(file, prefs) {
|
||||
// after these handlers exist.
|
||||
function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
|
||||
ipcMain.on("prefs:load", (event) => {
|
||||
event.returnValue = fromWindow(event) ? loadPrefs(fileOf()) : {};
|
||||
event.returnValue = fromWindow(event) ? pickPagePrefs(loadPrefs(fileOf())) : {};
|
||||
});
|
||||
ipcMain.on("prefs:save", (event, prefs) => {
|
||||
if (fromWindow(event)) {
|
||||
try {
|
||||
savePrefs(fileOf(), prefs);
|
||||
const current = loadPrefs(fileOf());
|
||||
const next = pickPagePrefs(prefs);
|
||||
if (current[SHORTCUT_PREF] !== undefined) next[SHORTCUT_PREF] = current[SHORTCUT_PREF];
|
||||
savePrefs(fileOf(), next);
|
||||
onSave();
|
||||
} catch {
|
||||
// A preference that did not save is one relaunch of lost state, not a
|
||||
@@ -70,4 +73,10 @@ function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function pickPagePrefs(prefs) {
|
||||
const page = pick(prefs);
|
||||
delete page[SHORTCUT_PREF];
|
||||
return page;
|
||||
}
|
||||
|
||||
module.exports = { PREFIX, PAGE_SLEEP_MS, pageSleepAfterMs, prefsFile, pick, loadPrefs, savePrefs, registerPrefs };
|
||||
@@ -31,6 +31,8 @@ contextBridge.exposeInMainWorld("reasonixHost", {
|
||||
showTextContextMenu: (request) => ipcRenderer.invoke("text:context-menu", request),
|
||||
// What the shell's graphics are running as this launch.
|
||||
graphics: () => ipcRenderer.invoke("shell:graphics"),
|
||||
globalShortcut: () => ipcRenderer.invoke("shell:global-shortcut"),
|
||||
setGlobalShortcut: (binding) => ipcRenderer.invoke("shell:set-global-shortcut", binding),
|
||||
// The window's processes as type and two sizes, for the memory a report may carry.
|
||||
processMemory: () => ipcRenderer.invoke("shell:process-memory"),
|
||||
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createGlobalShortcut, registerGlobalShortcutIPC, PREF_KEY } = require("../src/globalshortcut");
|
||||
const { savePrefs, loadPrefs, registerPrefs } = require("../src/prefs");
|
||||
|
||||
function fixture(binding = "") {
|
||||
const registered = new Map();
|
||||
const saved = [];
|
||||
const win = { visible: true, focused: true, minimized: false, destroyed: false,
|
||||
isVisible() { return this.visible; }, isFocused() { return this.focused; },
|
||||
isMinimized() { return this.minimized; }, isDestroyed() { return this.destroyed; },
|
||||
hide() { this.visible = false; this.focused = false; },
|
||||
};
|
||||
let shown = 0;
|
||||
let failSave = false;
|
||||
const api = {
|
||||
register(key, callback) {
|
||||
if (key === "Control+invalid") throw new TypeError("invalid accelerator");
|
||||
if (key === "Control+occupied") return false;
|
||||
registered.set(key, callback); return true;
|
||||
},
|
||||
unregister(key) { registered.delete(key); },
|
||||
};
|
||||
const shortcut = createGlobalShortcut({ globalShortcut: api, binding,
|
||||
save(value) { if (failSave) throw new Error("disk full"); saved.push(value); },
|
||||
windowOf: () => win, showWindow() { shown++; win.visible = true; win.focused = true; win.minimized = false; },
|
||||
});
|
||||
return { shortcut, registered, saved, win, get shown() { return shown; }, failSave() { failSave = true; } };
|
||||
}
|
||||
|
||||
test("starts disabled without claiming an OS binding and restores a saved binding before any renderer", () => {
|
||||
const off = fixture();
|
||||
assert.deepEqual(off.shortcut.start(), { binding: "", status: "disabled" });
|
||||
assert.equal(off.registered.size, 0);
|
||||
const on = fixture("Control+Space");
|
||||
assert.deepEqual(on.shortcut.start(), { binding: "Control+Space", status: "registered" });
|
||||
assert.equal(on.registered.size, 1);
|
||||
assert.deepEqual(on.saved, []);
|
||||
});
|
||||
|
||||
test("bare keys and Shift-only chords cannot claim machine-wide input through apply or restored preferences", () => {
|
||||
for (const binding of ["C", "Space", "F9", "Plus", " Shift ", "Shift+A", "Shift+Space"]) {
|
||||
const restored = fixture(binding);
|
||||
assert.deepEqual(restored.shortcut.start(), { binding, status: "invalid" });
|
||||
assert.equal(restored.registered.size, 0);
|
||||
const f = fixture("Control+Space"); f.shortcut.start();
|
||||
assert.deepEqual(f.shortcut.apply(binding), { binding: "Control+Space", status: "registered", error: "invalid" });
|
||||
assert.deepEqual([...f.registered.keys()], ["Control+Space"]);
|
||||
assert.deepEqual(f.saved, []);
|
||||
}
|
||||
for (const modifier of ["CommandOrControl", "CmdOrCtrl", "Command", "Cmd", "Control", "Ctrl", "Alt", "Option", "AltGr", "Super", "Meta"]) {
|
||||
const f = fixture();
|
||||
assert.equal(f.shortcut.apply(`${modifier}+Space`).status, "registered");
|
||||
assert.equal(f.shortcut.apply(`${modifier}+Shift+Space`).status, "registered");
|
||||
}
|
||||
});
|
||||
|
||||
test("focused hides; unfocused, hidden and minimized show through the existing path", () => {
|
||||
for (const changed of [{}, { focused: false }, { visible: false, focused: false }, { minimized: true }]) {
|
||||
const f = fixture("Control+Space"); f.shortcut.start(); Object.assign(f.win, changed);
|
||||
f.registered.get("Control+Space")();
|
||||
assert.equal(f.shown, Object.keys(changed).length ? 1 : 0);
|
||||
assert.equal(f.win.visible, Object.keys(changed).length > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("failed rebind preserves both registration and saved choice, successful replacement removes only the old binding", () => {
|
||||
const f = fixture("Control+Space"); f.shortcut.start();
|
||||
for (const [binding, error] of [["Control+invalid", "invalid"], ["Control+occupied", "unavailable"], [42, "invalid"]]) {
|
||||
assert.deepEqual(f.shortcut.apply(binding), { binding: "Control+Space", status: "registered", error });
|
||||
assert.deepEqual([...f.registered.keys()], ["Control+Space"]);
|
||||
assert.deepEqual(f.saved, []);
|
||||
}
|
||||
assert.deepEqual(f.shortcut.apply(" Control+Shift+Space "), { binding: "Control+Shift+Space", status: "registered" });
|
||||
assert.deepEqual([...f.registered.keys()], ["Control+Shift+Space"]);
|
||||
assert.deepEqual(f.saved, ["Control+Shift+Space"]);
|
||||
f.shortcut.apply("");
|
||||
assert.equal(f.registered.size, 0);
|
||||
assert.deepEqual(f.saved, ["Control+Shift+Space", ""]);
|
||||
});
|
||||
|
||||
test("disk failure rolls back the prospective registration, including disabling", () => {
|
||||
const f = fixture("Control+Space"); f.shortcut.start(); f.failSave();
|
||||
for (const value of ["Control+Shift+Space", ""]) {
|
||||
assert.deepEqual(f.shortcut.apply(value), { binding: "Control+Space", status: "registered", error: "save" });
|
||||
assert.deepEqual([...f.registered.keys()], ["Control+Space"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("startup failure is honest and may be replaced or disabled", () => {
|
||||
for (const [binding, status] of [["Control+invalid", "invalid"], ["Control+occupied", "unavailable"]]) {
|
||||
const f = fixture(binding);
|
||||
assert.deepEqual(f.shortcut.start(), { binding, status });
|
||||
assert.equal(f.registered.size, 0);
|
||||
assert.deepEqual(f.shortcut.apply(""), { binding: "", status: "disabled" });
|
||||
}
|
||||
});
|
||||
|
||||
test("quit unregisters its binding, suppresses queued presses and refuses new registration", () => {
|
||||
const f = fixture("Control+Space"); f.shortcut.start();
|
||||
const queued = f.registered.get("Control+Space");
|
||||
f.shortcut.close(); f.shortcut.close(); queued();
|
||||
assert.equal(f.registered.size, 0); assert.equal(f.shown, 0); assert.equal(f.win.visible, true);
|
||||
assert.equal(f.shortcut.apply("Control+Shift+Space").error, "quitting");
|
||||
});
|
||||
|
||||
test("only the authorized Studio main frame can read or immediately apply a shortcut", () => {
|
||||
const f = fixture(); f.shortcut.start();
|
||||
const handlers = new Map(); const sender = { mainFrame: {} };
|
||||
registerGlobalShortcutIPC({ handle: (key, fn) => handlers.set(key, fn) }, event => event.sender === sender, () => f.shortcut);
|
||||
for (const event of [{ sender: { mainFrame: {} }, senderFrame: {} }, { sender, senderFrame: {} }]) {
|
||||
assert.equal(handlers.get("shell:global-shortcut")(event), null);
|
||||
assert.equal(handlers.get("shell:set-global-shortcut")(event, "Control+Space"), null);
|
||||
}
|
||||
assert.equal(f.registered.size, 0);
|
||||
const event = { sender, senderFrame: sender.mainFrame };
|
||||
assert.equal(handlers.get("shell:set-global-shortcut")(event, "Control+Space").status, "registered");
|
||||
assert.equal(handlers.get("shell:global-shortcut")(event).binding, "Control+Space");
|
||||
assert.deepEqual(handlers.get("shell:set-global-shortcut")(event, " ".repeat(128) + "Control+Space"), { binding: "Control+Space", status: "registered", error: "invalid" });
|
||||
assert.deepEqual([...f.registered.keys()], ["Control+Space"]);
|
||||
assert.deepEqual(f.saved, ["Control+Space"]);
|
||||
});
|
||||
|
||||
test("page preference flushing cannot overwrite the shell-owned saved binding", t => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-shortcut-"));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const file = path.join(dir, "window-prefs.json");
|
||||
savePrefs(file, { [PREF_KEY]: "Control+Space", "rx-lang": "en" });
|
||||
const handlers = {};
|
||||
registerPrefs({ on: (key, fn) => handlers[key] = fn }, () => file, () => true);
|
||||
const event = {};
|
||||
handlers["prefs:load"](event);
|
||||
assert.deepEqual(event.returnValue, { "rx-lang": "en" });
|
||||
handlers["prefs:save"](event, { "rx-lang": "zh", [PREF_KEY]: "occupied" });
|
||||
assert.deepEqual(loadPrefs(file), { "rx-lang": "zh", [PREF_KEY]: "Control+Space" });
|
||||
});
|
||||
|
||||
test("oversized restored preferences never reach native registration", () => {
|
||||
const binding = "Control+" + "x".repeat(128);
|
||||
const f = fixture(binding);
|
||||
assert.deepEqual(f.shortcut.start(), { binding, status: "invalid" });
|
||||
assert.equal(f.registered.size, 0);
|
||||
assert.deepEqual(f.saved, []);
|
||||
assert.deepEqual(f.shortcut.apply(""), { binding: "", status: "disabled" });
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync, spawn } = require("node:child_process");
|
||||
const { app, globalShortcut } = require("electron");
|
||||
const { wait } = require("./livekit");
|
||||
|
||||
const home = process.env.REASONIX_HOME;
|
||||
if (!home) throw new Error("Use an isolated REASONIX_HOME for the native shortcut smoke");
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
fs.writeFileSync(path.join(home, "config.toml"), 'default_model = "smoke/smoke-model"\n[desktop]\nwelcomed = true\n[[providers]]\nname = "smoke"\nkind = "openai"\nbase_url = "http://127.0.0.1:9/v1"\nmodels = ["smoke-model"]\ndefault = "smoke-model"\napi_key_env = "SMOKE_API_KEY"\n');
|
||||
fs.writeFileSync(path.join(home, ".env"), "SMOKE_API_KEY=smoke\n");
|
||||
const { current } = require("../src/main");
|
||||
const input = process.env.REASONIX_XDOTOOL || "xdotool";
|
||||
const binding = "Control+Shift+F9";
|
||||
let neighbour;
|
||||
|
||||
async function until(read, description) {
|
||||
const end = Date.now() + 40000;
|
||||
while (Date.now() < end) {
|
||||
if (await read()) return;
|
||||
await wait(100);
|
||||
}
|
||||
throw new Error(`Timed out: ${description}`);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await until(() => current().win && !current().win.webContents.isLoading(), "Studio window");
|
||||
const win = current().win;
|
||||
const js = source => win.webContents.executeJavaScript(source);
|
||||
await until(() => js('!!document.querySelector(".chrome")'), "real Studio workbench");
|
||||
await until(() => js('!document.getElementById("boot")'), "startup overlay finishes");
|
||||
assert.equal(await js('window.reasonixHost.globalShortcut().then(value => value.status)'), "disabled");
|
||||
await js('document.querySelector(\'[data-action="chrome.settings"]\').click()');
|
||||
await until(() => js('!!document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\')'), "settings navigation");
|
||||
await js('document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\').click()');
|
||||
await until(() => js('!!document.querySelector("#set-global-shortcut input:not(:disabled)")'), "shortcut settings");
|
||||
const out = process.env.REASONIX_SCREENSHOTS;
|
||||
async function screenshots(stage) {
|
||||
if (!out) return;
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
for (const width of [1280, 390]) {
|
||||
win.setSize(1280, 1000);
|
||||
win.webContents.enableDeviceEmulation({ screenPosition: "desktop", screenSize: { width, height: 1000 }, viewSize: { width, height: 1000 }, deviceScaleFactor: 1, scale: 1 });
|
||||
for (const mode of ["light", "dark"]) {
|
||||
await js(`document.querySelector('[data-action="appearance.scheme"][data-value="${mode}"]').click()`);
|
||||
await js('document.querySelector("#set-global-shortcut").scrollIntoView({block:"center"})');
|
||||
await wait(350);
|
||||
const png = await win.webContents.capturePage({ x: 0, y: 0, width, height: 1000 });
|
||||
fs.writeFileSync(path.join(out, `${stage}-${mode}-${width}.png`), png.toPNG());
|
||||
}
|
||||
}
|
||||
win.webContents.disableDeviceEmulation();
|
||||
}
|
||||
await screenshots("before-disabled");
|
||||
await js('document.querySelector(\'button[data-action="window.global-shortcut"][data-value="record"]\').click()');
|
||||
win.webContents.sendInputEvent({ type: "keyDown", keyCode: "F9", modifiers: ["control", "shift"] });
|
||||
win.webContents.sendInputEvent({ type: "keyUp", keyCode: "F9", modifiers: ["control", "shift"] });
|
||||
await until(() => js(`document.querySelector("#set-global-shortcut input").value === "${binding}"`), "captured modifier chord");
|
||||
await js('document.querySelector(\'button[data-action="window.global-shortcut"][data-value="apply"]\').click()');
|
||||
await until(() => js('window.reasonixHost.globalShortcut().then(value => value.status === "registered")'), "real OS registration");
|
||||
assert.equal(globalShortcut.isRegistered(binding), true);
|
||||
console.log(`ok: real Studio settings capture/apply registered the native ${process.platform} binding`);
|
||||
await screenshots("after-registered");
|
||||
if (process.platform === "darwin") {
|
||||
execFileSync("osascript", ["-e", `tell application "System Events" to set frontmost of first application process whose unix id is ${process.pid} to true`], { timeout: 10000 });
|
||||
}
|
||||
win.show(); win.focus();
|
||||
await until(() => win.isFocused(), "focused Studio window");
|
||||
const press = () => process.platform === "darwin"
|
||||
? execFileSync("osascript", ["-e", 'tell application "System Events" to key code 101 using {control down, shift down}'], { timeout: 10000 })
|
||||
: execFileSync(input, ["key", "--clearmodifiers", "ctrl+shift+F9"]);
|
||||
press();
|
||||
await until(() => !win.isVisible(), "OS key hides focused Studio");
|
||||
press();
|
||||
await until(() => win.isVisible() && win.isFocused(), "OS key restores hidden Studio");
|
||||
console.log("ok: real OS key hides the focused window and restores it from hidden");
|
||||
win.minimize();
|
||||
await until(() => win.isMinimized(), "minimized Studio");
|
||||
press();
|
||||
await until(() => !win.isMinimized() && win.isVisible() && win.isFocused(), "OS key restores minimized Studio");
|
||||
console.log("ok: real OS key restores and focuses the minimized window");
|
||||
if (process.env.REASONIX_XMESSAGE) {
|
||||
neighbour = spawn(process.env.REASONIX_XMESSAGE, ["-title", "Reasonix shortcut smoke neighbour", "-buttons", "OK", "Separate native application"], { stdio: "ignore" });
|
||||
await until(() => {
|
||||
try { return execFileSync(input, ["search", "--name", "Reasonix shortcut smoke neighbour"]).toString().trim(); } catch { return false; }
|
||||
}, "external application window");
|
||||
const id = execFileSync(input, ["search", "--name", "Reasonix shortcut smoke neighbour"]).toString().trim().split("\n")[0];
|
||||
execFileSync(input, ["windowfocus", "--sync", id]);
|
||||
await until(() => !win.isFocused() && win.isVisible(), "external application takes focus");
|
||||
press();
|
||||
await until(() => win.isFocused() && win.isVisible(), "OS key raises Studio from another app");
|
||||
console.log("ok: real OS key raises visible, unfocused Studio while a separate native application has focus");
|
||||
neighbour.kill();
|
||||
}
|
||||
assert.equal(globalShortcut.register("Control+Shift+F10", () => {}), true);
|
||||
const failed = await js('window.reasonixHost.setGlobalShortcut("Control+Shift+F10")');
|
||||
assert.deepEqual(failed, { binding, status: "registered", error: "unavailable" });
|
||||
globalShortcut.unregister("Control+Shift+F10");
|
||||
const saved = JSON.parse(fs.readFileSync(path.join(app.getPath("userData"), "window-prefs.json"), "utf8"));
|
||||
assert.equal(saved["shell-global-shortcut"], binding);
|
||||
assert.equal((await js('window.reasonixHost.setGlobalShortcut("C")')).error, "invalid");
|
||||
assert.equal(globalShortcut.isRegistered("C"), false);
|
||||
assert.equal((await js('window.reasonixHost.setGlobalShortcut("invalid shortcut value")')).error, "invalid");
|
||||
assert.equal((await js('window.reasonixHost.setGlobalShortcut("")')).status, "disabled");
|
||||
assert.equal(globalShortcut.isRegistered(binding), false);
|
||||
console.log("ok: native conflict/invalid entries preserve prior binding and disabling unregisters it");
|
||||
}
|
||||
|
||||
run().then(() => { app.quit(); }).catch(error => {
|
||||
neighbour?.kill();
|
||||
console.error(error.stack || error);
|
||||
app.exit(1);
|
||||
});
|
||||
@@ -84,7 +84,7 @@ async function run() {
|
||||
placed: typeof window.reasonixHost?.pathForFile(new File(['x'], 'x.txt')),
|
||||
}))()`);
|
||||
|
||||
const verbs = ["answerBrowserLogin", "closeWindow", "controlBrowserView", "freezeBrowserView", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openWorkspace", "pathForFile", "pickFolder", "platform", "revealPath", "saveBytes", "saveText", "shell", "showBrowserView", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
|
||||
const verbs = ["answerBrowserLogin", "captureWindow", "closeWindow", "controlBrowserView", "freezeBrowserView", "globalShortcut", "graphics", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openFile", "openWorkspace", "pathForFile", "pickFolder", "platform", "processMemory", "revealLogs", "revealPath", "revealWorkspace", "saveBytes", "saveText", "setGlobalShortcut", "setUnreadBadge", "shell", "showBrowserView", "showTextContextMenu", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
|
||||
check("the bridge exposes verbs and nothing else", JSON.stringify(seen.bridge) === JSON.stringify(verbs), seen.bridge);
|
||||
check("the credential never reaches the page", !seen.cookie.includes("reasonix_token"), seen.cookie);
|
||||
check("the renderer has no node of its own", seen.globals.every((t) => t === "undefined"), seen.globals);
|
||||
|
||||
@@ -7,6 +7,15 @@ reviewed: 2026-09-22
|
||||
|
||||
# Reasonix Studio frontend
|
||||
|
||||
## Feedback contact
|
||||
|
||||
- Studio prefills new reports with the contact value from the last successful
|
||||
submission, stored in this device's local browser storage.
|
||||
- The field stays editable; submitting it empty clears the remembered value.
|
||||
Failed submissions do not replace it.
|
||||
- An unsent draft takes precedence while the page is open.
|
||||
- No feedback body or screenshots are persisted by this preference.
|
||||
|
||||
## Tests
|
||||
|
||||
`pnpm test` runs one worker per test file on the threads pool. Threads rather
|
||||
|
||||
@@ -49,6 +49,7 @@ 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/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 # 执行过程:在跑的步骤自己在动、收起后由标题接着动,步骤行没有空栏
|
||||
node perf/lang.mjs # 双语验证:英文启动、界面译文到位、切回中文
|
||||
|
||||
@@ -164,7 +164,7 @@ const settled = async (page) => {
|
||||
};
|
||||
|
||||
const found = new Map();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const scheme of ["light", "dark"]) for (const tier of ["", "soft"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1512, height: 950 }, locale: "zh-CN", colorScheme: scheme });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
// The receipt card is off unless a machine asked for it, and an unrendered
|
||||
@@ -177,6 +177,11 @@ for (const scheme of ["light", "dark"]) {
|
||||
});
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
await page.evaluate(({ scheme, tier }) => {
|
||||
document.documentElement.dataset.theme = scheme;
|
||||
if (tier) document.documentElement.dataset.contrast = tier;
|
||||
else delete document.documentElement.dataset.contrast;
|
||||
}, { scheme, tier });
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
// 一屏里画不出所有状态:审批、提问、失败的调用、设置页各自带着自己的配色。
|
||||
@@ -223,11 +228,11 @@ for (const scheme of ["light", "dark"]) {
|
||||
f({ kind: "turn_done", receipt: { verdict: "partial", changes: [{ path: "a.go", reviewed: true }], verifications: [{ command: "go test ./...", passed: false, stale: true }], gaps: [{ kind: "unverified_mutation", detail: "改了没跑" }], risks: ["只在 darwin 验过"], unverified: ["Windows"], saysSomething: true } });
|
||||
});
|
||||
await settled(page);
|
||||
for (const row of await sweep(page, scheme, "工作台")) found.set(row.key, row);
|
||||
for (const row of await sweep(page, `${scheme}/${tier || "unset"}`, "工作台")) found.set(row.key, row);
|
||||
|
||||
await page.locator('[data-action="chrome.settings"]').first().click();
|
||||
await settled(page);
|
||||
for (const row of await sweep(page, scheme, "设置")) if (!found.has(row.key)) found.set(row.key, row);
|
||||
for (const row of await sweep(page, `${scheme}/${tier || "unset"}`, "设置")) if (!found.has(row.key)) found.set(row.key, row);
|
||||
await page.close();
|
||||
}
|
||||
|
||||
|
||||
@@ -383,7 +383,8 @@ class BenchPort extends MockPort {
|
||||
// Lines the person queued: the panel leaves out the host's own queued
|
||||
// continuations, so a seed of those would draw nothing.
|
||||
intent: (QUEUE_IMAGES && i === 0 ? "steer" : "followup") as "steer" | "followup",
|
||||
state: "queued" as const,
|
||||
state: QUEUE_STATES && i < 2 ? (i === 0 ? "blocked" as const : "uncertain" as const) : "queued" as const,
|
||||
...(QUEUE_STATES && i < 2 ? { blockReason: "This queued instruction needs review before delivery. ".repeat(5) } : {}),
|
||||
...(QUEUE_IMAGES && i === 0 ? { images: true } : {}),
|
||||
// 三种长度,各有各的对照:第一条没被截断(不该弹),第二条长到撞天花板
|
||||
// (浮层要自己滚),其余是寻常的被截断行。内核把 preview 截到 120 runes,
|
||||
@@ -498,6 +499,7 @@ const SWITCH_MS = Number(query.get("switchms") ?? 0);
|
||||
// ?queue= is how many lines are already waiting when the window opens.
|
||||
const QUEUE = Number(query.get("queue") ?? 0);
|
||||
const QUEUE_BODY = query.has("queuebody");
|
||||
const QUEUE_STATES = query.has("queuestates");
|
||||
// ?queueimages makes the first seeded line a steer that carries a picture.
|
||||
const QUEUE_IMAGES = query.has("queueimages");
|
||||
const JOBS = Number(query.get("jobs") ?? 0);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const url = process.env.PERF_URL ?? "http://127.0.0.1:4407/perf.html";
|
||||
const shots = process.env.QUEUE_SHOTS ?? "/tmp/queue-14497-shots";
|
||||
const baseline = process.env.QUEUE_BASELINE === "1";
|
||||
mkdirSync(shots, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const failures = [];
|
||||
for (const theme of ["light", "dark"]) {
|
||||
for (const width of [1440, 390]) {
|
||||
const context = await browser.newContext({ viewport: { width, height: 1000 }, locale: "zh-CN" });
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (e) => failures.push(e.message));
|
||||
await page.goto(`${url}?queue=12&queuebody&queuestates&turns=8`, { waitUntil: "networkidle" });
|
||||
await page.locator(".qi").first().waitFor();
|
||||
await page.evaluate((theme) => { document.documentElement.dataset.theme = theme; }, theme);
|
||||
await page.waitForTimeout(500);
|
||||
const geometry = await page.evaluate(() => {
|
||||
const list = document.querySelector(".qitems");
|
||||
const row = document.querySelector(".qi");
|
||||
const style = getComputedStyle(row);
|
||||
return { border: style.borderTopWidth, height: row.getBoundingClientRect().height,
|
||||
scrolls: list.scrollHeight > list.clientHeight, visibleHeight: list.clientHeight, width: document.documentElement.scrollWidth,
|
||||
background: style.backgroundColor, gap: getComputedStyle(list).gap };
|
||||
});
|
||||
console.log(`${baseline ? "before" : "after"}/${theme}/${width}`, geometry);
|
||||
await page.screenshot({ path: `${shots}/${baseline ? "before" : "after"}-${theme}-${width}.png` });
|
||||
if (baseline) {
|
||||
assert.notEqual(geometry.border, "0px", "baseline must expose the standalone row border");
|
||||
} else {
|
||||
assert.equal(geometry.border, "0px");
|
||||
assert.ok(geometry.height >= 36 && geometry.height <= 40, JSON.stringify(geometry));
|
||||
assert.equal(geometry.gap, "2px");
|
||||
assert.ok(geometry.scrolls);
|
||||
assert.ok(geometry.width <= width);
|
||||
assert.equal(await page.getByRole("combobox", { name: "任务输入" }).getAttribute("placeholder"), "继续输入以排队后续修改");
|
||||
const row = page.locator(".qi").first();
|
||||
assert.equal(await row.locator(".qwhy").getAttribute("title"), "This queued instruction needs review before delivery. ".repeat(5));
|
||||
assert.ok(geometry.visibleHeight >= 180 && geometry.visibleHeight <= 208, JSON.stringify(geometry));
|
||||
await row.getByRole("button", { name: "改", exact: true }).focus();
|
||||
assert.equal(await row.getByRole("button", { name: "下移", exact: true }).evaluate((el) => getComputedStyle(el).opacity), "1");
|
||||
await page.screenshot({ path: `${shots}/focus-${theme}-${width}.png` });
|
||||
await row.getByRole("button", { name: "改", exact: true }).click();
|
||||
await page.locator(".qedit").waitFor();
|
||||
assert.ok(await page.locator(".qedit").inputValue());
|
||||
await page.screenshot({ path: `${shots}/edit-${theme}-${width}.png` });
|
||||
await page.locator(".qedit").press("Escape");
|
||||
assert.equal(await page.locator(".qedit").count(), 0);
|
||||
assert.equal(await row.getByRole("button", { name: "取回", exact: true }).getAttribute("title"), "取回");
|
||||
await page.locator(".qitems").evaluate((el) => { el.scrollTop = el.scrollHeight; });
|
||||
await page.locator(".qi").last().getByRole("button", { name: "取回", exact: true }).focus();
|
||||
assert.ok(await page.locator(".qi").last().getByRole("button", { name: "取回", exact: true }).isVisible());
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
assert.deepEqual(failures, []);
|
||||
console.log("Queue layout browser matrix passed");
|
||||
@@ -38,7 +38,7 @@ check("播出来的行在", (await rows.count()) === 3, `${await rows.count()}
|
||||
|
||||
const first = rows.nth(0);
|
||||
const wasFirst = await first.locator(".pv").innerText();
|
||||
await first.locator('button:has-text("改")').click();
|
||||
await first.getByRole("button", { name: "改", exact: true }).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const box = await page.locator(".queue .qedit").count();
|
||||
@@ -50,7 +50,7 @@ check("这一行说了为什么", await why.isVisible().catch(() => false), (awa
|
||||
// 说明是这一行的,不是面板的:换一行再点,上一行的说明得跟着走。一条粘住不动的
|
||||
// 提示会让下一次失败看起来像上一次的余音。
|
||||
const second = rows.nth(1);
|
||||
await second.locator('button:has-text("改")').click();
|
||||
await second.getByRole("button", { name: "改", exact: true }).click();
|
||||
await page.waitForTimeout(500);
|
||||
check("说明跟着被点的那一行走", (await first.locator(".qwhy[data-err]").count()) === 0);
|
||||
check("新的那一行才带着说明", await second.locator(".qwhy[data-err]").isVisible().catch(() => false));
|
||||
|
||||
@@ -73,7 +73,7 @@ for (const [label, width, height] of [["wide", 1440, 900], ["phone", 390, 800]])
|
||||
await page.goto(`${PAGE}?queue=2&queuebody`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".queue .qi", { timeout: 15000 });
|
||||
await page.waitForTimeout(600);
|
||||
await page.locator('.queue .qi').first().locator('button:has-text("改")').click();
|
||||
await page.locator('.queue .qi').first().getByRole("button", { name: "改", exact: true }).click();
|
||||
await page.waitForTimeout(600);
|
||||
const q = await page.evaluate(() => {
|
||||
const el = document.querySelector(".queue .qedit");
|
||||
@@ -92,7 +92,7 @@ for (const [label, width, height] of [["wide", 1440, 900], ["phone", 390, 800]])
|
||||
await page.goto(`${PAGE}?queue=4&queuebody`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".queue .qi", { timeout: 15000 });
|
||||
await page.waitForTimeout(600);
|
||||
await page.locator(".queue .qi").nth(1).locator('button:has-text("改")').click();
|
||||
await page.locator(".queue .qi").nth(1).getByRole("button", { name: "改", exact: true }).click();
|
||||
await page.waitForTimeout(600);
|
||||
if (fill) { await page.locator(".queue .qedit").fill(fill); await page.waitForTimeout(400); }
|
||||
const g = await page.evaluate(() => {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
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 shots = process.env.SURFACE_SCREENSHOTS;
|
||||
if (shots) await mkdir(shots, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const errors = [];
|
||||
const surfaces = ["nav", "rail", "chrome", "side", "compose"];
|
||||
const opacity = {
|
||||
dark: { nav: 0.45, rail: 0.46, chrome: 0.8, side: 0.81, compose: 0.88 },
|
||||
light: { nav: 0.6, rail: 0.61, chrome: 0.86, side: 0.87, compose: 0.89 },
|
||||
};
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) for (const width of [1600, 390]) {
|
||||
const context = await browser.newContext({ viewport: { width, height: width === 390 ? 844 : 1000 }, reducedMotion: "reduce" });
|
||||
await context.addInitScript((scheme) => localStorage.setItem("rx-theme", scheme), scheme);
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.route("**/themes/bench-photo/background", (route) => route.fulfill({ contentType: "image/svg+xml", body: '<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000"><rect width="1600" height="1000" fill="#638c99"/><circle cx="1100" cy="400" r="400" fill="#ddbf88"/></svg>' }));
|
||||
await page.goto(`${base}?pack=photo&ws=1&sess=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.addStyleTag({ content: "* { transition: none !important; animation: none !important; caret-color: transparent !important }" });
|
||||
// Details no longer mount .side in the dock layout; phone omits .nav.
|
||||
// Exercise their actual stylesheet selectors without changing visible UI.
|
||||
await page.evaluate((surfaces) => {
|
||||
for (const name of surfaces) if (!document.querySelector(`.${name}`)) {
|
||||
const probe = document.createElement("aside");
|
||||
probe.className = name;
|
||||
probe.style.cssText = "position:fixed;left:-9999px;width:10px;height:10px";
|
||||
document.body.append(probe);
|
||||
}
|
||||
}, surfaces);
|
||||
const styles = () => page.evaluate((surfaces) => Object.fromEntries(surfaces.map((name) => {
|
||||
const style = getComputedStyle(document.querySelector(`.${name}`));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = style.backgroundColor;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
return [name, { alpha: ctx.getImageData(0, 0, 1, 1).data[3] / 255, background: style.backgroundColor, blur: style.backdropFilter, shadow: style.textShadow }];
|
||||
})), surfaces);
|
||||
const paint = async (surfaceOpacity, backdrop = "photo") => page.evaluate(async ({ surfaceOpacity, backdrop, scheme }) => {
|
||||
const { apply } = await import("/src/ui/theme.ts");
|
||||
const tokens = {
|
||||
light: { bg: "#F2F6F8", bgSoft: "#F9FCFD", panel: "#FFFFFF", border: "#CBD9E0", fg: "#12191C", fgDim: "#4E5D65", accent: "#0E6E82" },
|
||||
dark: { bg: "#080D10", bgSoft: "#0C1316", panel: "#131C21", border: "#23333A", fg: "#E4EEF2", fgDim: "#8298A2", accent: "#4FB6CE" },
|
||||
};
|
||||
apply({ id: "bench-photo", name: "Surface fixture", tokens, background: { image: backdrop === "photo", focusX: 0.5, focusY: 0.5, homeOpacity: 0.9, taskOpacity: 0.5, overlayStrength: 0.4, surfaceOpacity }, ...(backdrop === "sky" ? { sky: { rayAlpha: 0.85, cloudAlpha: 0.4 } } : {}) }, scheme);
|
||||
}, { surfaceOpacity, backdrop, scheme });
|
||||
const baseline = await styles();
|
||||
await paint({});
|
||||
assert.deepEqual(await styles(), baseline, "omission keeps every computed paint exactly");
|
||||
const empty = await page.screenshot({ animations: "disabled" });
|
||||
await paint(undefined);
|
||||
assert.deepEqual(await page.screenshot({ animations: "disabled" }), empty, "omission keeps pixels exactly");
|
||||
await paint(opacity);
|
||||
const custom = await styles();
|
||||
for (const name of surfaces) {
|
||||
assert.ok(Math.abs(custom[name].alpha - opacity[scheme][name]) <= 1 / 255, `${scheme} ${name}: ${custom[name].background}`);
|
||||
assert.equal(custom[name].blur, baseline[name].blur);
|
||||
assert.equal(custom[name].shadow, baseline[name].shadow);
|
||||
}
|
||||
if (shots) await page.screenshot({ path: `${shots}/${scheme}-${width}-after.png`, animations: "disabled" });
|
||||
await paint(opacity, "sky");
|
||||
for (const [name, value] of Object.entries(await styles())) assert.ok(Math.abs(value.alpha - opacity[scheme][name]) <= 1 / 255, `sky ${name}`);
|
||||
await paint(opacity, "none");
|
||||
const withoutBackdrop = await styles();
|
||||
await paint(undefined, "none");
|
||||
assert.deepEqual(await styles(), withoutBackdrop, "data-bg off ignores surface map");
|
||||
await paint({ [scheme]: { nav: 0, compose: 1 } });
|
||||
const boundaries = await styles();
|
||||
assert.equal(boundaries.nav.alpha, 0);
|
||||
assert.equal(boundaries.compose.alpha, 1);
|
||||
for (const name of ["rail", "chrome", "side"]) assert.equal(boundaries[name].background, baseline[name].background, `omitted ${name}`);
|
||||
await paint(undefined);
|
||||
assert.deepEqual(await styles(), baseline, "switching to an old pack clears custom paint");
|
||||
await page.evaluate(async () => (await import("/src/ui/theme.ts")).apply(null, "light"));
|
||||
assert.equal(await page.locator("#pack-theme").count(), 0);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.dataset.bg), undefined);
|
||||
console.log(`ok ${scheme} ${width}: five computed alphas; exact omitted pixels; sky, backdrop removal, boundaries and pack clearing`);
|
||||
await context.close();
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,21 +1,31 @@
|
||||
// Ink contrast per tier and scheme. Each tier's inks must be at least as legible
|
||||
// as the default palette stepped once, and as the recorded floors below (ratios
|
||||
// on --page; --ghost shares --faint's), and every ink must clear AA on both grounds.
|
||||
// Built-in soft deliberately lowers contrast without crossing AA, including
|
||||
// small ghost text. Unset and the higher tiers retain their recorded palettes.
|
||||
// Custom packs keep authored inks for soft; see TOKENS.md for that boundary.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=1";
|
||||
const STEPS = { "--text": [3.0, 10.1], "--muted": [3.5, 7.5], "--faint": [3.5, 7.0], "--ghost": [3.5, 7.0] };
|
||||
const INKS = Object.keys(STEPS);
|
||||
const GROUNDS = ["--page", "--surface"];
|
||||
const GROUNDS = ["--page", "--surface", "--raised", "--overlay", "--float", "--float-hi"];
|
||||
const TIERS = ["soft", "normal", "strong"];
|
||||
const FLOOR = {
|
||||
"light/soft": { "--text": 11.69, "--muted": 7.63, "--faint": 5.76 },
|
||||
"light/normal": { "--text": 14.07, "--muted": 10.22, "--faint": 7.86 },
|
||||
"light/strong": { "--text": 17.86, "--muted": 13.55, "--faint": 10.42 },
|
||||
"dark/soft": { "--text": 11.48, "--muted": 7.48, "--faint": 5.63 },
|
||||
"dark/normal": { "--text": 13.92, "--muted": 9.79, "--faint": 7.57 },
|
||||
"dark/strong": { "--text": 18.01, "--muted": 13.16, "--faint": 9.92 },
|
||||
};
|
||||
const SNAPSHOT = {
|
||||
light: {
|
||||
"": ["#292b29", "#5e625c", "#666a63"],
|
||||
normal: ["oklch(.245 .0218 85)", "oklch(.34 .0196 85)", "oklch(.405 .0196 85)"],
|
||||
strong: ["oklch(.103 .0196 85)", "oklch(.26 .0184 85)", "oklch(.335 .0184 85)"],
|
||||
},
|
||||
dark: {
|
||||
"": ["#e7e8e3", "#b4b7b0", "#8e9289"],
|
||||
normal: ["oklch(.9588 .006756 115.3)", "oklch(.81 .01031 125.5)", "oklch(.728 .013 255)"],
|
||||
strong: ["oklch(1 .006756 115.3)", "oklch(.893 .011 255)", "oklch(.806 .012 255)"],
|
||||
},
|
||||
};
|
||||
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
@@ -29,7 +39,7 @@ page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
|
||||
const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => {
|
||||
const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS, SNAPSHOT }) => {
|
||||
const root = document.documentElement;
|
||||
const probe = document.createElement("div");
|
||||
document.body.append(probe);
|
||||
@@ -61,6 +71,15 @@ const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => {
|
||||
root.dataset.theme = scheme;
|
||||
delete root.dataset.contrast;
|
||||
const base = Object.fromEntries(INKS.map((n) => [n, read(n === "--ghost" ? "--faint" : n)]));
|
||||
for (const [tier, expected] of Object.entries(SNAPSHOT[scheme])) {
|
||||
if (tier) root.dataset.contrast = tier;
|
||||
else delete root.dataset.contrast;
|
||||
for (const [i, ink] of INKS.entries()) {
|
||||
probe.style.color = expected[Math.min(i, 2)];
|
||||
const ref = rgb(getComputedStyle(probe).color);
|
||||
out[`${scheme}/${tier || "unset"}/${ink}/snapshot`] = { got: rgb(read(ink)).join(","), ref: ref.join(",") };
|
||||
}
|
||||
}
|
||||
const grounds = Object.fromEntries(GROUNDS.map((g) => [g, rgb(read(g))]));
|
||||
for (const tier of TIERS) {
|
||||
const k = TIERS.indexOf(tier);
|
||||
@@ -70,21 +89,74 @@ const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => {
|
||||
probe.style.color = by ? `oklch(from ${base[ink]} calc(l ${scheme === "light" ? "-" : "+"} ${(by / 100).toFixed(3)}) c h)` : base[ink];
|
||||
const ref = rgb(getComputedStyle(probe).color);
|
||||
const got = rgb(read(ink));
|
||||
for (const g of GROUNDS) out[`${scheme}/${tier}/${ink}/${g}`] = { ref: ratio(ref, grounds[g]), got: ratio(got, grounds[g]) };
|
||||
for (const g of GROUNDS) out[`${scheme}/${tier}/${ink}/${g}`] = { ref: ratio(ref, grounds[g]), base: ratio(rgb(base[ink]), grounds[g]), got: ratio(got, grounds[g]) };
|
||||
}
|
||||
}
|
||||
}
|
||||
probe.remove();
|
||||
return out;
|
||||
}, { STEPS, INKS, GROUNDS, TIERS });
|
||||
}, { STEPS, INKS, GROUNDS, TIERS, SNAPSHOT });
|
||||
|
||||
for (const [key, { ref, got }] of Object.entries(measured)) {
|
||||
for (const [key, { ref, base, got }] of Object.entries(measured)) {
|
||||
const [scheme, tier, ink, ground] = key.split("/");
|
||||
const floor = ground === "--page" ? (FLOOR[`${scheme}/${tier}`][ink === "--ghost" ? "--faint" : ink] ?? 0) : 0;
|
||||
check(`${key} not below the stepped palette or its floor`, got >= Math.max(ref, floor) - 0.005, `${got.toFixed(2)} vs ${Math.max(ref, floor).toFixed(2)}`);
|
||||
if (ground === "snapshot") {
|
||||
check(`${key} stays unchanged`, got === ref, `${got} vs ${ref}`);
|
||||
continue;
|
||||
}
|
||||
if (tier === "soft") {
|
||||
const lowers = ink === "--text" || ink === "--muted";
|
||||
check(`${key} ${lowers ? "is genuinely softer than" : "retains the AA floor of"} unset`, lowers ? got < base - 0.1 : Math.abs(got - base) < 0.005, `${got.toFixed(2)} vs ${base.toFixed(2)}`);
|
||||
} else {
|
||||
const floor = ground === "--page" ? (FLOOR[`${scheme}/${tier}`][ink === "--ghost" ? "--faint" : ink] ?? 0) : 0;
|
||||
check(`${key} not below the stepped palette or its floor`, got >= Math.max(ref, floor) - 0.005, `${got.toFixed(2)} vs ${Math.max(ref, floor).toFixed(2)}`);
|
||||
}
|
||||
check(`${key} clears AA`, got >= 4.5, got.toFixed(2));
|
||||
}
|
||||
|
||||
const explicit = await page.evaluate((INKS) => {
|
||||
const root = document.documentElement;
|
||||
const p = document.createElement("div");
|
||||
document.body.append(p);
|
||||
const read = () => INKS.map((n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color));
|
||||
const out = {};
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
root.dataset.theme = scheme;
|
||||
for (const tier of ["soft", "normal", "strong"]) {
|
||||
root.dataset.contrast = tier;
|
||||
out[`${scheme}/${tier}`] = read();
|
||||
}
|
||||
}
|
||||
p.remove();
|
||||
return out;
|
||||
}, INKS);
|
||||
for (const system of ["light", "dark"]) {
|
||||
await page.emulateMedia({ colorScheme: system, contrast: "more" });
|
||||
for (const [key, expected] of Object.entries(explicit)) {
|
||||
const got = await page.evaluate(({ key, INKS }) => {
|
||||
const [scheme, tier] = key.split("/");
|
||||
document.documentElement.dataset.theme = scheme;
|
||||
document.documentElement.dataset.contrast = tier;
|
||||
const p = document.createElement("div");
|
||||
document.body.append(p);
|
||||
const out = INKS.map((n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color));
|
||||
p.remove();
|
||||
return out;
|
||||
}, { key, INKS });
|
||||
check(`${key} outranks system ${system} and prefers-contrast: more`, JSON.stringify(got) === JSON.stringify(expected));
|
||||
}
|
||||
}
|
||||
await page.emulateMedia({ colorScheme: "dark", contrast: "no-preference" });
|
||||
const automaticDark = await page.evaluate((INKS) => {
|
||||
delete document.documentElement.dataset.theme;
|
||||
document.documentElement.dataset.contrast = "soft";
|
||||
const p = document.createElement("div");
|
||||
document.body.append(p);
|
||||
const out = INKS.map((n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color));
|
||||
p.remove();
|
||||
return out;
|
||||
}, INKS);
|
||||
check("system dark soft matches explicit dark soft", JSON.stringify(automaticDark) === JSON.stringify(explicit["dark/soft"]));
|
||||
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
await page.emulateMedia({ colorScheme: scheme, contrast: "more" });
|
||||
const same = await page.evaluate((scheme) => {
|
||||
|
||||
@@ -23,6 +23,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.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.includes("工具与权限"))?.click());
|
||||
await page.evaluate(() => document.getElementById("prefs-tools-web")?.click());
|
||||
await page.waitForSelector("#set-web-access");
|
||||
const r = await page.evaluate(() => {
|
||||
const g = document.querySelector("#set-web-access");
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.SHOTS_DIR;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
if (SHOTS) mkdirSync(SHOTS, { recursive: true });
|
||||
|
||||
const measure = (page) => page.locator(".workbench-save").evaluate((button) => {
|
||||
const style = getComputedStyle(button);
|
||||
const bar = button.parentElement;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
const rgba = (color) => {
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
return [...ctx.getImageData(0, 0, 1, 1).data].map((v, i) => i === 3 ? v / 255 : v);
|
||||
};
|
||||
const over = (front, back) => front.slice(0, 3).map((v, i) => v * front[3] + back[i] * (1 - front[3]));
|
||||
const ancestors = [];
|
||||
for (let node = button.parentElement; node; node = node.parentElement) ancestors.unshift(node);
|
||||
let ground = [255, 255, 255];
|
||||
for (const node of ancestors) ground = over(rgba(getComputedStyle(node).backgroundColor), ground);
|
||||
const opacity = Number(style.opacity);
|
||||
const fill = over(rgba(style.backgroundColor), ground);
|
||||
const ink = over(rgba(style.color), fill);
|
||||
const lum = (rgb) => rgb.map((v) => {
|
||||
v /= 255;
|
||||
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
}).reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0);
|
||||
const [hi, lo] = [lum(over([...ink, opacity], ground)), lum(over([...fill, opacity], ground))].sort((a, b) => b - a);
|
||||
const text = [...button.childNodes].find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(text);
|
||||
const width = button.getBoundingClientRect().width;
|
||||
const natural = range.getBoundingClientRect().width + button.querySelector("svg").getBoundingClientRect().width
|
||||
+ parseFloat(style.columnGap) + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
|
||||
+ parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth);
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const children = [...bar.children].map((node) => node.getBoundingClientRect());
|
||||
return {
|
||||
width, natural, contrast: (hi + 0.05) / (lo + 0.05), disabled: button.disabled,
|
||||
singleRow: Math.max(...children.map((r) => r.top)) < Math.min(...children.map((r) => r.bottom)),
|
||||
contained: children.every((r) => r.left >= rect.left - 1 && r.right <= rect.right + 1),
|
||||
};
|
||||
});
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const locale of ["zh", "en"]) {
|
||||
for (const width of [1440, 390]) {
|
||||
for (const desktop of [true, false]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 1000 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(15000);
|
||||
page.on("pageerror", (error) => fails.push(error.message));
|
||||
// Native capabilities render both optional toolbar controls; no native action is invoked.
|
||||
if (desktop) await page.addInitScript(() => {
|
||||
window.reasonixHost = {
|
||||
shell: "electron", platform: "win32", titleBar: false,
|
||||
isWindowMaximised: async () => false, setUnreadBadge: async () => {},
|
||||
revealPath: async () => null, revealWorkspace: async () => null,
|
||||
};
|
||||
});
|
||||
const url = new URL(BASE);
|
||||
url.searchParams.set("pref", locale);
|
||||
url.searchParams.set("ws", "2");
|
||||
url.searchParams.set("sess", "6");
|
||||
url.searchParams.set("turns", "2");
|
||||
await page.goto(url.href, { waitUntil: "networkidle" });
|
||||
const workbench = page.locator('[data-action="pane.view"][data-value="browser"]');
|
||||
if (!(await workbench.isVisible())) await page.locator('[data-action="chrome.more"]').click();
|
||||
await workbench.click();
|
||||
await page.locator('[data-action="workbench.launch-files"]').click();
|
||||
await page.locator(".workbench-filelist button").filter({ hasText: "README.md" }).click();
|
||||
await page.locator(".workbench-save").waitFor();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const tag = `${scheme}/${locale}/${width}/${desktop ? "desktop" : "browser"}`;
|
||||
const verify = async (state) => {
|
||||
await page.locator(".workbench-save").evaluate(async (button) => {
|
||||
await Promise.all(button.getAnimations().map((animation) => animation.finished.catch(() => {})));
|
||||
});
|
||||
const result = await measure(page);
|
||||
check(`${tag}/${state}: content-sized Save`, result.width <= result.natural + 1, `${result.width.toFixed(1)}px vs ${result.natural.toFixed(1)}px content`);
|
||||
check(`${tag}/${state}: controls share one row`, result.singleRow);
|
||||
check(`${tag}/${state}: controls stay inside toolbar`, result.contained);
|
||||
check(`${tag}/${state}: readable label`, result.contrast >= 4.5, `${result.contrast.toFixed(2)}:1`);
|
||||
check(`${tag}/${state}: native disabled state`, result.disabled === (state !== "modified"));
|
||||
if (SHOTS && desktop && locale === "zh") await page.screenshot({ path: `${SHOTS}/${scheme}-${width}-${state}.png` });
|
||||
};
|
||||
await verify("unchanged");
|
||||
await page.locator('[data-action="workbench.mode"][data-value="file"]').click();
|
||||
const editor = page.locator(".cm-content");
|
||||
await editor.fill("# Save button regression\n\nChanged in the editor.");
|
||||
await page.locator(".workbench-save:not(:disabled)").waitFor();
|
||||
await verify("modified");
|
||||
await page.locator(".workbench-save").click();
|
||||
await page.locator(".workbench-save:disabled").waitFor();
|
||||
await verify("saved");
|
||||
await page.locator('[data-action="workbench.mode"][data-value="read"]').click();
|
||||
check(`${tag}: saved content remains readable`, await page.locator(".workbench-read h1").textContent() === "Save button regression");
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`${fails.length} failures: ${fails.join("; ")}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -109,6 +109,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "appearance.session-quick-actions", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.new-answer-marker", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "window.global-shortcut", kind: "shell-native", target: "none", proof: "browser" },
|
||||
{ id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.esc-stop", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.page-sleep", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -296,6 +297,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "toast.report", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "errbar.report", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "toast.dismiss", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "toast.act", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "model.group", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "model.group-clear", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "paste.save", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
@@ -593,6 +595,7 @@ 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.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
|
||||
// arriving from a search is the same arrival as arriving from the nav.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { boot, STORAGE } from "./index";
|
||||
import { say } from "./kernel";
|
||||
import { toolErrorDetail, toolRefusalReason } from "../ui/cards/outcome";
|
||||
import type { Tool } from "../port/wire";
|
||||
|
||||
afterEach(() => { localStorage.setItem(STORAGE, "zh"); boot(); });
|
||||
|
||||
// The calls the host stops before they run. The model keeps the English text;
|
||||
// the person reading the card gets the reason in their own language.
|
||||
const BLOCKED = [
|
||||
"shell.mixed_mutation_verification",
|
||||
"shell.inline_interpreter_not_last",
|
||||
"batch.skipped_after_failed_change",
|
||||
"batch.check_after_stopped_call",
|
||||
];
|
||||
|
||||
describe("calls the host stops before they run", () => {
|
||||
for (const language of ["zh", "en"]) {
|
||||
it(`are worded in ${language}, and the card shows that instead of the English line`, () => {
|
||||
localStorage.setItem(STORAGE, language);
|
||||
boot();
|
||||
for (const code of BLOCKED) {
|
||||
const tool = { name: "bash", args: {}, err: "blocked: raw English wording", refusalCode: code } as unknown as Tool;
|
||||
expect(say({ code }, ""), code).not.toBe("");
|
||||
expect(toolRefusalReason(tool), code).not.toBe("");
|
||||
expect(toolErrorDetail(tool), `${code}: the English line is not drawn when the reason is`).toBe("");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -577,6 +577,9 @@ export const EN: Record<string, string> = {
|
||||
|
||||
// ── 会话树 ───────────────────────────────────────────────────────
|
||||
"新会话": "New session",
|
||||
"这个会话的历史超过了安全读取上限,没有打开(文件完好,未改动)。可以只取最近的内容,另存为新会话继续。": "This session's history is over the safe read limit, so it was not opened (the file is intact and unchanged). You can carry only its most recent part over into a new session.",
|
||||
"从最近的内容继续": "Continue from the most recent part",
|
||||
"已从最近的内容另存为新会话,较早的 {n} 条没有带入;原会话文件保持不变。": "Saved the most recent part as a new session; {n} earlier messages were not carried over. The original session file is unchanged.",
|
||||
"标签数已到上限 {n},已把「{title}」从标签栏收起,对话仍在列表里。": "The open-session limit ({n}) was reached, so \"{title}\" was folded out of the tab bar. The conversation is still in the list.",
|
||||
"打开项目文件夹": "Open project folder",
|
||||
"新会话 {n}": "New session {n}",
|
||||
@@ -603,8 +606,10 @@ export const EN: Record<string, string> = {
|
||||
"读取代码、运行测试与修改文件均只在你选定的文件夹内进行。": "Reading code, running tests and editing files all happen inside the folder you choose.",
|
||||
"使用当前位置": "Stay where I am",
|
||||
"隔离": "Isolated",
|
||||
"原目录": "Local checkout",
|
||||
"隔离目录": "Worktree",
|
||||
"独立工作树": "Isolated worktree",
|
||||
"直接在已打开的项目文件夹中工作,与使用该目录的其他会话共享文件。": "Work directly in the opened project folder, sharing files with other sessions using it.",
|
||||
"在单独的 Git 工作目录中修改代码;端口、数据库等运行资源仍可能共享。": "Edit code in a separate Git working directory; ports, databases, and other runtime resources may still be shared.",
|
||||
"发送消息时创建独立工作树,基于所选分支的已提交版本,不包含未提交修改;端口、数据库等运行资源仍可能共享。": "Create an isolated worktree when you send the message, using the selected branch's committed version without uncommitted changes; ports, databases, and other runtime resources may still be shared.",
|
||||
"移除": "Remove",
|
||||
"删除": "Delete",
|
||||
"删除该会话": "Delete this session",
|
||||
@@ -755,9 +760,6 @@ export const EN: Record<string, string> = {
|
||||
"代码块、命令、差异与工具输出共用这一等宽字体,不跟随界面主题改变。":
|
||||
"Code blocks, commands, diffs and tool output share this monospace font and do not change with the interface theme.",
|
||||
"等宽字体": "Monospace font",
|
||||
"更多个性化": "More personalization",
|
||||
"已设置自定义壁纸": "Custom wallpaper set",
|
||||
"壁纸与背景位置": "Wallpaper and background position",
|
||||
"文字粗细": "Text weight",
|
||||
"视觉效果": "Visual effects",
|
||||
"窗口持续占用显卡时,改为「节能」:界面不变,只去掉磨砂玻璃、动态天空和颗粒这几层。": "If the window keeps your GPU busy, choose Reduced: the layout stays as it is, only frosted glass, the animated sky and the grain layer go.",
|
||||
@@ -966,7 +968,6 @@ export const EN: Record<string, string> = {
|
||||
"返回正文": "Back to the conversation",
|
||||
"完成": "Done",
|
||||
"快捷键": "Keyboard shortcuts",
|
||||
"下列按键在窗口中生效;标着 ⌘ 的地方,Windows 与 Linux 上是 Ctrl。": "These work anywhere in the window. Where a chord shows ⌘, Windows and Linux use Ctrl.",
|
||||
"在对话里查找": "Find in the conversation",
|
||||
"搜索会话": "Search conversations",
|
||||
"在提问之间移动": "Move between your questions",
|
||||
|
||||
@@ -59,13 +59,10 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"创建已取消,任务尚未发送": "Creation was cancelled. The task has not been sent.",
|
||||
"创建等待超时,任务和附件已保留,可查看详情后重试": "Workspace creation timed out. The draft and attachments are retained. Check the details before retrying.",
|
||||
"配置工作目录": "Configure workspace",
|
||||
"独立 worktree · {branch}": "Isolated worktree · {branch}",
|
||||
"当前目录 · {branch}": "Current folder · {branch}",
|
||||
"新建 worktree": "New worktree",
|
||||
"独立 worktree": "Isolated worktree",
|
||||
"独立工作树 · {branch}": "Isolated worktree · {branch}",
|
||||
"当前目录": "Current folder",
|
||||
"此会话已绑定独立 worktree。后续任务继续使用此目录。": "This session is bound to an isolated worktree. Future tasks will use this folder.",
|
||||
"如需创建新的 worktree,请在源目录新建会话。需继续本会话时可直接切换已有分支。": "To create another worktree, start a new session in the source folder. You can switch existing branches to continue this session.",
|
||||
"使用当前目录": "Use current folder",
|
||||
"关闭工作目录配置": "Close workspace configuration",
|
||||
"发送消息时开始创建。": " Creation starts when you send the message.",
|
||||
"选择分支会立即切换,并影响共享当前目录的其他会话。": "Switching branches takes effect immediately and affects other sessions sharing this folder.",
|
||||
@@ -78,7 +75,6 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"选择起始分支": "Choose source branch",
|
||||
"搜索分支": "Search branches",
|
||||
"搜索分支…": "Search branches…",
|
||||
"当前目录": "Current folder",
|
||||
"查看完整路径": "Show full path",
|
||||
"复制完整路径": "Copy full path",
|
||||
"复制操作编号": "Copy operation ID",
|
||||
@@ -112,6 +108,8 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"正在发送…": "Sending…",
|
||||
"描述任务、问题或要改的内容…": "Describe a task, question, or change…",
|
||||
"描述任务…": "Describe a task…",
|
||||
"继续输入以排队后续修改": "Keep typing to queue further changes",
|
||||
"继续输入…": "Keep typing…",
|
||||
"任务输入": "Task input",
|
||||
"命令与技能": "Commands and skills",
|
||||
"正在加载命令与技能…": "Loading commands and skills…",
|
||||
|
||||
@@ -13,6 +13,10 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"该路径指向网络上的另一台机器,不在当前工作区内。文件工具只按路径写法拒绝它,不会去访问;工作区本身在网络共享上时,其下的路径可用": "That path names another machine on the network and is outside this workspace. File tools refuse it by its spelling and never look it up; paths below a workspace that is itself on a network share are allowed",
|
||||
"内置浏览器不会打开指向网络上另一台机器的路径,只按路径写法拒绝,不会去访问": "The built-in browser does not open a path that names another machine on the network; it is refused by its spelling and never looked up",
|
||||
"无法读取目标文件:它在你配置的禁读列表里。": "Cannot read the target file: it is on the forbidden-read list you configured.",
|
||||
"这条命令把一个宿主无法确认不改动工作区的步骤和验证检查写在了一起,已拦截,没有运行。请把它们拆成两次调用:先改动,再单独验证。": "This command puts a step the host cannot confirm leaves the workspace alone together with a verification check, so it was blocked and did not run. Split it into two calls: change first, then verify on its own.",
|
||||
"这条命令里的内联解释器不是最后一段,后面的步骤会决定整条命令的退出状态,解释器里的失败会被当成成功,已拦截,没有运行。请把它放在最后,或单独运行。": "The inline interpreter in this command is not its last segment, so later steps decide the exit status and a failure inside the interpreter would read as success. It was blocked and did not run. Put it last, or run it on its own.",
|
||||
"同一批调用里前面的修改失败或被拦截了,所以这一步没有执行。请先处理那处修改,再重试。": "An earlier change in the same batch of calls failed or was blocked, so this step did not run. Deal with that change first, then retry.",
|
||||
"同一批调用里前面有一次调用在运行前就被停止了,这次检查测不出你要的状态,所以没有执行。请先重跑被停止的调用,或单独运行这次检查。": "An earlier call in the same batch was stopped before it ran, so this check would not measure the state you asked for and did not run. Re-run the stopped call first, or run this check on its own.",
|
||||
"无法读取目标文件:它不在这个会话允许读取的文件夹里。": "Cannot read the target file: it is outside the folders this session may read.",
|
||||
"读视频需要本机装有 ffmpeg(含 ffprobe)并能在系统路径里找到。装好后再试一次。": "Reading a video needs ffmpeg (with ffprobe) installed and on the system path. Install it and try again.",
|
||||
"不认识这种视频格式。支持 MP4/MOV、MKV/WebM、AVI、FLV、ASF、MPEG 和 Ogg 这些常见封装。": "This video format is not recognised. MP4/MOV, MKV/WebM, AVI, FLV, ASF, MPEG and Ogg containers are supported.",
|
||||
@@ -477,6 +481,8 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"打不开这个会话": "Could not open that conversation",
|
||||
"该路径位于会话目录之外": "That path is outside the conversations directory",
|
||||
"没有这个会话": "No such conversation",
|
||||
"这个会话的历史超过了安全读取上限,没有打开;文件完好未改动": "This conversation's history is over the safe read limit, so it was not opened; the file is intact and unchanged",
|
||||
"没能取出这个会话最近的内容": "Could not take the most recent part of this conversation",
|
||||
"该地址不接受此种请求方式": "That address does not accept this request method",
|
||||
"请求体必须是 application/json": "The request body must be application/json",
|
||||
"这台服务器未开放权限编辑": "This server does not open permission editing",
|
||||
|
||||
@@ -2,6 +2,23 @@
|
||||
// alone — one catalogue is still the model, and this file is read as its
|
||||
// continuation: same keying by Chinese source text, same grouping by screen.
|
||||
export const EN_SETTINGS: Record<string, string> = {
|
||||
"全局显示 / 隐藏快捷键": "Global show / hide shortcut",
|
||||
"默认关闭。仅在 Studio 运行时生效;窗口聚焦时隐藏,否则显示并聚焦。不会启动已退出的应用,也不更改窗口内快捷键。": "Off by default. While Studio is running, hide its focused window or show and focus it otherwise. Does not launch a stopped app or change in-app shortcuts.",
|
||||
"快捷键组合": "Shortcut binding",
|
||||
"点击录制后按下修饰键与按键组合,或直接编辑快捷键。Esc 取消录制;清除会立即关闭快捷键。Wayland 或系统保留组合可能不受支持。": "Click Record and press a modifier and key combination, or edit the shortcut directly. Esc cancels recording; Clear immediately disables the shortcut. Wayland or OS-reserved bindings may not be supported.",
|
||||
"请按修饰键与按键组合…": "Press a modifier and key combination…",
|
||||
"录制快捷键": "Record shortcut",
|
||||
"应用快捷键": "Apply shortcut",
|
||||
"清除快捷键": "Clear shortcut",
|
||||
"全局快捷键已关闭": "Global shortcut disabled",
|
||||
"已注册:{value}": "Registered: {value}",
|
||||
"快捷键格式无效,请使用修饰键与按键组合,例如 CommandOrControl+Shift+Space。": "Invalid accelerator. Use a modifier and key combination, e.g. CommandOrControl+Shift+Space.",
|
||||
"系统未能注册此快捷键:可能已被占用、未获授权,或当前桌面不支持。": "The OS could not register this shortcut: it may be occupied, denied permission, or unsupported by this desktop.",
|
||||
"当前环境不支持全局快捷键。": "Global shortcuts are not supported in this environment.",
|
||||
"快捷键未能保存;原设置保持不变。": "Could not save the shortcut; the previous setting is unchanged.",
|
||||
"Studio 正在退出,未更改快捷键。": "Studio is quitting; the shortcut was not changed.",
|
||||
"无法读取或更改 shell 快捷键状态。请重新打开设置后再试。": "Could not read or change shell shortcut status. Reopen settings to try again.",
|
||||
"下列按键在窗口中生效;标着 ⌘ 的地方,Windows 与 Linux 上是 Ctrl。全局显示 / 隐藏快捷键在上方单独配置,仅桌面 shell 支持。": "These keys work inside the window; ⌘ means Ctrl on Windows and Linux. Configure global show / hide separately above, in the desktop shell only.",
|
||||
"加载中…": "Loading…",
|
||||
"AI 文件交付卡片": "AI file delivery cards",
|
||||
"启用 AI 文件交付卡片": "Enable AI file delivery cards",
|
||||
@@ -127,6 +144,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"项目配置覆盖了这一项;此处保存的是全局设置。": "The project config overrides this; what you set here is saved globally.",
|
||||
"尚无可用搜索模型": "No search models available",
|
||||
"{model}(不可用)": "{model} (unavailable)",
|
||||
"{model}(未声明读图)": "{model} (not declared to read images)",
|
||||
"模型用途": "Model uses",
|
||||
"用途": "Use",
|
||||
"使用模型": "Model",
|
||||
@@ -756,6 +774,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"摘要请求用了 {in} 输入、{out} 输出 token,缓存命中 {hit}。": "The summary request used {in} input and {out} output tokens, {hit} from cache.",
|
||||
"无需压缩:{why}": "Nothing to compact: {why}",
|
||||
"压缩失败:{why}": "Compaction failed: {why}",
|
||||
"你有 {n} 条较早的消息过长,没能完整保留,现在只通过摘要保留。在消息开头加上 [[keep]] 可以让它原样保留。": "{n} earlier message(s) of yours were too large to keep whole and now survive only through the summary. Prefix a message with [[keep]] to hold it verbatim.",
|
||||
"你用 [[keep]] 标记的消息中,有 {n} 条超出了 [[keep]] 的容量,最早的几条现在只通过摘要保留。": "{n} message(s) you marked [[keep]] went past the room [[keep]] has, so the oldest now survive only through the summary.",
|
||||
"子代理「{task}」在等 {by} 先结束:{why}": "Sub-agent \"{task}\" is waiting for {by} to finish: {why}",
|
||||
"另一个子代理": "another sub-agent",
|
||||
"两者都没声明 write_paths,各自按整个工作区处理": "neither declared write_paths, so each is treated as writing the whole workspace",
|
||||
@@ -1003,6 +1023,14 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"关闭窗口后在托盘中继续运行": "Keep running in the tray when the window is closed",
|
||||
"关闭窗口不会中断会话和后台任务;从托盘菜单退出才会完全关闭程序": "Closing the window does not stop sessions or background tasks; use Quit in the tray menu to exit completely",
|
||||
"本轮执行": "This turn",
|
||||
"批准与规则": "Approval and rules",
|
||||
"沙箱与执行": "Sandbox and execution",
|
||||
"联网与浏览器": "Web and browser",
|
||||
"子代理与协作": "Sub-agents and collaboration",
|
||||
"外观与主题": "Appearance and theme",
|
||||
"会话显示": "Conversation display",
|
||||
"导航与侧栏": "Navigation and sidebar",
|
||||
"窗口与性能": "Window and performance",
|
||||
"运行环境": "Where it runs",
|
||||
"记录与用量": "What it remembers",
|
||||
"这台机器": "This machine",
|
||||
@@ -1352,6 +1380,9 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"图标栏": "Icon rail",
|
||||
"导航入口": "Navigation entries",
|
||||
"放大设置窗口": "Enlarge the settings window",
|
||||
"隐藏共同前缀": "Hide the shared prefix",
|
||||
"{name} 的模型名前缀": "Model name prefix of {name}",
|
||||
"路由类服务给每个模型加的同一段前缀(如 group/)在显示时省略,只改显示;模型 ID 不变,自己填的别名优先,保存后生效。": "The same prefix a routing service puts on every model (such as group/) is left out of how they are drawn. Display only: model ids do not change, an alias you typed wins, and it takes effect on save.",
|
||||
"缩小设置窗口": "Shrink the settings window",
|
||||
"图标栏和侧栏里直达各页面的入口:可以隐藏,也可以调整先后。会话、反馈、账号和设置始终保留。": "The shortcuts to pages in the icon rail and the sidebar: hide any of them or change their order. Sessions, feedback, account and settings are always kept.",
|
||||
"还原排列": "Reset arrangement",
|
||||
|
||||
@@ -31,6 +31,14 @@ export const EN_USAGE: Record<string, string> = {
|
||||
"美元 USD": "US dollar (USD)",
|
||||
"无法读取币种设置。": "Could not read the currency setting.",
|
||||
"用量与成本": "Usage & cost",
|
||||
"上传量": "Upload volume",
|
||||
"发给模型服务商的请求内容大小(不含连接本身的开销,也不含下载)。每次请求都会带上完整的对话,所以它取决于上下文有多长、请求有多少次,缓存命中只省费用、不省流量。":
|
||||
"The size of the request bodies sent to model providers (without the connection's own overhead, and without downloads). Every request carries the whole conversation, so it depends on how long the context is and how many requests are made; cache hits save cost, not bandwidth.",
|
||||
"还没有记录:从这个版本起开始统计。": "Nothing recorded yet: counting starts with this version.",
|
||||
"今天上传": "Uploaded today",
|
||||
"近 {n} 天上传": "Uploaded in the last {n} days",
|
||||
"{size},{n} 次请求,平均每次 {avg}": "{size} in {n} requests, {avg} each on average",
|
||||
"{size}({share})": "{size} ({share})",
|
||||
"本机记录的 token 用量与花费,仅保存在这台机器上,不会上传。命中缓存的输入按缓存价计费,因此命中率直接影响费用。":
|
||||
"Token usage and cost recorded on this machine only; nothing is uploaded. Input that hits the cache is billed at the cache rate, so the hit rate drives the bill.",
|
||||
"正在读取记录…": "Reading the records…",
|
||||
@@ -62,6 +70,7 @@ export const EN_USAGE: Record<string, string> = {
|
||||
"花费": "Spend",
|
||||
"含估算": "includes estimates",
|
||||
"未定价调用未计入": "unpriced calls not included",
|
||||
"其中 {n} 笔未定价": "{n} calls unpriced",
|
||||
"{day} 起 · {n} 天有记录": "from {day} · {n} days recorded",
|
||||
"这段时间 {tok} tokens 里有 {rate}% 命中前缀缓存 —— 命中的部分按缓存价计费,比未命中便宜一个量级。": "{rate}% of these {tok} tokens hit the prefix cache — a hit bills at the cache rate, an order of magnitude under a miss.",
|
||||
"命中 {a} · 未命中 {b}": "{a} hit · {b} missed",
|
||||
|
||||
@@ -123,6 +123,10 @@ const SAID: Record<string, string> = {
|
||||
"workspace.root_not_granted": "这个文件夹没有授权给当前工作区",
|
||||
"workspace.target_access_unconfigured": "未配置文件访问策略,无法执行此文件操作。",
|
||||
"workspace.read_forbidden": "无法读取目标文件:它在你配置的禁读列表里。",
|
||||
"shell.mixed_mutation_verification": "这条命令把一个宿主无法确认不改动工作区的步骤和验证检查写在了一起,已拦截,没有运行。请把它们拆成两次调用:先改动,再单独验证。",
|
||||
"shell.inline_interpreter_not_last": "这条命令里的内联解释器不是最后一段,后面的步骤会决定整条命令的退出状态,解释器里的失败会被当成成功,已拦截,没有运行。请把它放在最后,或单独运行。",
|
||||
"batch.skipped_after_failed_change": "同一批调用里前面的修改失败或被拦截了,所以这一步没有执行。请先处理那处修改,再重试。",
|
||||
"batch.check_after_stopped_call": "同一批调用里前面有一次调用在运行前就被停止了,这次检查测不出你要的状态,所以没有执行。请先重跑被停止的调用,或单独运行这次检查。",
|
||||
"workspace.read_outside_scope": "无法读取目标文件:它不在这个会话允许读取的文件夹里。",
|
||||
"video.ffmpeg_missing": "读视频需要本机装有 ffmpeg(含 ffprobe)并能在系统路径里找到。装好后再试一次。",
|
||||
"video.unsupported_container": "不认识这种视频格式。支持 MP4/MOV、MKV/WebM、AVI、FLV、ASF、MPEG 和 Ogg 这些常见封装。",
|
||||
@@ -170,6 +174,8 @@ const SAID: Record<string, string> = {
|
||||
"session.open_failed": "打不开这个会话",
|
||||
"session.outside_dir": "该路径位于会话目录之外",
|
||||
"session.unknown": "没有这个会话",
|
||||
"session.replay_limit": "这个会话的历史超过了安全读取上限,没有打开;文件完好未改动",
|
||||
"session.tail_failed": "没能取出这个会话最近的内容",
|
||||
"request.method_not_allowed": "该地址不接受此种请求方式",
|
||||
"request.bad_content_type": "请求体必须是 application/json",
|
||||
"permissions.editing_disabled": "这台服务器未开放权限编辑",
|
||||
|
||||
@@ -17,6 +17,8 @@ const PAYLOAD: Record<string, string | undefined> = {
|
||||
compaction_telemetry: JSON.stringify({ mode: "summarized", source_tokens: 122525, projection_tokens: 61192, input_tokens: 40710, output_tokens: 8186, cache_hit_tokens: 256, request_count: 1 }),
|
||||
context_composition: JSON.stringify({ total: 561061, at: 419430, active: 400000, parts: [{ name: "tool_output", tokens: 500000 }] }),
|
||||
subagent_waiting: JSON.stringify({ task: "fix the typo", by: "rewrite the docs", whole: true }),
|
||||
user_turns_dropped: JSON.stringify({ count: 5, tokens: 1578, budget: 8000 }),
|
||||
kept_turns_folded: JSON.stringify({ count: 2, tokens: 900, budget: 4000 }),
|
||||
display_currency: "USD",
|
||||
compact_declined: undefined,
|
||||
compact_failed: "summary_failed",
|
||||
@@ -70,6 +72,8 @@ describe("notice wording", () => {
|
||||
["job_stalled", '{"id":"j","quietSeconds":"900"}'],
|
||||
["job_stalled", '{"id":"j","quietSeconds":0}'],
|
||||
["compaction_telemetry", '{"mode":"summarized"}'],
|
||||
["user_turns_dropped", '{"count":0}'],
|
||||
["kept_turns_folded", "not json"],
|
||||
["compaction_telemetry", '{"mode":"","source_tokens":1,"projection_tokens":1}'],
|
||||
["compaction_telemetry", '{"mode":"summarized","source_tokens":"122525","projection_tokens":1}'],
|
||||
["compact_failed", "no_such_reason"],
|
||||
|
||||
@@ -7,6 +7,7 @@ import { EXTENSION_SKIPPED, extensionSkippedVars } from "./extension_skipped";
|
||||
import { INBOX_RECOVERED, inboxRecoveredVars } from "./inbox_recovered";
|
||||
import { JOB_FAILED, JOB_FINISHED, JOB_STARTED, JOB_KILLED, JOB_STALLED, jobErrorText, jobNoticeVars, jobStalledVars } from "./job_notice";
|
||||
import { NOTICE_TEXT } from "./notices";
|
||||
import { KEPT_TURNS_FOLDED, USER_TURNS_DROPPED, turnsDroppedVars } from "./turns_dropped";
|
||||
import { PERMISSION_RULES_DORMANT, permissionRulesDormantVars } from "./permission_rules_dormant";
|
||||
|
||||
export type NoticeVars = Record<string, string | number>;
|
||||
@@ -30,6 +31,8 @@ const VARS: Record<string, (detail: string | undefined) => NoticeVars | undefine
|
||||
[COMPACTION_TELEMETRY]: compactionNoticeVars,
|
||||
[CONTEXT_COMPOSITION]: contextCompositionVars,
|
||||
[SUBAGENT_WAITING]: subagentWaitingVars,
|
||||
[USER_TURNS_DROPPED]: turnsDroppedVars,
|
||||
[KEPT_TURNS_FOLDED]: turnsDroppedVars,
|
||||
display_currency: (detail) => ({ mode: detail || "auto" }),
|
||||
compact_declined: compactReason("compact_declined"),
|
||||
compact_failed: compactReason("compact_failed"),
|
||||
|
||||
@@ -51,6 +51,8 @@ export const NOTICE_TEXT: Record<string, string> = {
|
||||
compact_declined: "无需压缩:{why}",
|
||||
compact_failed: "压缩失败:{why}",
|
||||
context_composition: "上下文约 {total},已超过压缩阈值 {at},没有更多可折叠的内容。占用最多:{top}。{active}",
|
||||
user_turns_dropped: "你有 {n} 条较早的消息过长,没能完整保留,现在只通过摘要保留。在消息开头加上 [[keep]] 可以让它原样保留。",
|
||||
kept_turns_folded: "你用 [[keep]] 标记的消息中,有 {n} 条超出了 [[keep]] 的容量,最早的几条现在只通过摘要保留。",
|
||||
subagent_waiting: "子代理「{task}」在等 {by} 先结束:{why}",
|
||||
compact_held: "自动压缩暂缓,上次尝试没有完成:{why}",
|
||||
permission_rules_dormant: "有 {n} 条权限规则没有对应的工具,匹配不到任何调用,因此起不到限制作用(如「{list}」里的 {rule});到「设置 → 权限」里删除或改写,命令要写成 Bash(命令:*)",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export const USER_TURNS_DROPPED = "user_turns_dropped";
|
||||
export const KEPT_TURNS_FOLDED = "kept_turns_folded";
|
||||
|
||||
// The typed payload these notices carry as their detail, read as the variables
|
||||
// their sentences are worded from. A detail that is not one reads as none, so the
|
||||
// notice keeps the kernel's English.
|
||||
export function turnsDroppedVars(detail: string | undefined): Record<string, string> | undefined {
|
||||
if (!detail) return undefined;
|
||||
let p: { count?: unknown };
|
||||
try {
|
||||
p = JSON.parse(detail) as { count?: unknown };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return typeof p.count === "number" && p.count > 0 ? { n: String(p.count) } : undefined;
|
||||
}
|
||||
@@ -24,6 +24,14 @@ export interface GraphicsInfo {
|
||||
compositing: string;
|
||||
}
|
||||
|
||||
export interface GlobalShortcutInfo {
|
||||
binding: string;
|
||||
status: "disabled" | "registered" | "invalid" | "unavailable" | "unsupported";
|
||||
error?: "invalid" | "unavailable" | "save" | "quitting";
|
||||
}
|
||||
|
||||
const unsupportedShortcut: GlobalShortcutInfo = { binding: "", status: "unsupported" };
|
||||
|
||||
export interface TextContextMenuRequest {
|
||||
editable: boolean;
|
||||
selected: boolean;
|
||||
@@ -52,6 +60,8 @@ export interface HostPort {
|
||||
captureWindow(): Promise<Uint8Array | null>;
|
||||
/** How the shell draws this launch; null where it has no say, as in a tab. */
|
||||
graphics(): Promise<GraphicsInfo | null>;
|
||||
globalShortcut(): Promise<GlobalShortcutInfo>;
|
||||
setGlobalShortcut(binding: string): Promise<GlobalShortcutInfo>;
|
||||
/** The shell's own processes as a type and two sizes each, for the memory a
|
||||
* report may carry; empty where there is no shell to ask, as in a tab. */
|
||||
processMemory(): Promise<FeedbackProcessMemory[]>;
|
||||
@@ -181,6 +191,8 @@ interface ElectronBridge {
|
||||
closeWindow(): Promise<void>;
|
||||
captureWindow?(): Promise<Uint8Array | null>;
|
||||
graphics?(): Promise<GraphicsInfo | null>;
|
||||
globalShortcut?(): Promise<GlobalShortcutInfo | null>;
|
||||
setGlobalShortcut?(binding: string): Promise<GlobalShortcutInfo | null>;
|
||||
processMemory?(): Promise<unknown>;
|
||||
openExternal(url: string): Promise<void>;
|
||||
setUnreadBadge?(unread: boolean): Promise<void>;
|
||||
@@ -250,6 +262,12 @@ class ElectronHost implements HostPort {
|
||||
graphics() {
|
||||
return this.api.graphics?.().catch(() => null) ?? Promise.resolve(null);
|
||||
}
|
||||
globalShortcut() {
|
||||
return this.api.globalShortcut?.().then(value => value ?? unsupportedShortcut) ?? Promise.resolve(unsupportedShortcut);
|
||||
}
|
||||
setGlobalShortcut(binding: string) {
|
||||
return this.api.setGlobalShortcut?.(binding).then(value => value ?? unsupportedShortcut) ?? Promise.resolve(unsupportedShortcut);
|
||||
}
|
||||
processMemory() {
|
||||
return this.api.processMemory?.().then((rows: unknown) => (Array.isArray(rows) ? (rows as FeedbackProcessMemory[]) : [])).catch(() => []) ?? Promise.resolve([]);
|
||||
}
|
||||
@@ -358,6 +376,12 @@ class BrowserHost implements HostPort {
|
||||
graphics() {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
globalShortcut() {
|
||||
return Promise.resolve(unsupportedShortcut);
|
||||
}
|
||||
setGlobalShortcut() {
|
||||
return Promise.resolve(unsupportedShortcut);
|
||||
}
|
||||
processMemory() {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
// Host selection is cached at module load/use; each test needs a fresh module.
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("does not grant browser or network viewers a host-machine shortcut", async () => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("window", {});
|
||||
const { host } = await import("./host");
|
||||
expect(await host().globalShortcut()).toEqual({ binding: "", status: "unsupported" });
|
||||
expect(await host().setGlobalShortcut("CommandOrControl+Shift+Space")).toEqual({ binding: "", status: "unsupported" });
|
||||
});
|
||||
|
||||
it("asks the local Electron shell immediately, without browser storage", async () => {
|
||||
vi.resetModules();
|
||||
const state = { binding: "Control+Shift+Space", status: "registered" };
|
||||
const apply = vi.fn(async () => state);
|
||||
vi.stubGlobal("window", { reasonixHost: { shell: "electron", platform: "linux", globalShortcut: async () => state, setGlobalShortcut: apply } });
|
||||
const { host } = await import("./host");
|
||||
expect(await host().globalShortcut()).toEqual(state);
|
||||
expect(await host().setGlobalShortcut(state.binding)).toEqual(state);
|
||||
expect(apply).toHaveBeenCalledWith(state.binding);
|
||||
});
|
||||
@@ -14,6 +14,20 @@ export interface ThemeBackground {
|
||||
taskOpacity: number;
|
||||
overlayStrength: number;
|
||||
textGlow?: "default" | "none";
|
||||
surfaceOpacity?: ThemeSurfaceOpacities;
|
||||
}
|
||||
|
||||
export interface ThemeSurfaceOpacities {
|
||||
light?: ThemeSurfaceOpacity;
|
||||
dark?: ThemeSurfaceOpacity;
|
||||
}
|
||||
|
||||
export interface ThemeSurfaceOpacity {
|
||||
nav?: number;
|
||||
rail?: number;
|
||||
chrome?: number;
|
||||
side?: number;
|
||||
compose?: number;
|
||||
}
|
||||
|
||||
// A live backdrop the window draws, rather than a picture it places. Absent on
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
|
||||
import type { JobOutput, PlanAction } from "./session";
|
||||
import { approvalKind } from "./mock_approval";
|
||||
import { HttpError } from "./port";
|
||||
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
|
||||
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, UploadReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import { MockWorkspace, mockWorkspaces, mockWorktreeSources, MockPreparations } from "./mock_workspace";
|
||||
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
|
||||
@@ -11,7 +11,7 @@ import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
|
||||
import { mockStorage, mockStoragePlan } from "./mock_storage";
|
||||
import { MEMORIES, mockMemoryCatalog } from "./mock_memory";
|
||||
import { mockModels } from "./mock_models";
|
||||
import { mockUsage } from "./mock_usage";
|
||||
import { mockUploadUsage, mockUsage } from "./mock_usage";
|
||||
|
||||
export class MockPort extends MockWorkspace implements AgentPort {
|
||||
private listeners = new Set<(ev: WireEvent) => void>();
|
||||
@@ -112,9 +112,9 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
|
||||
private mem: MemoryEntry[] = MEMORIES.map((m) => ({ ...m }));
|
||||
|
||||
async usage(query: number | UsageQuery): Promise<UsageReport> {
|
||||
return mockUsage(query);
|
||||
}
|
||||
async usage(query: number | UsageQuery): Promise<UsageReport> { return mockUsage(query); }
|
||||
|
||||
uploadUsage(days: number): Promise<UploadReport> { return Promise.resolve(mockUploadUsage(days)); }
|
||||
|
||||
async memories(): Promise<MemoryCatalog> {
|
||||
return mockMemoryCatalog(this.mem);
|
||||
@@ -416,6 +416,10 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
return `/mock/branch-${turn}.jsonl`;
|
||||
}
|
||||
|
||||
async continueFromTail(path: string): Promise<{ path: string; dropped: number }> {
|
||||
return { path: path.replace(/\.jsonl$/, "-tail.jsonl"), dropped: 12 };
|
||||
}
|
||||
|
||||
// The second prompt onwards is scripted to have run bash, so mock mode can
|
||||
// show the consent stage the real kernel demands on partial coverage.
|
||||
async prepareRewind(turn: number, scope: RewindScope): Promise<RewindPlan> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UsageHour, UsageQuery, UsageReport } from "./port";
|
||||
import type { UploadReport, UsageHour, UsageQuery, UsageReport } from "./port";
|
||||
import { USAGE_PRICED, USAGE_TOKENS } from "./mock_memory";
|
||||
import { DEFAULT_USAGE_DAYS } from "./usage";
|
||||
|
||||
@@ -108,3 +108,20 @@ export function mockUsage(query: number | UsageQuery): UsageReport {
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// A fortnight of uploads with quiet days, two providers and one heavy day.
|
||||
export function mockUploadUsage(days: number): UploadReport {
|
||||
const shown = Math.min(days, 14);
|
||||
const daily = Array.from({ length: shown }, (_, i) => ({
|
||||
day: new Date(Date.now() - (shown - 1 - i) * DAY_MS).toISOString().slice(0, 10),
|
||||
bytes: [0, 18e6, 240e6, 410e6, 95e6, 0, 0, 120e6, 760e6, 330e6, 40e6, 0, 210e6, 150e6][i] ?? 0,
|
||||
}));
|
||||
const total = daily.reduce((n, d) => n + d.bytes, 0);
|
||||
return {
|
||||
from: daily[0]?.day ?? "", to: daily[daily.length - 1]?.day ?? "", bytes: total, requests: 1280, daily,
|
||||
providers: [
|
||||
{ provider: "deepseek", bytes: Math.round(total * 0.86), requests: 1100, percent: 86 },
|
||||
{ provider: "relay-work", bytes: Math.round(total * 0.14), requests: 180, percent: 14 },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -56,6 +56,8 @@ export interface ModelEntry {
|
||||
modelAlias?: string;
|
||||
// The vendor slug of the preset this provider came from; absent when none declared it.
|
||||
mark?: string;
|
||||
// The model as it reads with its entry's shared prefix left out; absent draws it as it is.
|
||||
modelShort?: string;
|
||||
// The service's own vendor slug; differs from mark on a relay, whose models are others'.
|
||||
serviceMark?: string;
|
||||
kind?: string;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
|
||||
import type { AgentComposition, AgentPresetVersion, AgentPresetCatalog, CompositionSummary } from "./composition";
|
||||
export type * from "./composition";
|
||||
import { DeliveryError, HttpError } from "./http_error";
|
||||
import type { DeliveryFault } from "./http_error";
|
||||
import { DeliveryError, HttpError, type DeliveryFault } from "./http_error";
|
||||
import type { SideProgress } from "./side";
|
||||
|
||||
/** What `subscribe` hands back: the disposer, and where the transport has one, a
|
||||
@@ -20,10 +19,10 @@ export type { AdjudicationEntry, AdjudicationState, Adjudications } from "./adju
|
||||
import type { HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource } from "./hook";
|
||||
import type { CapabilityScope, McpCatalog, McpDraft, McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, ScopeLayer } from "./mcp";
|
||||
import type { MemoryCatalog, MemoryEdit, MemoryEntry, UnreadableMemory } from "./memory";
|
||||
import type { UsageQuery, UsageReport } from "./usage";
|
||||
import type { UploadUsagePort, UsageQuery, UsageReport } from "./usage";
|
||||
export { DEFAULT_USAGE_DAYS } from "./usage";
|
||||
export type { MemoryEdit } from "./memory";
|
||||
export type { Money, UsageDay, UsageHour, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage";
|
||||
export type { Money, UploadDay, UploadProvider, UploadReport, UsageDay, UsageHour, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage";
|
||||
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments, RoleOverride } from "./model";
|
||||
import type { NetworkProbe, NetworkSettings } from "./network";
|
||||
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, JobOutput, Preset, RewindPlan, ToolOutputPage, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, SessionUsage, SourceUsage, WalletLine, WalletReading, PlanAction } from "./session";
|
||||
@@ -32,6 +31,7 @@ import type { SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan } fro
|
||||
import type { SubagentRun, SubagentTranscript } from "./subagent";
|
||||
import type { StudioCrash, StudioLogs, UpdateMode, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
|
||||
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo, WorkspacePreviewPort } from "./workspace";
|
||||
import type { SessionTailPort } from "./session_tail";
|
||||
|
||||
// The port is one contract; its subjects each keep their own file, the way the
|
||||
// wire and the layers below already do. This is where a reader still finds
|
||||
@@ -183,7 +183,7 @@ export interface TrayPrefs {
|
||||
closeToTray: boolean;
|
||||
}
|
||||
|
||||
export interface AgentPort extends WorkspacePreviewPort {
|
||||
export interface AgentPort extends WorkspacePreviewPort, SessionTailPort, UploadUsagePort {
|
||||
providerSetup(): Promise<ProviderSetup | null>;
|
||||
saveProviderKey(apiKey: string): Promise<void>;
|
||||
// The models that answer the job asked about; "chat" when none is named.
|
||||
|
||||
@@ -12,6 +12,10 @@ export interface ProviderEntry {
|
||||
baseUrl: string;
|
||||
models: string[];
|
||||
modelAliases?: Record<string, string>;
|
||||
// Whether the prefix every model shares is left out of how they are drawn, and each
|
||||
// model as it then reads; a model drawn as it is has no entry.
|
||||
hideModelPrefix?: boolean;
|
||||
modelShorts?: Record<string, string>;
|
||||
default: string;
|
||||
hasKey: boolean;
|
||||
keyEnv?: string;
|
||||
@@ -249,6 +253,8 @@ export interface ProviderEdit {
|
||||
name: string;
|
||||
// Omitted preserves aliases; blank or missing values reset listed models.
|
||||
modelAliases?: Record<string, string>;
|
||||
// Omitted leaves the choice alone.
|
||||
hideModelPrefix?: boolean;
|
||||
// Omitted leaves the wire format alone.
|
||||
kind?: string;
|
||||
baseUrl?: string;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface SessionTailPort {
|
||||
// Copies the newest part of a session too large to open into a new session;
|
||||
// dropped is how many earlier messages were left out. The source is untouched.
|
||||
continueFromTail(path: string): Promise<{ path: string; dropped: number }>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
|
||||
import { PLAN_ACTIONS, type PlanAction } from "./session";
|
||||
import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
|
||||
import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, UploadReport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
|
||||
import { HttpError, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles } from "./port";
|
||||
import { SseWorkspace } from "./sse_workspace";
|
||||
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
|
||||
@@ -83,6 +83,9 @@ export class SsePort extends SseWorkspace implements AgentPort {
|
||||
}
|
||||
return this.get<UsageReport>("/usage?" + q);
|
||||
}
|
||||
uploadUsage(days: number) {
|
||||
return this.get<UploadReport>(`/usage/upload?days=${days}`);
|
||||
}
|
||||
memories() {
|
||||
return this.get<MemoryCatalog>("/memory");
|
||||
}
|
||||
@@ -476,6 +479,9 @@ export class SsePort extends SseWorkspace implements AgentPort {
|
||||
async branchAt(turn: number, name: string) {
|
||||
return (await this.post0<{ path: string }>("/branch/at", { turn, name })).path;
|
||||
}
|
||||
continueFromTail(path: string) {
|
||||
return this.post0<{ path: string; dropped: number }>("/session/tail", { path });
|
||||
}
|
||||
availableUndo() { return this.get<RewindUndo | null>("/rewind/undo"); }
|
||||
|
||||
undoRewind(transactionId: string) {
|
||||
|
||||
@@ -78,6 +78,10 @@ export interface UsageReport {
|
||||
// Set when a counted call's money is unknown (no rate card, or a quote that
|
||||
// could not price its usage). cost is then a floor, not the bill.
|
||||
costIncomplete?: boolean;
|
||||
// How many of those counted calls are under that floor, and the distinct
|
||||
// reasons the rows recorded (the kernel's incomplete-reason vocabulary).
|
||||
unpricedCalls?: number;
|
||||
incompleteReasons?: string[];
|
||||
active_days: number;
|
||||
top_model: string;
|
||||
top_provider: string;
|
||||
@@ -88,3 +92,30 @@ export interface UsageReport {
|
||||
models: UsageModel[];
|
||||
providers: UsageProvider[];
|
||||
}
|
||||
|
||||
export interface UploadDay {
|
||||
day: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface UploadProvider {
|
||||
provider: string;
|
||||
bytes: number;
|
||||
requests: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
// What model providers were sent: request bodies as they went out, before the
|
||||
// connection adds its own overhead. Downloads are not counted.
|
||||
export interface UploadReport {
|
||||
from: string;
|
||||
to: string;
|
||||
bytes: number;
|
||||
requests: number;
|
||||
daily: UploadDay[];
|
||||
providers: UploadProvider[];
|
||||
}
|
||||
|
||||
export interface UploadUsagePort {
|
||||
uploadUsage(days: number): Promise<UploadReport>;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export interface Toast {
|
||||
text: string;
|
||||
// A note tells the person what the window did on its own; it is not a failure.
|
||||
tone?: "note";
|
||||
// A way out the notice offers; running it dismisses the notice.
|
||||
action?: { label: string; run: () => void };
|
||||
}
|
||||
|
||||
export const MAX_TOASTS = 3;
|
||||
@@ -33,6 +35,12 @@ export function pushNote(text: string): void {
|
||||
publish([...toasts, { id: ++serial, text, tone: "note" as const }].slice(-MAX_TOASTS));
|
||||
}
|
||||
|
||||
/** A note with one button that does what the note offers. */
|
||||
export function pushOffer(text: string, action: { label: string; run: () => void }): void {
|
||||
if (!text.trim() || toasts.some((toast) => toast.text === text)) return;
|
||||
publish([...toasts, { id: ++serial, text, tone: "note" as const, action }].slice(-MAX_TOASTS));
|
||||
}
|
||||
|
||||
export function dismissToast(id: number): void {
|
||||
publish(toasts.filter((toast) => toast.id !== id));
|
||||
}
|
||||
|
||||
@@ -267,21 +267,22 @@ and selected states, while long titles truncate within the remaining width.
|
||||
that land", so the state is a chip on the row. The count and the byte total
|
||||
each refuse alone: a header showing one of them is wrong about the other
|
||||
exactly when it matters.
|
||||
- The number of entries is capped (64); their height is not. The head stays put
|
||||
and the rows grow; total height comes from `.composeaux`, and the rule here
|
||||
only says that the rows scroll rather than the head scrolling away with them.
|
||||
- `steer_consumed` means this turn has already read the line into its context:
|
||||
it cannot be taken back, and `AckDequeue` removes it when the turn ends. It
|
||||
stops being a card and becomes a record — still visible, because a crash before
|
||||
the write has to leave it on screen, but no longer a card's height. Its border
|
||||
goes transparent rather than away, or the column loses its alignment.
|
||||
- Its actions are quiet but present: a row of controls that exists only under the
|
||||
pointer reads as a row that does nothing.
|
||||
- The queue is the upper half of the composer, sharing its surface and outer
|
||||
frame. Transparent 38px rows have 12px horizontal padding and 2px gaps; only
|
||||
status pills carry status colour. A quiet separator leads into the input.
|
||||
- The head stays put while roughly five rows fit before internal scrolling,
|
||||
with edge fades. `.composeaux` still owns the shared height budget.
|
||||
- `steer_consumed` and `running` entries leave this pending strip: they have
|
||||
already reached the transcript and are not input waiting to be sent.
|
||||
- Edit and withdraw use icons with unchanged accessible names and titles.
|
||||
Withdraw restores input, never discards it. Move controls reveal on hover or
|
||||
focus and remain visible on touch; their keyboard order is unchanged.
|
||||
- Everything stacked above the input shares one ceiling, `--aux-budget`, so a
|
||||
full queue scrolls inside it instead of taking the transcript.
|
||||
- The studio composer's chrome is taller than the one the budget was set
|
||||
against, so studio lowers it (28vh, 19vh when short): with 64 queued lines the
|
||||
transcript keeps 40% of the pane, which `perf/budget.mjs` holds.
|
||||
- Without notices, a queue gets a 44vh auxiliary budget (28vh when short),
|
||||
capped at 420px (260px when short), so about five compact rows fit. Notices
|
||||
restore the smaller 28vh/19vh budget to protect transcript space; the input
|
||||
stays visible under the 64-entry pressure case in `perf/budget.mjs`.
|
||||
- The queued line is the only thing on screen that has not happened yet, so its
|
||||
cancel sits beside "queued" and stays visible. A cancel revealed on hover is no
|
||||
cancel.
|
||||
@@ -342,6 +343,9 @@ and selected states, while long titles truncate within the remaining width.
|
||||
the pane's tab-row line so the two rows read as one: the strip is
|
||||
`--pane-bar-h` less the card's top inset and border. `perf/dock.mjs` holds
|
||||
the two lines together.
|
||||
- The file toolbar gives spare width to the truncated path; optional controls
|
||||
and Save keep their intrinsic widths. Enabled Save uses accent foreground ink;
|
||||
disabled Save uses opaque muted ink on a neutral surface.
|
||||
- The workbench is hidden, not unmounted. It holds the open files and a browser
|
||||
per tab, each with its own history, and a glance at the conversation must not
|
||||
cost them. `.scroll[data-pane="browser"][hidden]` was already written for it.
|
||||
|
||||
@@ -28,8 +28,10 @@ the gate is named.
|
||||
- The selector ties the contrast tiers (`:root[data-theme][data-contrast]`) and
|
||||
beats them by order. With no pack there is no element: the tiers alone carry
|
||||
the inks, and the reader's contrast is theirs.
|
||||
- A pack's own inks take the reader's contrast as a relative-colour lightness
|
||||
offset inside that rule; the built-in inks need none.
|
||||
- A pack's own inks take normal/strong as relative-colour lightness offsets
|
||||
inside that rule. Unset and soft preserve the author's baseline: arbitrary
|
||||
inks and grounds may already meet AA only narrowly, so a blind negative
|
||||
offset is not safe. This is not an accessibility guarantee for user packs.
|
||||
- `--ok --warn --err --net --deleg --add --del --focus` are not in the pack
|
||||
vocabulary and are never written.
|
||||
- Decoration that is not a status has its own tokens, which a pack may set
|
||||
@@ -348,26 +350,53 @@ pack states a shape, never a number the layout has to agree with.
|
||||
pack sheet. The backdrop transcript rule defaults to its 2px shadow and 7px
|
||||
halo when this variable is absent. The choice affects text only; the frosted
|
||||
backing remains. Reduced effects overrides the text shadow.
|
||||
- `background.surfaceOpacity.{light,dark}` paints `--bg-{nav,rail,chrome,side,compose}-background`
|
||||
through the pack sheet, only for a backdrop. Omitted values keep existing paint.
|
||||
- Shell opacity mixes `--page`; composer opacity mixes `--raised`. Existing
|
||||
Studio scheme-specific overrides and blur remain the defaults.
|
||||
|
||||
- Stroke weight is an axis beside contrast, not part of it: however bright the
|
||||
colour, 400 Han at 11px is thin. Han strokes are dense and blur at a size
|
||||
Latin still holds, so the Chinese interface starts at medium.
|
||||
- The `heavy` tier steps further for Chinese, whose default is already the Latin
|
||||
bold tier, and stops at 700: above it a Han character's closed strokes fill in.
|
||||
- Text contrast has three tiers; backgrounds and semantic colours do not
|
||||
participate. The floor is AA body text: `--ghost` must reach 4.5:1 on the
|
||||
darkest ground it actually lands on, because it appears at 10–11px and the
|
||||
large-text exemption starts at 18.66px. That ground is `--page` — the rail,
|
||||
the side column and the chrome all lay it, where `--ghost` appears two orders
|
||||
of magnitude more often than on `--raised`. The default tier starts at that
|
||||
line and the other two step up; the body end does not move, because near-white
|
||||
on a dark ground haloes and narrowing the span is what "softer" means.
|
||||
Enforced by `perf/contrast.mjs`.
|
||||
- `--ghost` is `--faint`'s value in every tier: a lighter one fails the floor.
|
||||
- `perf/tiers.mjs` holds every tier: each ink is at least as legible as the
|
||||
default palette stepped once (3.0/10.1, 3.5/7.5 and 3.5/7.0 lightness points
|
||||
for text, muted and faint) and as the floors recorded there, on both grounds.
|
||||
- The proposed decision for #12019 retains **soft** as a lower-contrast built-in
|
||||
body/muted palette. Unlike the previous contract, the body end now moves.
|
||||
- Unset, normal and strong remain unchanged. Pack-authored inks keep their
|
||||
baseline for soft, as described under Theme packs.
|
||||
- Every text ink, including 10–11px faint/ghost, must clear 4.5:1 on its actual
|
||||
grounds; these sizes do not qualify for the large-text exemption. Applicable
|
||||
non-text graphics still need 3:1. No semantic colours or graphics are softened.
|
||||
- Faint/ghost keep the default AA floor: light is only 4.532:1 on `--overlay`,
|
||||
dark only 4.504:1 on `--float-hi`. Softening them would fail AA despite passing
|
||||
on page. `--ghost` equals `--faint` in every tier.
|
||||
- Light soft previously made faint/ghost stronger than unset; it now preserves
|
||||
the actual default instead.
|
||||
- `perf/tiers.mjs` measures Chromium colours on page, surface, raised, overlay,
|
||||
float and float-hi. It pins unset/normal/strong and requires softer body/muted,
|
||||
stable faint/ghost, and AA. The higher tiers keep their stepped-palette floors.
|
||||
- `perf/contrast.mjs` and `tools/contrast-sweep.mjs` also audit rendered text and
|
||||
composited grounds.
|
||||
- `prefers-contrast: more` is followed when the user has not chosen a tier.
|
||||
Light and dark are judged separately: a manual dark choice under a light
|
||||
system scheme would otherwise paint the light theme's dark body text onto a
|
||||
dark ground.
|
||||
|
||||
### Soft contrast decision measurements
|
||||
|
||||
Chromium, canonical Studio palette, WCAG sRGB relative luminance. Each cell is
|
||||
**page / surface**, rounded to three decimals; thresholds use unrounded ratios.
|
||||
These are small-text measurements, not large-text exemptions.
|
||||
|
||||
| Scheme / tier | text | muted | faint | ghost |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Light / unset (unchanged) | 12.496 / 13.653 | 5.445 / 5.949 | 4.831 / 5.279 | 4.831 / 5.279 |
|
||||
| Light / previous soft | 12.496 / 13.653 | 7.695 / 8.407 | 5.815 / 6.353 | 5.815 / 6.353 |
|
||||
| Light / soft | 9.324 / 10.187 | 5.140 / 5.616 | 4.831 / 5.279 | 4.831 / 5.279 |
|
||||
| Light / normal (unchanged) | 14.198 / 15.513 | 10.316 / 11.271 | 7.933 / 8.668 | 7.933 / 8.668 |
|
||||
| Light / strong (unchanged) | 18.018 / 19.686 | 13.669 / 14.934 | 10.510 / 11.483 | 10.510 / 11.483 |
|
||||
| Dark / unset and previous soft | 14.730 / 14.314 | 8.934 / 8.682 | 5.722 / 5.560 | 5.722 / 5.560 |
|
||||
| Dark / soft | 10.822 / 10.516 | 7.411 / 7.202 | 5.722 / 5.560 | 5.722 / 5.560 |
|
||||
| Dark / normal (unchanged) | 16.125 / 15.670 | 10.065 / 9.781 | 7.565 / 7.352 | 7.565 / 7.352 |
|
||||
| Dark / strong (unchanged) | 18.099 / 17.588 | 13.163 / 12.792 | 9.916 / 9.636 | 9.916 / 9.636 |
|
||||
|
||||
@@ -62,24 +62,24 @@
|
||||
:root[data-sky] .app { background: transparent }
|
||||
|
||||
:root[data-bg] .app { background: transparent }
|
||||
|
||||
.rail, .side { background: var(--page) }
|
||||
.panes { background: var(--surface) }
|
||||
|
||||
:root[data-bg] .main, :root[data-bg] .panes { background: transparent }
|
||||
|
||||
:root[data-bg] .rail, :root[data-bg] .side,
|
||||
:root[data-bg] .nav, :root[data-bg] .chrome {
|
||||
background: color-mix(in srgb, var(--page) 62%, transparent);
|
||||
text-shadow: 0 1px 2px color-mix(in srgb, var(--page) 88%, transparent),
|
||||
0 0 6px color-mix(in srgb, var(--page) 70%, transparent);
|
||||
}
|
||||
:root[data-bg] .nav { background: var(--bg-nav-background, color-mix(in srgb, var(--page) 62%, transparent)) }
|
||||
:root[data-bg] .rail { background: var(--bg-rail-background, color-mix(in srgb, var(--page) 62%, transparent)) }
|
||||
:root[data-bg] .chrome { background: var(--bg-chrome-background, color-mix(in srgb, var(--page) 62%, transparent)) }
|
||||
:root[data-bg] .side { background: var(--bg-side-background, color-mix(in srgb, var(--page) 62%, transparent)) }
|
||||
:root[data-bg] .flow:not([data-empty]) {
|
||||
text-shadow: var(--flow-text-shadow, 0 1px 2px color-mix(in srgb, var(--page) 90%, transparent),
|
||||
0 0 7px color-mix(in srgb, var(--page) 72%, transparent));
|
||||
}
|
||||
:root[data-bg] .compose {
|
||||
background: color-mix(in srgb, var(--raised) 80%, transparent);
|
||||
background: var(--bg-compose-background, color-mix(in srgb, var(--raised) 80%, transparent));
|
||||
-webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
.btn.sm { padding: 2.5px 8px; font-size: var(--fs-meta); }
|
||||
|
||||
.picker { position: relative; flex: none }
|
||||
.isolab { flex: none; white-space: nowrap; font: var(--w-med) var(--fs-micro) var(--mono); letter-spacing: var(--track-cap); color: var(--ok-ink); padding: 1.5px 6px; border-radius: var(--r-pill); background: color-mix(in srgb, var(--ok) 13%, transparent) }
|
||||
.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 }
|
||||
|
||||
.hostlab { color: var(--net-ink); background: color-mix(in srgb, var(--net) 13%, transparent) }
|
||||
@@ -405,8 +405,8 @@
|
||||
.pane[data-run="done"] .rmark { color: var(--ok) }
|
||||
|
||||
.queue {
|
||||
display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px;
|
||||
min-height: 0;
|
||||
display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px;
|
||||
min-height: 0; padding-bottom: 10px; border-bottom: 1px solid var(--hair);
|
||||
font: var(--w-reg) var(--fs-meta) var(--mono);
|
||||
animation: qin var(--m-card) both;
|
||||
}
|
||||
@@ -446,18 +446,17 @@
|
||||
.qhold:disabled { opacity: .45; cursor: default }
|
||||
|
||||
.qitems {
|
||||
display: grid; gap: 4px; min-height: 0;
|
||||
display: grid; gap: 2px; min-height: 0; max-height: 208px;
|
||||
overflow-y: auto; overscroll-behavior: contain;
|
||||
mask-image: linear-gradient(to bottom, transparent, #000 5px, #000 calc(100% - 5px), transparent);
|
||||
}
|
||||
.qi {
|
||||
display: flex; align-items: center; gap: 8px; min-width: 0; padding: 7px 9px;
|
||||
border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--raised);
|
||||
display: flex; align-items: center; gap: 6px; min-width: 0; min-height: 38px; padding: 4px 12px;
|
||||
border: 0; border-radius: var(--r-sm); background: transparent;
|
||||
color: var(--text); animation: qstep var(--t-card) var(--ease-out) both;
|
||||
transition:
|
||||
padding var(--t-card) var(--ease-out), font-size var(--t-card) var(--ease-out),
|
||||
opacity var(--t-card) var(--ease-out), border-color var(--t-card) var(--ease-out),
|
||||
background var(--t-card) var(--ease-out);
|
||||
transition: opacity var(--t-card) var(--ease-out), background var(--t-card) var(--ease-out);
|
||||
}
|
||||
.qi:hover, .qi:focus-within { background: var(--overlay) }
|
||||
.qdot {
|
||||
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);
|
||||
@@ -470,7 +469,7 @@
|
||||
}
|
||||
.pv { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.rf { flex: none; color: var(--faint) }
|
||||
.qwhy { flex: none; color: var(--warn-ink) }
|
||||
.qwhy { flex: 0 1 24%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted) }
|
||||
.qwhy[data-err] { color: var(--err-ink) }
|
||||
.qedit {
|
||||
flex: 1; min-width: 0; padding: 2px 5px; border: 1px solid var(--focus); border-radius: var(--r-xs);
|
||||
@@ -485,7 +484,7 @@
|
||||
|
||||
.qacts { display: flex; align-items: center; gap: 1px; flex: none }
|
||||
.qacts button {
|
||||
min-width: 17px; height: 19px; padding: 0 4px; border: none; border-radius: var(--r-xs);
|
||||
min-width: 24px; height: 26px; padding: 0 4px; border: none; border-radius: var(--r-xs);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: none; color: var(--faint); font: inherit; cursor: pointer;
|
||||
transition: color var(--m-line) , background var(--m-line) ;
|
||||
@@ -498,14 +497,15 @@
|
||||
.qacts button.qnow:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent-ink) }
|
||||
.qacts button:hover:not(:disabled) { background: var(--overlay); color: var(--text) }
|
||||
.qacts button:disabled { opacity: .3; cursor: default }
|
||||
.qacts .qmove { opacity: 0; width: 0; min-width: 0; padding: 0; overflow: hidden }
|
||||
.qi:hover .qmove, .qi:focus-within .qmove { opacity: 1; width: 24px; min-width: 24px; padding: 0 4px }
|
||||
@media (hover: none) { .qacts .qmove { opacity: 1; width: 24px; min-width: 24px; padding: 0 4px } }
|
||||
.qi .x {
|
||||
flex: none; padding: 2px 7px; border: none; border-radius: var(--r-pill); background: none;
|
||||
color: var(--faint); font: var(--w-med) var(--fs-micro) var(--ui); line-height: 1.3; cursor: pointer;
|
||||
flex: none; width: 26px; height: 26px; padding: 2px; border: none; border-radius: var(--r-xs); background: none;
|
||||
color: var(--faint); font: var(--w-med) var(--fs-meta) var(--ui); line-height: 1.3; cursor: pointer;
|
||||
transition: color var(--m-line) , background var(--m-line) ;
|
||||
}
|
||||
.qi .x:hover { color: var(--err-ink); background: color-mix(in srgb, var(--err) 12%, transparent) }
|
||||
.qi .x { position: relative }
|
||||
.qi .x::after { content: ""; position: absolute; inset: -5px -2px }
|
||||
.qi .x:hover, .qi .x:focus-visible { color: var(--text); background: var(--overlay) }
|
||||
|
||||
@keyframes bumpup { 0% { transform: translateY(2px); opacity: .5 } 100% { transform: none; opacity: 1 } }
|
||||
|
||||
@@ -2512,7 +2512,7 @@
|
||||
background: var(--overlay); font-weight: var(--w-med);
|
||||
}
|
||||
.prefs-nav button[aria-selected="true"] svg { color: var(--text) }
|
||||
.prefs-nav button:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px }
|
||||
.prefs-nav button:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px } .prefs-nav button[data-open] { font-weight: var(--w-med) } .prefs-nav button.prefs-page { min-height: 30px; padding-left: 34px }
|
||||
|
||||
.prefs-nav .nv {
|
||||
flex: 0 99 auto; min-width: 0; max-width: 74px; overflow: hidden; text-overflow: ellipsis;
|
||||
@@ -2643,7 +2643,7 @@
|
||||
}
|
||||
.prefs-nav::-webkit-scrollbar { display: none }
|
||||
.prefs-nav .navsec, .prefs-nav .nv { display: none }
|
||||
.prefs-nav button { flex: none; min-height: 32px; padding: 6px 10px }
|
||||
.prefs-nav button, .prefs-nav button.prefs-page { flex: none; min-height: 32px; padding: 6px 10px }
|
||||
.prefs-nav button::before {
|
||||
left: 50%; top: auto; bottom: 0; width: 0; height: 2px;
|
||||
transform: translateX(-50%); transition: width var(--t-card) var(--ease-spring);
|
||||
@@ -4085,8 +4085,6 @@
|
||||
.theme-more > summary b { font: var(--w-med) var(--fs-ui) var(--ui); color: var(--text) }
|
||||
.theme-more > summary em { font: var(--w-reg) var(--fs-meta) var(--ui); font-style: normal; color: var(--faint) }
|
||||
.theme-more > .palettes { padding: 5px 0 15px }
|
||||
.personal { margin-top: 12px }
|
||||
.personal > .grp { border-top: 1px solid var(--hair); margin-top: 0 }
|
||||
.pal { display: flex; flex-direction: column; gap: 7px; padding: 0; border: 0; background: none; text-align: left; cursor: pointer }
|
||||
.pal-art {
|
||||
position: relative; display: flex; aspect-ratio: 16 / 10; overflow: hidden;
|
||||
|
||||
@@ -35,7 +35,7 @@ const MARKS: Record<string, string> = {
|
||||
// change per call site rather than per rule — recorded so the count cannot grow
|
||||
// while the work is outstanding. Never widen this to land a change.
|
||||
const MIXED = new Set([
|
||||
"c", "cnt", "cost", "cur", "d", "delta", "dt", "fail", "isolab", "k",
|
||||
"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",
|
||||
]);
|
||||
|
||||
@@ -1084,7 +1084,8 @@ body { background: var(--page) !important; }
|
||||
.compose::after { border-color: var(--border); }
|
||||
.compose:hover::after { border-color: var(--border); }
|
||||
.compose { --aux-budget: min(calc(28vh / var(--zoom, 1)), 280px); }
|
||||
[data-fold~="short"] .compose { --aux-budget: min(calc(19vh / var(--zoom, 1)), 150px); }
|
||||
.compose:has(.queue):not(:has(.composenotes > *)) { --aux-budget: min(calc(44vh / var(--zoom, 1)), 420px); }
|
||||
[data-fold~="short"] .compose { --aux-budget: min(calc(19vh / var(--zoom, 1)), 150px); } [data-fold~="short"] .compose:has(.queue):not(:has(.composenotes > *)) { --aux-budget: min(calc(28vh / var(--zoom, 1)), 260px); }
|
||||
.compose:focus-within { box-shadow: 0 0 0 3px rgb(197 201 192 / .055), 0 20px 48px -32px #000; }
|
||||
.compose:focus-within::after { border-color: #4a4e47; }
|
||||
.compose .prompt { display: none; }
|
||||
@@ -1173,7 +1174,7 @@ body { background: var(--page) !important; }
|
||||
.btn.send { width: 34px; height: 34px; border: 0; border-radius: 50%; background: var(--accent); color: var(--accent-fg); }
|
||||
.btn.send:hover { background: var(--accent); transform: translateY(-1px); }
|
||||
.btn.send:disabled { background: var(--hair); color: var(--ghost); }
|
||||
.composeaux,
|
||||
.composeaux { width: 100%; margin-inline: 0; }
|
||||
.composenotes { width: min(var(--column), calc(100% - var(--srail-w) - 42px)); margin-inline: auto; }
|
||||
.composenotes { color: var(--ghost); }
|
||||
.studio-meterrail {
|
||||
@@ -1298,7 +1299,7 @@ body { background: var(--page) !important; }
|
||||
}
|
||||
|
||||
/* The inspector is contextual, not a permanent dashboard. */
|
||||
.side { background: var(--page); border-left: 1px solid var(--hair); }
|
||||
.side { background: var(--bg-side-background, var(--page)); border-left: 1px solid var(--hair); }
|
||||
.side .scroll { background: var(--page); }
|
||||
.side .meter { border-color: var(--hair); }
|
||||
|
||||
@@ -1422,14 +1423,14 @@ body { background: var(--page) !important; }
|
||||
.workbench-filelist span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workbench-filelist em { color: var(--warn-ink); font-style: normal; }
|
||||
.workbench-no-files { margin: 18px 12px; color: var(--faint); font: 500 calc(10px * var(--fs-scale, 1))/1.4 var(--ui); text-align: center; }
|
||||
.workbench-filebar { min-height: 39px; padding: 0 10px; flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--hair); }
|
||||
.workbench-filebar strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 calc(11px * var(--fs-scale, 1))/1.5 var(--mono); }
|
||||
.workbench-filebar { min-height: 39px; padding: 0 10px; flex: none; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--hair); }
|
||||
.workbench-filebar strong { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 calc(11px * var(--fs-scale, 1))/1.5 var(--mono); }
|
||||
.workbench-filebar > :not(strong) { flex-shrink: 0; }
|
||||
.workbench-filebar [role="group"] { display: flex; padding: 2px; background: var(--page); border: 1px solid var(--hair); border-radius: 5px; }
|
||||
.workbench-filebar button { min-height: 25px; padding: 0 8px; border: 0; border-radius: 3px; background: transparent; color: var(--muted); font: 600 calc(10px * var(--fs-scale, 1))/1 var(--ui); }
|
||||
.workbench-filebar button[aria-pressed="true"] { background: var(--raised); color: var(--text); }
|
||||
.workbench-filebar .workbench-save { display: flex; align-items: center; gap: 5px; }
|
||||
.workbench-filebar .workbench-save { background: var(--accent); color: white; }
|
||||
.workbench-filebar .workbench-save:disabled { opacity: .35; }
|
||||
.workbench-filebar .workbench-save { display: flex; align-items: center; gap: 5px; background: var(--accent); color: var(--accent-fg); }
|
||||
.workbench-filebar .workbench-save:disabled { background: var(--overlay); color: var(--muted); cursor: default; }
|
||||
.workbench-filebar .workbench-readonly { padding: 3px 8px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font: var(--w-med) var(--fs-meta)/1.4 var(--ui); white-space: nowrap; }
|
||||
.workbench-filebar svg { width: 13px; height: 13px; }
|
||||
.workbench-editor { flex: 1; min-height: 0; width: 100%; display: flex; flex-direction: column; overflow: hidden; background: var(--face-code); }
|
||||
@@ -1485,7 +1486,6 @@ body { background: var(--page) !important; }
|
||||
.app[data-rail="off"] .rail { pointer-events: none; }
|
||||
.flow,
|
||||
.compose,
|
||||
.composeaux,
|
||||
.composenotes { width: calc(100% - 24px); }
|
||||
.flow { left: 0; padding-inline: 0; }
|
||||
.chrome .crumb-proj,
|
||||
@@ -1582,7 +1582,7 @@ body { background: var(--page) !important; }
|
||||
}
|
||||
:root[data-theme="light"] .btn.send { background: var(--accent); color: var(--accent-fg); }
|
||||
:root[data-theme="light"] .btn.send:hover { background: #111210; }
|
||||
:root[data-theme="light"] .side,
|
||||
:root[data-theme="light"] .side { background: var(--bg-side-background, var(--overlay)); border-color: var(--border); }
|
||||
:root[data-theme="light"] .side .scroll { background: var(--overlay); border-color: var(--border); }
|
||||
:root[data-theme="light"] .prefs-sheet,
|
||||
:root[data-theme="light"] .prefs-hd,
|
||||
@@ -1839,9 +1839,8 @@ body { background: var(--page) !important; }
|
||||
:root[data-bg] .pane:has([data-pane="flow"] .flow[data-empty]) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
:root[data-bg] :is(.rail, .chrome) {
|
||||
background: color-mix(in srgb, var(--page) 80%, transparent) !important;
|
||||
}
|
||||
:root[data-bg] .rail { background: var(--bg-rail-background, color-mix(in srgb, var(--page) 80%, transparent)) !important; }
|
||||
:root[data-bg] .chrome { background: var(--bg-chrome-background, color-mix(in srgb, var(--page) 80%, transparent)) !important; }
|
||||
|
||||
.hero .rmark {
|
||||
filter: drop-shadow(0 0 28px var(--studio-light-primary));
|
||||
@@ -3266,9 +3265,8 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
|
||||
.studio-workspace-kind { color: var(--muted); }
|
||||
.studio-user-foot .acctrow:hover,
|
||||
.studio-settings:hover { background: var(--accent-wash); color: var(--text); }
|
||||
:root[data-theme="light"][data-bg] :is(.rail, .chrome) {
|
||||
background: color-mix(in srgb, var(--page) 86%, transparent) !important;
|
||||
}
|
||||
:root[data-theme="light"][data-bg] .rail { background: var(--bg-rail-background, color-mix(in srgb, var(--page) 86%, transparent)) !important; }
|
||||
:root[data-theme="light"][data-bg] .chrome { background: var(--bg-chrome-background, color-mix(in srgb, var(--page) 86%, transparent)) !important; }
|
||||
:root[data-theme="light"][data-bg] .railfoot {
|
||||
box-shadow: 0 -1px color-mix(in srgb, var(--border) 62%, transparent);
|
||||
}
|
||||
@@ -3283,10 +3281,10 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
|
||||
background: var(--accent-wash);
|
||||
color: var(--text);
|
||||
}
|
||||
.side,
|
||||
.side .scroll,
|
||||
.studio-browser,
|
||||
.studio-browser-toolbar { background: var(--surface); border-color: var(--hair); color: var(--text); }
|
||||
.side { background: var(--bg-side-background, var(--surface)); border-color: var(--hair); color: var(--text); }
|
||||
.studio-browser-address { background: var(--raised); border-color: var(--border); }
|
||||
.studio-browser-address input { color: var(--text); }
|
||||
/* A wallpaper remains visible, while cards keep enough of the selected pack's
|
||||
@@ -3297,7 +3295,7 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
|
||||
-webkit-backdrop-filter: blur(11px) saturate(1.04);
|
||||
backdrop-filter: blur(11px) saturate(1.04);
|
||||
}
|
||||
:root[data-bg] .compose { background: color-mix(in srgb, var(--raised) 88%, transparent) !important; }
|
||||
:root[data-bg] .compose { background: var(--bg-compose-background, color-mix(in srgb, var(--raised) 88%, transparent)) !important; }
|
||||
:root[data-bg] .flow:not([data-empty]) { isolation: isolate; }
|
||||
:root[data-bg] .flow:not([data-empty])::before {
|
||||
content: "";
|
||||
|
||||
@@ -221,9 +221,12 @@
|
||||
|
||||
:root[data-weight="heavy"]:lang(zh) { --w-reg: 600; --w-med: 700; --w-semi: 700 }
|
||||
|
||||
:root, :root[data-contrast="soft"] {
|
||||
:root {
|
||||
--text: #292b29; --muted: oklch(.41 .0207 85); --faint: oklch(.475 .0207 85); --ghost: oklch(.475 .0207 85);
|
||||
}
|
||||
:root[data-contrast="soft"] {
|
||||
--text: #3d3f3b; --muted: #62665d; --faint: #666a63; --ghost: #666a63;
|
||||
}
|
||||
:root[data-contrast="normal"] {
|
||||
--text: oklch(.245 .0218 85); --muted: oklch(.34 .0196 85); --faint: oklch(.405 .0196 85); --ghost: oklch(.405 .0196 85);
|
||||
}
|
||||
@@ -232,9 +235,12 @@
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root, :root[data-contrast="soft"] {
|
||||
:root {
|
||||
--text: #e7e8e3; --muted: #b4b7b0; --faint: #8e9289; --ghost: #8e9289;
|
||||
}
|
||||
:root[data-contrast="soft"] {
|
||||
--text: #c6c9c1; --muted: #a3a79e; --faint: #8e9289; --ghost: #8e9289;
|
||||
}
|
||||
:root[data-contrast="normal"] {
|
||||
--text: oklch(.9588 .006756 115.3); --muted: oklch(.81 .01031 125.5); --faint: oklch(.728 .013 255); --ghost: oklch(.728 .013 255);
|
||||
}
|
||||
@@ -243,18 +249,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"], :root[data-theme="dark"][data-contrast="soft"] {
|
||||
:root[data-theme="dark"] {
|
||||
--text: #e7e8e3; --muted: #b4b7b0; --faint: #8e9289; --ghost: #8e9289;
|
||||
}
|
||||
:root[data-theme="dark"][data-contrast="soft"] {
|
||||
--text: #c6c9c1; --muted: #a3a79e; --faint: #8e9289; --ghost: #8e9289;
|
||||
}
|
||||
:root[data-theme="dark"][data-contrast="normal"] {
|
||||
--text: oklch(.9588 .006756 115.3); --muted: oklch(.81 .01031 125.5); --faint: oklch(.728 .013 255); --ghost: oklch(.728 .013 255);
|
||||
}
|
||||
:root[data-theme="dark"][data-contrast="strong"] {
|
||||
--text: oklch(1 .006756 115.3); --muted: oklch(.893 .011 255); --faint: oklch(.806 .012 255); --ghost: oklch(.806 .012 255);
|
||||
}
|
||||
:root[data-theme="light"], :root[data-theme="light"][data-contrast="soft"] {
|
||||
:root[data-theme="light"] {
|
||||
--text: #292b29; --muted: oklch(.41 .0207 85); --faint: oklch(.475 .0207 85); --ghost: oklch(.475 .0207 85);
|
||||
}
|
||||
:root[data-theme="light"][data-contrast="soft"] {
|
||||
--text: #3d3f3b; --muted: #62665d; --faint: #666a63; --ghost: #666a63;
|
||||
}
|
||||
:root[data-theme="light"][data-contrast="normal"] {
|
||||
--text: oklch(.245 .0218 85); --muted: oklch(.34 .0196 85); --faint: oklch(.405 .0196 85); --ghost: oklch(.405 .0196 85);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
.worktree-setup-context { display: flex; align-items: center; gap: 10px; min-width: 0; margin-bottom: 24px }
|
||||
.worktree-setup-context > .studio-icon { color: var(--brand); width: 24px; height: 24px; flex-shrink: 0 }
|
||||
.worktree-setup-context strong { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--fs-lead); font-weight: var(--w-med) }
|
||||
.worktree-setup-context > span { margin-left: auto; flex-shrink: 0; border-radius: 6px; padding: 3px 8px; background: var(--brand-wash); color: var(--brand); font-size: var(--fs-micro) }
|
||||
.worktree-setup-context > span { margin-left: auto; flex-shrink: 0; border-radius: 6px; padding: 3px 8px; background: var(--overlay); color: var(--muted); font-size: var(--fs-micro) }
|
||||
.worktree-setup h1 { margin: 0; font: var(--w-med) var(--fs-display)/1.3 var(--ui); letter-spacing: -.025em }
|
||||
.worktree-setup-description { margin: 10px 0 22px; color: var(--muted); font-size: var(--fs-ui); line-height: 1.7 }
|
||||
.worktree-setup-phase { display: flex; justify-content: space-between; align-items: center; gap: 18px; padding: 13px 14px; border-radius: 9px; background: var(--brand-wash); color: var(--brand); font-size: var(--fs-ui) }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.worktree-trigger .lb { max-width: 230px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
:root .compose .mode.worktree-trigger[data-on] { color: var(--brand); background: var(--brand-wash) }
|
||||
:root .compose .mode.worktree-trigger[data-on] .lb, :root .compose .mode.worktree-trigger[data-on] .studio-icon { color: var(--brand) }
|
||||
:root .compose .mode.worktree-trigger[data-on] { color: var(--muted); background: var(--overlay) }
|
||||
:root .compose .mode.worktree-trigger[data-on] .lb, :root .compose .mode.worktree-trigger[data-on] .studio-icon { color: var(--muted) }
|
||||
.workspace-palette { position: fixed; z-index: 1000; width: min(400px, calc(100vw / var(--zoom, 1) - 24px)); max-height: calc(100vh / var(--zoom, 1) - 24px); overflow: auto; padding: 16px; border: 1px solid var(--hair); border-radius: 16px; background: var(--float); color: var(--text); box-shadow: var(--shadow-float); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui) }
|
||||
.workspace-palette .studio-icon { width: 18px; height: 18px; flex-shrink: 0 }
|
||||
.workspace-palette-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 12px; margin-bottom: 16px; border-bottom: 1px solid var(--hair); font-size: var(--fs-ui); font-weight: var(--w-med) }
|
||||
@@ -9,7 +9,7 @@
|
||||
.workspace-palette-identity { display: flex; align-items: center; gap: 10px; min-width: 0; margin-bottom: 16px }
|
||||
.workspace-palette-identity > .studio-icon { width: 23px; height: 23px; color: var(--brand) }
|
||||
.workspace-palette-identity > strong { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: var(--w-med) }
|
||||
.workspace-palette-identity > span { flex-shrink: 0; max-width: 46%; padding: 3px 7px; border-radius: 6px; color: var(--brand); background: var(--brand-wash); font-size: var(--fs-micro) }
|
||||
.workspace-palette-identity > span { flex-shrink: 0; max-width: 46%; padding: 3px 7px; border-radius: 6px; color: var(--muted); background: var(--overlay); font-size: var(--fs-micro) }
|
||||
.worktree-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 3px; margin: 0 0 14px; background: var(--raised); border: 1px solid var(--hair); border-radius: 9px }
|
||||
.worktree-modes button { display: flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid transparent; border-radius: 6px; min-height: 36px; padding: 6px; color: var(--muted); background: none; font: inherit; font-size: var(--fs-meta); cursor: pointer }
|
||||
.worktree-modes button[aria-pressed="true"] { background: var(--float); border-color: var(--hair); color: var(--text) }
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { t } from "../i18n";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { Appearance as Look } from "../port/port";
|
||||
import { Appearance } from "./Appearance";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} });
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); });
|
||||
|
||||
const selected: Look = {
|
||||
wallpaper: { kind: "image", url: "/selected.jpg", opacity: 0.5, dim: 0.55, focusX: 0.25, focusY: 0.75 },
|
||||
};
|
||||
|
||||
function draw(initial: Look = selected) {
|
||||
const port = new MockPort();
|
||||
const upload = vi.spyOn(port, "uploadWallpaper").mockResolvedValue(selected);
|
||||
const clear = vi.spyOn(port, "clearWallpaper").mockResolvedValue();
|
||||
const save = vi.fn();
|
||||
function Settings() {
|
||||
const [look, setLook] = useState(initial);
|
||||
return <Appearance port={port} theme="light" contrast="" weight="" onTheme={() => {}} onContrast={() => {}}
|
||||
onWeight={() => {}} reloadThemes={() => {}} look={look} onLook={(next) => { save(next); setLook(next); }} />;
|
||||
}
|
||||
return { ...render(<Settings />), upload, clear, save };
|
||||
}
|
||||
|
||||
it("exposes the wallpaper section and its single selected preview without opening a disclosure", async () => {
|
||||
const { container } = draw();
|
||||
const section = container.querySelector('#set-wallpaper[data-setting="wallpaper"]')!;
|
||||
expect(section.closest("details")).toBeNull();
|
||||
const preview = screen.getByRole("img", { name: t("壁纸") });
|
||||
expect(section.contains(preview)).toBe(true);
|
||||
expect(container.querySelectorAll(".paperview")).toHaveLength(1);
|
||||
expect(preview.style.backgroundImage).toContain("/selected.jpg");
|
||||
expect(preview.style.backgroundPosition).toBe("25% 75%");
|
||||
expect((preview.querySelector("i") as HTMLElement).style.opacity).toBe("0.5");
|
||||
expect((preview.querySelector("b") as HTMLElement).style.opacity).toBe("0.55");
|
||||
await waitFor(() => expect(container.querySelector(".palettes")).not.toBeNull());
|
||||
});
|
||||
|
||||
it("keeps upload, replacement, adjustment and removal on the same appearance state", async () => {
|
||||
const { container, upload, clear, save } = draw({});
|
||||
const input = container.querySelector('input[data-action="wallpaper.change"]') as HTMLInputElement;
|
||||
const first = new File(["image"], "wallpaper.png", { type: "image/png" });
|
||||
expect(container.querySelector(".paperview")).toBeNull();
|
||||
fireEvent.change(input, { target: { files: [first] } });
|
||||
await waitFor(() => expect(upload).toHaveBeenCalledWith(first));
|
||||
await screen.findByRole("img", { name: t("壁纸") });
|
||||
expect(input.value).toBe("");
|
||||
const second = new File(["replacement"], "replacement.jpg", { type: "image/jpeg" });
|
||||
fireEvent.change(input, { target: { files: [second] } });
|
||||
await waitFor(() => expect(upload).toHaveBeenCalledWith(second));
|
||||
for (const [name, field, value] of [["浓度", "opacity", "0.8"], ["压暗", "dim", "0.2"], ["横向焦点", "focusX", "0.6"], ["纵向焦点", "focusY", "0.4"]]) {
|
||||
fireEvent.change(screen.getByRole("slider", { name: new RegExp(t(name)) }), { target: { value } });
|
||||
expect(save.mock.lastCall?.[0].wallpaper[field]).toBe(Number(value));
|
||||
}
|
||||
expect(screen.getByRole("img", { name: t("壁纸") }).style.backgroundPosition).toBe("60% 40%");
|
||||
fireEvent.click(container.querySelector('[data-action="wallpaper.remove"]')!);
|
||||
await waitFor(() => expect(clear).toHaveBeenCalledOnce());
|
||||
await waitFor(() => expect(container.querySelector(".paperview")).toBeNull());
|
||||
expect(save.mock.lastCall?.[0].wallpaper).toBeUndefined();
|
||||
expect(screen.getByRole("button", { name: t("选择图片或视频…") })).toBeTruthy();
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { SessionQuickSection } from "./SessionQuickSection";
|
||||
import { NavEntriesSection } from "./NavEntriesSection";
|
||||
import { NavRailSection } from "./NavRailSection";
|
||||
import { GraphicsSection } from "./GraphicsSection";
|
||||
import { GlobalShortcutSection } from "./GlobalShortcutSection";
|
||||
import { SendShortcut } from "./SendShortcut";
|
||||
import { KeysSheet } from "./KeysSheet";
|
||||
import { SessionTabsSetting } from "./SessionTabsSetting";
|
||||
@@ -378,13 +379,6 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
<ApplyNote id="size" />
|
||||
</section>
|
||||
|
||||
<details className="advset personal">
|
||||
<summary>
|
||||
<span className="tx">
|
||||
<span className="lb">{t("更多个性化")}</span>
|
||||
<span className="ds">{look.wallpaper ? t("已设置自定义壁纸") : t("壁纸与背景位置")}</span>
|
||||
</span>
|
||||
</summary>
|
||||
<section className="grp" id="set-wallpaper" data-setting="wallpaper">
|
||||
<div className="grp-hd">
|
||||
<h3>{t("壁纸")}</h3>
|
||||
@@ -422,6 +416,8 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
{look.wallpaper && (
|
||||
<div
|
||||
className="paperview"
|
||||
role="img"
|
||||
aria-label={t("壁纸")}
|
||||
ref={setView}
|
||||
style={{
|
||||
backgroundImage: look.wallpaper.kind === "video" ? undefined : `url("${look.wallpaper.url}")`,
|
||||
@@ -527,7 +523,6 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
</div>
|
||||
<ApplyNote id="wallpaper" />
|
||||
</section>
|
||||
</details>
|
||||
|
||||
<section className="grp" id="set-language" data-setting="language">
|
||||
<div className="grp-hd">
|
||||
@@ -561,6 +556,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
<NewAnswerSection />
|
||||
<GraphicsSection />
|
||||
<SendShortcut />
|
||||
<GlobalShortcutSection />
|
||||
<KeysSheet />
|
||||
<EscStopSetting />
|
||||
<PageSleepSetting />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ariaChord, chord } from "./keys";
|
||||
import { PhonePop } from "./PhonePop";
|
||||
import { ViewSwitch } from "./ViewSwitch";
|
||||
import { TooltipButton } from "./TooltipButton";
|
||||
import { workspaceDirectoryDescription } from "./workspaceDirectory";
|
||||
import { WindowControls, zoomOnTitleBar } from "./WindowControls";
|
||||
import type { PaneView } from "../state/paneview";
|
||||
|
||||
@@ -99,9 +100,9 @@ export function Chrome({ port, status, title, steer, account, host, theme, onThe
|
||||
<span className="crumb-proj" title={root}>
|
||||
{project}
|
||||
</span>
|
||||
<span className="isolab" hidden={!ws || ws.current !== root} title={root}>
|
||||
{ws?.isolated ? t("隔离目录") : t("原目录")}
|
||||
</span>
|
||||
{ws?.isolated && ws.current === root && <span className="isolab" title={`${workspaceDirectoryDescription(true)}\n${root}`}>
|
||||
{t("独立工作树")}
|
||||
</span>}
|
||||
<span className="isolab hostlab" hidden={!host}>
|
||||
{host}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "../i18n";
|
||||
import { UNCONFIGURED_REF } from "./ConnectNote";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { keepPasteAsFile, PasteKeep, type PasteChip } from "./pastekeep";
|
||||
import type { AgentPort, ChipCall, ModelEntry, SessionStatus, WorkspaceGit } from "../port/port";
|
||||
@@ -11,7 +10,7 @@ import { WorktreeChip } from "./WorktreeChip";
|
||||
import { useWorktreeSetup, WorktreeSetupFailure } from "./useWorktreeSetup";
|
||||
import { WorktreeSetupPage } from "./WorktreeSetupPage";
|
||||
import { Policy } from "./Policy";
|
||||
import { chosenGroups, groupHeadings, modelMenu, modelTooltip, narrowToGroups } from "./modelmenu";
|
||||
import { chosenGroups, groupHeadings, modelLabel, modelMenu, modelTooltip, narrowToGroups } from "./modelmenu";
|
||||
import { ModelGroupChips } from "./ModelGroupChips";
|
||||
import { effortMenu, effortReading, effortsFor, forcesThinkingFor, routeEffortPick } from "./effort";
|
||||
import { CompletionMenu, useCompletion } from "./Completion";
|
||||
@@ -44,6 +43,7 @@ interface Props {
|
||||
port: AgentPort;
|
||||
status: SessionStatus | null;
|
||||
running: boolean;
|
||||
queued?: boolean;
|
||||
// Text a card asked to be quoted, and a counter that makes the same text
|
||||
// twice two requests. Quoting the same reply again is an ordinary thing to
|
||||
// do, and comparing the string alone would drop the second one.
|
||||
@@ -79,7 +79,7 @@ interface Props {
|
||||
draftKey?: string;
|
||||
}
|
||||
|
||||
export function Composer({ port, status, running, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) {
|
||||
export function Composer({ port, status, running, queued = false, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) {
|
||||
const touch = touchKeyboard();
|
||||
const providerOrder = useProviderOrder();
|
||||
const modelGroups = useModelFilter();
|
||||
@@ -217,7 +217,7 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
|
||||
pending.current = null;
|
||||
}, []);
|
||||
useFitHeight(box, text, placeCaret);
|
||||
const placeholder = usePlaceholderFit(box, [t("描述任务、问题或要改的内容…"), t("描述任务…"), "…"]);
|
||||
const placeholder = usePlaceholderFit(box, queued ? [t("继续输入以排队后续修改"), t("继续输入…"), "…"] : [t("描述任务、问题或要改的内容…"), t("描述任务…"), "…"]);
|
||||
|
||||
// Attachments ride into the turn as path references, exactly as they do from
|
||||
// the CLI — the host saved the bytes, the turn parser resolves the token. A
|
||||
@@ -389,9 +389,7 @@ export function Composer({ port, status, running, quote, restore, inject, focus,
|
||||
// composer answering for an endpoint that never spoke.
|
||||
const declared = efforts.length > 0;
|
||||
const forcedThinking = forcesThinkingFor(models, status?.modelRef);
|
||||
const modelLb = status?.modelRef === UNCONFIGURED_REF ? t("未连接")
|
||||
: models.find((m) => m.ref === status?.modelRef)?.modelAlias?.trim()
|
||||
|| (status?.modelRef?.replace(/^[^/]+\//, "") ?? status?.label ?? "—");
|
||||
const modelLb = modelLabel(models, status);
|
||||
// A model switch rebuilds the runtime kernel-side; other controls here may
|
||||
// land at once. Each click needs its own pending state: greying the whole
|
||||
// shelf says nothing can change while only one control waits on the kernel.
|
||||
|
||||
@@ -18,7 +18,7 @@ it.each(["failed", "cancelled"])("restores composer focus after dismissing %s pr
|
||||
const st = { workspaceRoot: "/source", modelRef: "deepseek/deepseek-v4-pro", preset: "balanced", effort: "auto", toolApprovalMode: "ask" } as SessionStatus;
|
||||
render(<Composer port={port} status={st} running={false} fresh git={{ repo: true, branch: "main", name: "project", detached: false, added: 0, removed: 0, untracked: 0 }} onSubmit={onSubmit} onChanged={vi.fn()} onError={vi.fn()}/>);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "新建 worktree" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "独立工作树" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "关闭工作目录配置" }));
|
||||
const box = screen.getByLabelText("任务输入") as HTMLTextAreaElement;
|
||||
box.focus();
|
||||
|
||||
@@ -46,6 +46,7 @@ export function EditConn({
|
||||
const [models, setModels] = useState<string[]>(seededModels);
|
||||
const [picked, setPicked] = useState<string[]>(entry.models);
|
||||
const [aliases, setAliases] = useState<Record<string, string>>(entry.modelAliases ?? {});
|
||||
const [hidePrefix, setHidePrefix] = useState(entry.hideModelPrefix === true);
|
||||
const [vision, setVision] = useState<string[]>(seededVision);
|
||||
const [visionSettable, setVisionSettable] = useState<string[] | undefined>(
|
||||
entry.visionSettable ? [...new Set([...entry.visionSettable, ...(initialCheck?.vision ?? [])])] : undefined,
|
||||
@@ -228,6 +229,7 @@ export function EditConn({
|
||||
baseUrl: baseUrl.trim(),
|
||||
apiKey: apiKey.trim(),
|
||||
models: picked,
|
||||
...(hidePrefix !== (entry.hideModelPrefix === true) && { hideModelPrefix: hidePrefix }),
|
||||
modelAliases: Object.fromEntries(Object.entries(aliases).filter(([m, value]) => picked.includes(m) && value.trim()).map(([m, value]) => [m, value.trim()])),
|
||||
default: picked.includes(def) ? def : picked[0] ?? "",
|
||||
vision: vision.filter((m) => picked.includes(m)),
|
||||
@@ -356,13 +358,26 @@ export function EditConn({
|
||||
</label>
|
||||
</div>
|
||||
{(picked.length > 1 || picked.some((m) => entry.modelLimits?.[m])) && (
|
||||
<ModelLimits models={picked} value={limitText} onChange={setLimitText} inherited={inheritedLimitsFor} />
|
||||
<ModelLimits models={picked} value={limitText} onChange={setLimitText} inherited={inheritedLimitsFor} labels={entry.modelShorts} />
|
||||
)}
|
||||
{picked.length > 0 && <ModelPrices models={picked} value={priceText} onChange={setPriceText} official={entry.officialPricing === true} stored={entry.modelPrices ?? {}}
|
||||
{picked.length > 0 && <ModelPrices models={picked} value={priceText} onChange={setPriceText} official={entry.officialPricing === true} stored={entry.modelPrices ?? {}} labels={entry.modelShorts}
|
||||
onOpenSource={(url) => void Promise.resolve(port.openExternal(url)).catch(() => {})} />}
|
||||
{picked.length > 0 && <div className="fields"><BillingModeField value={billing} onChange={setBilling} /></div>}
|
||||
</div>
|
||||
|
||||
<div className="vway" data-field="hide-model-prefix">
|
||||
<span className="lb">{t("隐藏共同前缀")}</span>
|
||||
<div className="seg" role="group" aria-label={t("{name} 的模型名前缀", { name: entry.displayName?.trim() || entry.name })}>
|
||||
{[true, false].map((on) => (
|
||||
<button key={String(on)} type="button" data-action="provider.draft" data-value={`hide-model-prefix:${on}`}
|
||||
aria-pressed={hidePrefix === on} disabled={busy !== ""} onClick={() => setHidePrefix(on)}>
|
||||
{t(on ? "开" : "关")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="why">{t("路由类服务给每个模型加的同一段前缀(如 group/)在显示时省略,只改显示;模型 ID 不变,自己填的别名优先,保存后生效。")}</span>
|
||||
</div>
|
||||
|
||||
<div className="mlist">
|
||||
<div className="mlhead">
|
||||
<span className="ttl">{t("模型")}</span>
|
||||
@@ -403,6 +418,7 @@ export function EditConn({
|
||||
visionLocked={visionLocked}
|
||||
onCheck={checkModel}
|
||||
aliases={aliases}
|
||||
labels={entry.modelShorts}
|
||||
onAlias={(m, value) => setAliases((current) => ({ ...current, [m]: value }))}
|
||||
checkDisabled={busy !== "" || testing}
|
||||
onToggle={(m) => toggle(picked, setPicked, m)}
|
||||
|
||||
@@ -11,7 +11,10 @@ import { HttpError, type AgentPort } from "../port/port";
|
||||
import { FEEDBACK_CODE, type FeedbackEnv } from "../port/feedback";
|
||||
import { host } from "../port/host";
|
||||
|
||||
beforeEach(dropDraft);
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
dropDraft();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -59,6 +62,52 @@ async function fill(text = "侧栏在缩放窗口后丢失选中项", nick = "ad
|
||||
}
|
||||
|
||||
describe("feedback form", () => {
|
||||
it("remembers submitted contact across fresh forms and lets an empty submission clear it", async () => {
|
||||
const contact = () => screen.getByRole<HTMLInputElement>("textbox", { name: /联系方式/ });
|
||||
setup();
|
||||
await fill();
|
||||
await userEvent.type(contact(), "[email protected]");
|
||||
await userEvent.click(send());
|
||||
await screen.findByText("已收到你的反馈");
|
||||
cleanup();
|
||||
dropDraft();
|
||||
setup();
|
||||
await fill();
|
||||
expect(contact().value).toBe("[email protected]");
|
||||
await userEvent.clear(contact());
|
||||
await userEvent.click(send());
|
||||
await screen.findByText("已收到你的反馈");
|
||||
cleanup();
|
||||
dropDraft();
|
||||
setup();
|
||||
await ready();
|
||||
expect(contact().value).toBe("");
|
||||
});
|
||||
|
||||
it("keeps an unsent draft ahead of remembered contact without persisting a failed submission", async () => {
|
||||
const contact = () => screen.getByRole<HTMLInputElement>("textbox", { name: /联系方式/ });
|
||||
const { port } = setup();
|
||||
await fill();
|
||||
await userEvent.type(contact(), "[email protected]");
|
||||
await userEvent.click(send());
|
||||
await screen.findByText("已收到你的反馈");
|
||||
await userEvent.click(screen.getByRole("button", { name: "再写一条" }));
|
||||
await userEvent.type(body(), "another report");
|
||||
await userEvent.clear(contact());
|
||||
vi.spyOn(port, "sendFeedback").mockRejectedValueOnce(new Error("offline"));
|
||||
await userEvent.click(send());
|
||||
await screen.findByRole("button", { name: "重试发送" });
|
||||
cleanup();
|
||||
setup();
|
||||
await ready();
|
||||
expect(contact().value).toBe("");
|
||||
cleanup();
|
||||
dropDraft();
|
||||
setup();
|
||||
await ready();
|
||||
expect(contact().value).toBe("[email protected]");
|
||||
});
|
||||
|
||||
it("holds Send back until there is text and a nickname, and counts UTF-8 bytes", async () => {
|
||||
setup();
|
||||
await ready();
|
||||
|
||||
@@ -8,7 +8,7 @@ import { host } from "../port/host";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { dropDraft, heldDraft, holdDraft } from "./feedbackdraft";
|
||||
import { dropDraft, heldDraft, holdDraft, rememberContact } from "./feedbackdraft";
|
||||
import { feedbackFailure, type FeedbackFailure } from "./feedbackfailure";
|
||||
import { FeedbackShot } from "./FeedbackShot";
|
||||
import { LazyMarkdown } from "./LazyMarkdown";
|
||||
@@ -210,6 +210,7 @@ export function FeedbackForm({ port, pasteRoot, active, onMine, onClose, onFile,
|
||||
...(shellMemory.length > 0 ? { memory: shellMemory } : {}),
|
||||
});
|
||||
shotEpoch.current++;
|
||||
rememberContact(contact.trim());
|
||||
dropDraft();
|
||||
setSent(receipt);
|
||||
setPhase("sent");
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { host, type GlobalShortcutInfo } from "../port/host";
|
||||
import { Group } from "./Group";
|
||||
import { acceleratorLabel } from "./keys";
|
||||
|
||||
const STATUS = {
|
||||
disabled: "全局快捷键已关闭",
|
||||
registered: "已注册:{value}",
|
||||
invalid: "快捷键格式无效,请使用修饰键与按键组合,例如 CommandOrControl+Shift+Space。",
|
||||
unavailable: "系统未能注册此快捷键:可能已被占用、未获授权,或当前桌面不支持。",
|
||||
unsupported: "当前环境不支持全局快捷键。",
|
||||
};
|
||||
const ERRORS = {
|
||||
invalid: STATUS.invalid,
|
||||
unavailable: STATUS.unavailable,
|
||||
save: "快捷键未能保存;原设置保持不变。",
|
||||
quitting: "Studio 正在退出,未更改快捷键。",
|
||||
};
|
||||
|
||||
function capturedBinding(event: KeyboardEvent<HTMLInputElement>): string {
|
||||
if (event.nativeEvent.isComposing || event.repeat || event.getModifierState("AltGraph")) return "";
|
||||
const modifiers = [
|
||||
event.ctrlKey && "Control", event.altKey && "Alt", event.shiftKey && "Shift", event.metaKey && "Super",
|
||||
].filter(Boolean);
|
||||
if (!event.ctrlKey && !event.altKey && !event.metaKey) return "";
|
||||
const names: Record<string, string> = { " ": "Space", "+": "Plus", ArrowUp: "Up", ArrowDown: "Down", ArrowLeft: "Left", ArrowRight: "Right" };
|
||||
const punctuation: Record<string, string> = { Comma: ",", Period: ".", Slash: "/", Semicolon: ";", Quote: "'", BracketLeft: "[", BracketRight: "]", Backslash: "\\", Backquote: "`", Minus: "-", Equal: "=" };
|
||||
const position = /^(Key[A-Z]|Digit[0-9])$/.test(event.code) ? event.code.replace(/^(Key|Digit)/, "") : punctuation[event.code];
|
||||
const key = names[event.key] ?? (/^[a-z0-9]$/i.test(event.key) ? event.key.toUpperCase() : position ?? event.key);
|
||||
if (!/^(Space|Plus|Up|Down|Left|Right|Enter|Escape|Backspace|Delete|Insert|Home|End|PageUp|PageDown|F([1-9]|1[0-9]|2[0-4])|[A-Z0-9,./;'\[\]\\`=-])$/.test(key)) return "";
|
||||
return [...modifiers, key].join("+");
|
||||
}
|
||||
|
||||
export function GlobalShortcutSection() {
|
||||
const [now, setNow] = useState<GlobalShortcutInfo | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
host().globalShortcut().then(value => {
|
||||
if (live) { setNow(value); setDraft(value.binding); }
|
||||
}).catch(() => live && setFailed(true));
|
||||
return () => { live = false; };
|
||||
}, []);
|
||||
if (!host().inShell() || now?.status === "unsupported") return null;
|
||||
async function apply(binding: string) {
|
||||
setBusy(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
const value = await host().setGlobalShortcut(binding);
|
||||
setNow(value);
|
||||
if (!value.error) setDraft(value.binding);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Group id="global-shortcut" title={t("全局显示 / 隐藏快捷键")} hint={t("默认关闭。仅在 Studio 运行时生效;窗口聚焦时隐藏,否则显示并聚焦。不会启动已退出的应用,也不更改窗口内快捷键。") }>
|
||||
<form data-action="window.global-shortcut" data-value="apply" onSubmit={event => { event.preventDefault(); void apply(draft); }}>
|
||||
<label className="lrow">
|
||||
<span className="tx"><span className="lb">{t("快捷键组合")}</span></span>
|
||||
<input ref={input} className="fontown" data-action-change="window.global-shortcut" data-action-keydown="window.global-shortcut" data-value="binding" aria-label={t("快捷键组合")} maxLength={128} placeholder={recording ? t("请按修饰键与按键组合…") : "CommandOrControl+Shift+Space"} value={draft} readOnly={recording} onChange={event => setDraft(event.target.value)} onBlur={() => setRecording(false)} onKeyDown={event => {
|
||||
if (!recording || event.key === "Tab") return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
if (event.key === "Escape" && !event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) { setRecording(false); return; }
|
||||
const binding = capturedBinding(event);
|
||||
if (binding) { setDraft(binding); setRecording(false); }
|
||||
}} disabled={!now || busy} />
|
||||
</label>
|
||||
{draft && <p className="note"><kbd>{acceleratorLabel(draft)}</kbd></p>}
|
||||
<p className="note">{t("点击录制后按下修饰键与按键组合,或直接编辑快捷键。Esc 取消录制;清除会立即关闭快捷键。Wayland 或系统保留组合可能不受支持。")}</p>
|
||||
<div className="seg" data-text>
|
||||
<button type="button" data-action="window.global-shortcut" data-value="record" disabled={!now || busy} onClick={() => { setRecording(true); input.current?.focus(); }}>{t("录制快捷键")}</button>
|
||||
<button type="submit" data-action="window.global-shortcut" data-value="apply" disabled={!now || busy}>{t("应用快捷键")}</button>
|
||||
<button type="button" data-action="window.global-shortcut" data-value="disable" disabled={!now || busy || (!now.binding && !draft)} onClick={() => void apply("")}>{t("清除快捷键")}</button>
|
||||
</div>
|
||||
</form>
|
||||
{now && <p className="note" role="status">{t(STATUS[now.status], { value: acceleratorLabel(now.binding) })}</p>}
|
||||
{now?.error && <p className="note" role="alert">{t(ERRORS[now.error])}</p>}
|
||||
{failed && <p className="note" role="alert">{t("无法读取或更改 shell 快捷键状态。请重新打开设置后再试。")}</p>}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export function KeysSheet() {
|
||||
<Group
|
||||
id="keys"
|
||||
title={t("快捷键")}
|
||||
hint={t("下列按键在窗口中生效;标着 ⌘ 的地方,Windows 与 Linux 上是 Ctrl。")}
|
||||
hint={t("下列按键在窗口中生效;标着 ⌘ 的地方,Windows 与 Linux 上是 Ctrl。全局显示 / 隐藏快捷键在上方单独配置,仅桌面 shell 支持。")}
|
||||
>
|
||||
{KEYS.map(([keys, what]) => (
|
||||
<div className="lrow keys-row" key={keys + what}>
|
||||
|
||||
@@ -51,6 +51,8 @@ interface Props {
|
||||
// be a dead control. One endpoint can serve both kinds, so it is not a
|
||||
// property of the connection.
|
||||
visionLocked?: (m: string) => boolean;
|
||||
// How a model reads with its shared prefix left out; the id stays in the tooltip.
|
||||
labels?: Record<string, string>;
|
||||
facts?: Record<string, ModelFact>;
|
||||
onCheck?: (m: string) => void;
|
||||
checkDisabled?: boolean;
|
||||
@@ -64,7 +66,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ModelChoice({
|
||||
models, aliases, onAlias, picked, vision, def, onDefault, onVision, visionLocked, facts, onCheck, checkDisabled, onToggle, onAdd,
|
||||
models, aliases, onAlias, picked, vision, def, onDefault, onVision, visionLocked, labels, facts, onCheck, checkDisabled, onToggle, onAdd,
|
||||
onFetch, fetching, fetchDisabled, fetchFail,
|
||||
}: Props) {
|
||||
const [q, setQ] = useState("");
|
||||
@@ -132,7 +134,7 @@ export function ModelChoice({
|
||||
<i />
|
||||
</button>
|
||||
<span className="mid">
|
||||
<span className="nm">{m}</span>
|
||||
<span className="nm" title={labels?.[m] ? m : undefined}>{labels?.[m] ?? m}</span>
|
||||
{onAlias && <input className="malias" value={aliasFor(m)} spellCheck={false}
|
||||
data-action="provider.draft" data-value="model-alias" data-target={m}
|
||||
aria-label={t("{name} 的显示别名", { name: m })}
|
||||
|
||||
@@ -31,9 +31,11 @@ export function limitsToSend(picked: string[], text: Record<string, LimitText>,
|
||||
}
|
||||
|
||||
export function ModelLimits({
|
||||
models, value, onChange, inherited,
|
||||
models, value, onChange, inherited, labels,
|
||||
}: {
|
||||
models: string[];
|
||||
// How a model reads when its shared prefix is left out; absent draws it as it is.
|
||||
labels?: Record<string, string>;
|
||||
value: Record<string, LimitText>;
|
||||
onChange: (next: Record<string, LimitText>) => void;
|
||||
inherited: (model: string) => ModelLimit | null | undefined;
|
||||
@@ -53,7 +55,7 @@ export function ModelLimits({
|
||||
const own = cur.win !== "" || cur.out !== "";
|
||||
return (
|
||||
<li key={model} className="ml-row" data-model={model} data-own={own || undefined}>
|
||||
<span className="me-name" title={model}>{shortId(model)}</span>
|
||||
<span className="me-name" title={model}>{labels?.[model] ?? shortId(model)}</span>
|
||||
<span className="unit-field">
|
||||
<input
|
||||
data-action="provider.draft" data-value="model-window"
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import type { ModelEntry, ProviderEntry } from "../port/port";
|
||||
import type { Port } from "./Providers";
|
||||
import { EditConn } from "./EditConn";
|
||||
import { ModelLimits } from "./ModelLimits";
|
||||
import { ModelUsage } from "./ModelUsage";
|
||||
import { modelMenu } from "./modelmenu";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const routed = [
|
||||
{ ref: "mag/group/deepseek-v4.1-flash", provider: "mag", vendor: "m.example", model: "group/deepseek-v4.1-flash", modelShort: "deepseek-v4.1-flash", kind: "openai" },
|
||||
{ ref: "mag/group/glm-5", provider: "mag", vendor: "m.example", model: "group/glm-5", modelShort: "glm-5", modelAlias: "My GLM", kind: "openai" },
|
||||
] as ModelEntry[];
|
||||
|
||||
it("draws the model menu and the usage list without the shared prefix, a typed alias still first, and keeps the ref exact", () => {
|
||||
const items = modelMenu(routed);
|
||||
const rows = items.filter((it) => !it.header && !it.plain);
|
||||
expect(rows.map((r) => r.label)).toEqual(["deepseek-v4.1-flash", "My GLM"]);
|
||||
expect(rows.map((r) => r.value)).toEqual(["mag/group/deepseek-v4.1-flash", "mag/group/glm-5"]);
|
||||
expect(rows[0].desc).toBeUndefined();
|
||||
expect(rows[1].desc).toBe("group/glm-5");
|
||||
render(<ModelUsage models={routed} roles={null} main="mag/group/deepseek-v4.1-flash" busy="" protocol={{}} onMain={vi.fn()} onRole={vi.fn()} />);
|
||||
const options = [...(screen.getByRole("combobox", { name: "默认模型" }) as HTMLSelectElement).options].map((o) => o.textContent);
|
||||
expect(options).toEqual(["deepseek-v4.1-flash", "My GLM"]);
|
||||
});
|
||||
|
||||
it("shows the short name on a limits row and keeps the full id in the tooltip", () => {
|
||||
render(<ModelLimits models={["group/deepseek-v4.1-flash", "other"]} value={{}} onChange={vi.fn()} inherited={() => null}
|
||||
labels={{ "group/deepseek-v4.1-flash": "deepseek-v4.1-flash" }} />);
|
||||
const row = document.querySelector('[data-model="group/deepseek-v4.1-flash"]')!;
|
||||
const name = within(row as HTMLElement).getByText(/deepseek/);
|
||||
expect(name.textContent).toBe("deepseek-v4.1-flash");
|
||||
expect(name.getAttribute("title")).toBe("group/deepseek-v4.1-flash");
|
||||
expect(document.querySelector('[data-model="other"] .me-name')?.textContent).toBe("other");
|
||||
});
|
||||
|
||||
function drawEdit(entry: Partial<ProviderEntry> = {}) {
|
||||
const editProvider = vi.fn().mockResolvedValue(undefined);
|
||||
const full = {
|
||||
name: "mag", kind: "openai", baseUrl: "https://m.example/v1",
|
||||
models: ["group/a", "group/b"], default: "group/a", hasKey: true, inUse: false, preset: false, ...entry,
|
||||
} as ProviderEntry;
|
||||
const port = { editProvider, protocols: vi.fn(async () => []), checkProvider: vi.fn(async () => ({ ok: true, models: [] })) } as unknown as Port;
|
||||
render(<EditConn entry={full} port={port} busy="" setBusy={() => {}} onDone={vi.fn()} onRevert={() => {}} />);
|
||||
return { editProvider, group: screen.getByRole("group", { name: "mag 的模型名前缀" }) };
|
||||
}
|
||||
|
||||
it("starts off, turns on with a click, and sends the choice with the model ids untouched", async () => {
|
||||
const { editProvider, group } = drawEdit();
|
||||
expect(within(group).getByRole("button", { name: "关" }).getAttribute("aria-pressed")).toBe("true");
|
||||
await userEvent.click(within(group).getByRole("button", { name: "开" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /保存/ }));
|
||||
expect(editProvider.mock.calls[0]?.[0]).toMatchObject({ hideModelPrefix: true, models: ["group/a", "group/b"] });
|
||||
});
|
||||
|
||||
it("shows a stored choice as on and sends it off when turned off", async () => {
|
||||
const { editProvider, group } = drawEdit({ hideModelPrefix: true, modelShorts: { "group/a": "a", "group/b": "b" } });
|
||||
expect(within(group).getByRole("button", { name: "开" }).getAttribute("aria-pressed")).toBe("true");
|
||||
await userEvent.click(within(group).getByRole("button", { name: "关" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /保存/ }));
|
||||
expect(editProvider.mock.calls[0]?.[0]).toMatchObject({ hideModelPrefix: false });
|
||||
});
|
||||
@@ -89,9 +89,10 @@ export function pricesToSend(picked: string[], text: Record<string, PriceText>,
|
||||
}
|
||||
|
||||
export function ModelPrices({
|
||||
models, value, onChange, official = false, stored = {}, onOpenSource,
|
||||
models, value, onChange, official = false, stored = {}, onOpenSource, labels,
|
||||
}: {
|
||||
models: string[];
|
||||
labels?: Record<string, string>;
|
||||
value: Record<string, PriceText>;
|
||||
onChange: (next: Record<string, PriceText>) => void;
|
||||
official?: boolean;
|
||||
@@ -114,7 +115,7 @@ export function ModelPrices({
|
||||
const freeCached = foreign && cur.hit !== "" && Number(cur.hit) === 0 && Number(cur.inp) > 0;
|
||||
return (
|
||||
<li key={model} className="ml-row mp-row" data-model={model} data-own={own || undefined}>
|
||||
<span className="me-name" title={model}>{shortId(model)}</span>
|
||||
<span className="me-name" title={model}>{labels?.[model] ?? shortId(model)}</span>
|
||||
<span className="unit-field">
|
||||
<input data-action="provider.draft" data-value="model-price-input" aria-label={t("{model} 的输入单价", { model })}
|
||||
aria-invalid={incomplete(cur) && cur.inp === "" ? true : undefined} inputMode="decimal" value={cur.inp} placeholder={t("未知")}
|
||||
|
||||
@@ -87,3 +87,19 @@ it("keeps naming effort fixed off even when the model exposes effort levels", as
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "子代理的思考强度" }), "max");
|
||||
expect(onRole).toHaveBeenCalledWith("subagent", models[1].ref, "max");
|
||||
});
|
||||
|
||||
it("offers the vision row only models that read images, and no other row loses its models", () => {
|
||||
draw();
|
||||
const vision = [...(screen.getByRole("combobox", { name: "看图" }) as HTMLSelectElement).options].map((o) => o.textContent);
|
||||
expect(vision).toEqual(["跟随主模型", "deepseek-flash · 读图"]);
|
||||
const subagent = [...(screen.getByRole("combobox", { name: "子代理" }) as HTMLSelectElement).options].map((o) => o.textContent);
|
||||
expect(subagent).toContain("deepseek-pro");
|
||||
});
|
||||
|
||||
it("keeps a vision model chosen earlier visible when it is not declared to read images", () => {
|
||||
const roles: RoleAssignments = { ...ROLES, roles: { ...ROLES.roles, vision: "deepseek/deepseek-pro" } };
|
||||
render(<ModelUsage models={MODELS} roles={roles} main="deepseek/deepseek-flash" busy="" protocol={{}} onMain={vi.fn()} onRole={vi.fn()} />);
|
||||
const select = screen.getByRole("combobox", { name: "看图" }) as HTMLSelectElement;
|
||||
expect(select.value).toBe("deepseek/deepseek-pro");
|
||||
expect(select.selectedOptions[0]?.textContent).toBe("deepseek/deepseek-pro(未声明读图)");
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { activeKind, contextLabel, groupVendors, type Vendor } from "./Models";
|
||||
import { orderAccounts, useProviderOrder } from "../state/providerorder";
|
||||
|
||||
type RoleKey = keyof RoleAssignments["roles"];
|
||||
type Answers = "chat" | "decision" | "search";
|
||||
type Answers = "chat" | "decision" | "search" | "vision";
|
||||
|
||||
// Decision is the one job that cannot follow the main model: it asks a question
|
||||
// set, which a chat model has no answer for, so its row offers the decision
|
||||
@@ -19,8 +19,10 @@ const ROLES: [RoleKey, string, string, Answers][] = [
|
||||
["decision", "决策", "system_one 询问的后端", "decision"],
|
||||
];
|
||||
|
||||
const shown = (m: ModelEntry) => m.modelAlias?.trim() || m.modelShort || m.model;
|
||||
const answersOf = (m: ModelEntry): Answers => (m.answers === "decision" ? "decision" : "chat");
|
||||
const answersMatch = (m: ModelEntry, answers: Answers) => answers === "search" ? m.webSearch === true : answersOf(m) === answers;
|
||||
const answersMatch = (m: ModelEntry, answers: Answers) =>
|
||||
answers === "search" ? m.webSearch === true : answers === "vision" ? m.vision === true && answersOf(m) === "chat" : answersOf(m) === answers;
|
||||
|
||||
// Only what the config or the catalog declares: an inferred "reads images" sends
|
||||
// the user to a request the endpoint rejects.
|
||||
@@ -90,13 +92,13 @@ export function ModelUsage({ models, roles, main, busy, protocol, effort = "auto
|
||||
const set = roles.roles[key];
|
||||
const roleModel = byRef(set || (key === "title" || answers === "decision" ? "" : main));
|
||||
const roleEffort = roles.efforts?.[key] || "auto";
|
||||
const offered = models.filter((m) => answersOf(m) === answers);
|
||||
const offered = models.filter((m) => answersMatch(m, answers));
|
||||
const none = answers === "decision" && offered.length === 0;
|
||||
return (
|
||||
<div className="usage-row" role="row" key={key}>
|
||||
<span className="usage-job" role="rowheader"><b>{t(name)}</b><small>{t(tag)}</small></span>
|
||||
<span role="cell">
|
||||
<Choices vendors={vendors} protocol={protocol} main={main} value={set} answers={answers}
|
||||
<Choices vendors={vendors} protocol={protocol} main={main} value={set} answers={key === "vision" ? "vision" : answers}
|
||||
label={t(name)} role disabled={busy !== "" || none}
|
||||
empty={t(key === "title" ? "不使用" : answers === "chat" ? "跟随主模型" : none ? "尚无可用来源" : "不使用")}
|
||||
onPick={(ref) => onRole(key, ref, "auto")} />
|
||||
@@ -217,10 +219,11 @@ function Choices({
|
||||
onChange={(e) => onPick(e.target.value)}>
|
||||
{empty !== undefined && <option value="">{empty}</option>}
|
||||
{answers === "search" && value && !groups.some((g) => g.rows.some((m) => m.ref === value)) && <option value={value}>{t("{model}(不可用)", { model: value })}</option>}
|
||||
{answers === "vision" && value && !groups.some((g) => g.rows.some((m) => m.ref === value)) && <option value={value}>{t("{model}(未声明读图)", { model: value })}</option>}
|
||||
{groups.map(({ v, rows }) => (
|
||||
<optgroup key={v.key} label={v.label}>
|
||||
{rows.map((m) => (
|
||||
<option key={m.ref} value={m.ref}>{m.vision ? `${m.model} · ${t("读图")}` : m.model}</option>
|
||||
<option key={m.ref} value={m.ref}>{m.vision ? `${shown(m)} · ${t("读图")}` : shown(m)}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
|
||||
@@ -636,7 +636,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
|
||||
{alert && <div className="cmpalert">{alert}</div>}
|
||||
<RootMissingBanner items={s.items} onPick={onOpenProject} />
|
||||
{pill && <ChangedFilesPill changes={tree?.changes ?? []} onOpen={openChanges} />}
|
||||
<Composer worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} fresh={ready && !historyPaging.hasMore && !historyPaging.busy && !historyPaging.failed && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
|
||||
<Composer queued={waiting(queue).length > 0} worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} fresh={ready && !historyPaging.hasMore && !historyPaging.busy && !historyPaging.failed && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
|
||||
<MeterRail
|
||||
tps={tps} trail={trail} running={running} speed={speed} metrics={s.metrics} ctx={ctx} mcp={mcp} compact={compact} cost={cost} turnCost={s.metrics.coverage === "none" ? NO_COST : s.metrics.turn}
|
||||
wallet={wallet} hideAmounts={hideAmounts} tasks={rail.tasks} jobs={jobs} onSettings={onSettings}
|
||||
|
||||
@@ -190,7 +190,6 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
|
||||
? t("尚未送达,可以改,也可以取回")
|
||||
: t("正在送出…")}
|
||||
</span>
|
||||
{queue.paused && <span className="qflag" data-hold="">{t("已暂停")}</span>}
|
||||
{queue.readonly && <span className="qflag" data-ro="">{t("只读")}</span>}
|
||||
{/* The kernel pauses itself when it recovers entries: something was
|
||||
said that no one has since re-confirmed. */}
|
||||
@@ -218,7 +217,7 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
|
||||
const coded = it.blockCode ? BLOCK_WHY[it.blockCode] : undefined;
|
||||
const why = coded ? t(coded) : it.blockReason;
|
||||
return (
|
||||
<div key={it.id} className="qi" data-state={it.state}>
|
||||
<div key={it.id} className="qi" data-state={it.state} title={why || undefined}>
|
||||
{/* The chip is the answer to "did that land". Its wording says
|
||||
which turn the line goes into; the title says when. */}
|
||||
{ordinary(it) ? (
|
||||
@@ -257,7 +256,7 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
|
||||
<Overflow className="pv" text={it.preview} />
|
||||
)}
|
||||
{!!it.refs?.length && <span className="rf">{t("冻结 {n} 文件", { n: it.refs.length })}</span>}
|
||||
{why && <span className="qwhy">{why}</span>}
|
||||
{why && (it.state === "blocked" || it.state === "uncertain") && <span className="qwhy" title={why}>{why}</span>}
|
||||
{unread?.id === it.id && (
|
||||
<span className="qwhy" data-err="" role="alert">
|
||||
{t("无法读取该条的正文,未打开编辑:{why}", { why: unread.why })}
|
||||
@@ -266,15 +265,15 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
|
||||
{live && (
|
||||
<span className="qacts">
|
||||
{items.length > 1 && <>
|
||||
<button data-action="queue.move" data-target={it.id} data-value="up" onClick={() => onMove(it.id, i - 1)} disabled={i === 0} title={t("上移")} aria-label={t("上移")}>
|
||||
<button className="qmove" data-action="queue.move" data-target={it.id} data-value="up" onClick={() => onMove(it.id, i - 1)} disabled={i === 0} title={t("上移")} aria-label={t("上移")}>
|
||||
<StudioIcon name="arrow" />
|
||||
</button>
|
||||
<button data-action="queue.move" data-target={it.id} data-value="down" onClick={() => onMove(it.id, i + 1)} disabled={i === items.length - 1} title={t("下移")} aria-label={t("下移")}>
|
||||
<button className="qmove" data-action="queue.move" data-target={it.id} data-value="down" onClick={() => onMove(it.id, i + 1)} disabled={i === items.length - 1} title={t("下移")} aria-label={t("下移")}>
|
||||
<StudioIcon name="arrow" data-flip="" />
|
||||
</button>
|
||||
</>}
|
||||
<button data-action="queue.edit" data-target={it.id} onClick={() => void open(it.id)} title={t("编辑")}>
|
||||
{t("改")}
|
||||
<button data-action="queue.edit" data-target={it.id} onClick={() => void open(it.id)} title={t("编辑")} aria-label={t("改")}>
|
||||
<StudioIcon name="edit" />
|
||||
</button>
|
||||
{/* Waiting is the normal case and needs no button. This is the
|
||||
other one: the turn in front of this line is not worth
|
||||
@@ -304,8 +303,8 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
|
||||
</span>
|
||||
)}
|
||||
{live && (
|
||||
<button className="x" data-action="queue.cancel" data-target={it.id} onClick={() => onCancel(it.id)} title={t("取回")}>
|
||||
{t("取回")}
|
||||
<button className="x" data-action="queue.cancel" data-target={it.id} onClick={() => onCancel(it.id)} title={t("取回")} aria-label={t("取回")}>
|
||||
<StudioIcon name="rewind" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,8 +11,11 @@ import type { CapabilityScope, McpEntry, ModelEntry, PluginPackage, SkillEntry }
|
||||
import { useSettingsSearch } from "./useSettingsSearch";
|
||||
import { useSettingLanding } from "./useSettingLanding";
|
||||
import { bytes, tokens as fmtTokens } from "../i18n/format";
|
||||
import { STORAGE_TALLY_DELAY, cachedStorageTotal, storageTotal } from "./storageTally";
|
||||
import { SECTION_NAME } from "./prefsnav";
|
||||
import { SettingsNav } from "./SettingsNav";
|
||||
import { SettingsPageStyle } from "./SettingsPageStyle";
|
||||
import { firstPage, pageOf } from "./settingPages";
|
||||
import { Group, SCOPE_SAID } from "./Group";
|
||||
import { PlanApproval, DefaultMode } from "./PlanApproval";
|
||||
import { PeersSection } from "./PeersSection";
|
||||
@@ -92,6 +95,11 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
const localPort = localWorkspacePort ?? homePort;
|
||||
const [openedSection, openedAnchor = ""] = (opened ?? "").split(":", 2);
|
||||
const [at, setAt] = useState<Section>((openedSection as Section) || "session");
|
||||
const [pages, setPages] = useState<Partial<Record<Section, string>>>(() => {
|
||||
const first = pageOf((openedSection as Section) || "session", openedAnchor);
|
||||
return first ? { [(openedSection as Section) || "session"]: first } : {};
|
||||
});
|
||||
const pageNow = pages[at] ?? firstPage(at);
|
||||
// 搜索的语料是这张表,不是屏幕上的 DOM:分区是按需挂载的,读 DOM 就只搜得到
|
||||
// 当前这一页,而为了搜索把每一页都挂起来会让隐藏页面开始发请求。
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -184,12 +192,14 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
let alive = true;
|
||||
setTally({});
|
||||
const put = (k: Section, v: string) => { if (alive) setTally((prev) => ({ ...prev, [k]: v })); };
|
||||
homePort.storage().then((st) => put("storage", bytes(st.roots.reduce((n, r) => n + (r.bytes || 0), 0)))).catch(() => {});
|
||||
const known = cachedStorageTotal(homePort);
|
||||
if (known !== undefined) put("storage", bytes(known));
|
||||
const walk = known !== undefined ? undefined : setTimeout(() => { storageTotal(homePort).then((n) => put("storage", bytes(n))).catch(() => {}); }, STORAGE_TALLY_DELAY);
|
||||
// The same window Usage itself opens on, or the list and the page it opens
|
||||
// would report two different totals for one word.
|
||||
homePort.usage(DEFAULT_DAYS).then((u) => put("usage", fmtTokens(u.tokens))).catch(() => {});
|
||||
homePort.versions().then((v) => put("versions", v.current || "dev")).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
return () => { alive = false; clearTimeout(walk); };
|
||||
}, [homePort]);
|
||||
|
||||
// Focus lands once, when the pane opens, and goes back where it came from on
|
||||
@@ -313,6 +323,8 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
const go = (section: Section, anchor: string) => {
|
||||
navigation.leave(() => {
|
||||
setAt(section);
|
||||
const target = pageOf(section, anchor);
|
||||
if (target) setPages((p) => ({ ...p, [section]: target }));
|
||||
setQuery("");
|
||||
setLanded(anchor);
|
||||
if (section === "ext") setExtTab(anchor === "market" ? "market" : "installed");
|
||||
@@ -398,9 +410,9 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
</div>
|
||||
<ConfigTrouble port={port} onRepaired={onChanged} />
|
||||
<div className="prefs-body">
|
||||
<SettingsNav at={at} values={nav} shown={shown} danger={danger} disabled={updatingPkg.applying} onSelect={(section) => { if (section !== at) navigation.leave(() => { setLanded(""); setAt(section); }); }} />
|
||||
<SettingsNav at={at} values={nav} shown={shown} danger={danger} disabled={updatingPkg.applying} onSelect={(section) => { if (section !== at) navigation.leave(() => { setLanded(""); setAt(section); }); }} page={pageNow} onPage={(section, page) => navigation.leave(() => { setLanded(""); setPages((p) => ({ ...p, [section]: page })); })} />
|
||||
|
||||
<div className="prefs-main" key={at} role="tabpanel" aria-labelledby={`prefs-${at}`} data-sec={at}>
|
||||
<div className="prefs-main" key={at} role="tabpanel" aria-labelledby={pageNow ? `prefs-${at}-${pageNow}` : `prefs-${at}`} data-sec={at}>
|
||||
<Boundary
|
||||
fallback={
|
||||
<div className="find" data-lvl="err" role="alert">
|
||||
@@ -412,6 +424,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
{/* Keyed on the section: replacing this column is the navigation,
|
||||
and it is the one thing here whose mount really is the cause. */}
|
||||
<div className="prefs-col" key={at}>
|
||||
<SettingsPageStyle section={at} page={pageNow} />
|
||||
<SettingsHeading section={at} value={nav[at]} danger={danger(at)} />
|
||||
{/* Every refused write on every page lands in one state, so it is
|
||||
said in one place. Copied onto the pages somebody remembered, it
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Fragment } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { ICON, NAV, type Section } from "./prefsnav";
|
||||
import { arrowTabs } from "./tablist";
|
||||
import { pagesOf } from "./settingPages";
|
||||
import { UpdateDot } from "./UpdateDot";
|
||||
|
||||
interface Props {
|
||||
@@ -11,9 +12,11 @@ interface Props {
|
||||
danger: (id: Section) => boolean;
|
||||
disabled: boolean;
|
||||
onSelect: (id: Section) => void;
|
||||
page?: string;
|
||||
onPage?: (section: Section, page: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsNav({ at, values, shown, danger, disabled, onSelect }: Props) {
|
||||
export function SettingsNav({ at, values, shown, danger, disabled, onSelect, page, onPage }: Props) {
|
||||
return (
|
||||
<nav className="prefs-nav" role="tablist" aria-label={t("设置分类")} onKeyDown={arrowTabs}>
|
||||
{NAV.map(([group, items]) => {
|
||||
@@ -23,10 +26,11 @@ export function SettingsNav({ at, values, shown, danger, disabled, onSelect }: P
|
||||
<Fragment key={group}>
|
||||
<div className="navsec">{t(group)}</div>
|
||||
{rows.map(([id, name]) => (
|
||||
<button key={id} id={`prefs-${id}`} role="tab" title={t(name)}
|
||||
<Fragment key={id}>
|
||||
<button id={`prefs-${id}`} role="tab" title={t(name)}
|
||||
data-action="settings.section" data-value={id}
|
||||
tabIndex={at === id ? 0 : -1}
|
||||
aria-selected={at === id} disabled={disabled} onClick={() => onSelect(id)}>
|
||||
tabIndex={at === id && pagesOf(id).length === 0 ? 0 : -1} data-open={at === id && pagesOf(id).length > 0 ? "" : undefined}
|
||||
aria-selected={at === id && pagesOf(id).length === 0} disabled={disabled} onClick={() => onSelect(id)}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">{ICON[id]}</svg>
|
||||
{id === "versions" && <UpdateDot />}
|
||||
<span className="nm">{t(name)}</span>
|
||||
@@ -34,6 +38,15 @@ export function SettingsNav({ at, values, shown, danger, disabled, onSelect }: P
|
||||
{values[id]}
|
||||
</span>
|
||||
</button>
|
||||
{at === id && pagesOf(id).map((p) => (
|
||||
<button key={p.id} id={`prefs-${id}-${p.id}`} role="tab" className="prefs-page" title={t(p.label)}
|
||||
data-action="settings.page" data-value={`${id}:${p.id}`}
|
||||
tabIndex={page === p.id ? 0 : -1} aria-selected={page === p.id} disabled={disabled}
|
||||
onClick={() => onPage?.(id, p.id)}>
|
||||
<span className="nm">{t(p.label)}</span>
|
||||
</button>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import type { Section } from "./prefsnav";
|
||||
import { hiddenAnchors } from "./settingPages";
|
||||
|
||||
export function SettingsPageStyle({ section, page }: { section: Section; page: string | undefined }) {
|
||||
const style = useRef<HTMLStyleElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const panel = style.current?.closest<HTMLElement>(".prefs-main");
|
||||
if (panel) panel.scrollTop = 0;
|
||||
}, [section, page]);
|
||||
const hidden = page ? hiddenAnchors(section, page) : [];
|
||||
if (hidden.length === 0) return null;
|
||||
const blocks = hidden.map((id) => `.prefs-col [data-setting="${id}"]`).join(",");
|
||||
const wrappers = hidden.map((id) => `.prefs-col details:has(> [data-setting="${id}"])`).join(",");
|
||||
return <style ref={style}>{`${blocks}{display:none}${wrappers}{display:none}`}</style>;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { clearToasts, pushNote, pushToast } from "../state/toasts";
|
||||
import { clearToasts, pushNote, pushOffer, pushToast } from "../state/toasts";
|
||||
import { ToastStack } from "./ToastStack";
|
||||
|
||||
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
|
||||
@@ -35,4 +35,21 @@ describe("a note in the window's corner", () => {
|
||||
expect(failure.getAttribute("role")).toBe("alert");
|
||||
expect(failure.querySelector('[data-action="toast.report"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("runs what a note offers when its button is pressed, and then goes away", () => {
|
||||
const run = vi.fn();
|
||||
render(<ToastStack onFeedback={() => {}} />);
|
||||
act(() => pushOffer("太大了", { label: "取最近的内容", run }));
|
||||
const note = screen.getByText("太大了").closest(".toast")!;
|
||||
expect(note.getAttribute("role")).toBe("status");
|
||||
fireEvent.click(note.querySelector('[data-action="toast.act"]')!);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText("太大了")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers no button when the note has nothing to offer", () => {
|
||||
render(<ToastStack onFeedback={() => {}} />);
|
||||
act(() => pushNote("fyi"));
|
||||
expect(document.querySelector('[data-action="toast.act"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ export function ToastStack({ onFeedback, port, idle = false, look = {}, onLook =
|
||||
{toasts.map((toast) => toast.tone === "note" ? (
|
||||
<div className="toast note" role="status" key={toast.id}>
|
||||
<span>{toast.text}</span>
|
||||
{toast.action && <button data-action="toast.act" data-target={String(toast.id)} onClick={() => { dismissToast(toast.id); toast.action?.run(); }}>{toast.action.label}</button>}
|
||||
<button data-action="toast.dismiss" data-target={String(toast.id)} onClick={() => dismissToast(toast.id)}>{t("知道了")}</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import "./testkit";
|
||||
import { UploadUsage } from "./UploadUsage";
|
||||
import { HttpError, type UploadReport } from "../port/port";
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
||||
|
||||
const report = (over: Partial<UploadReport> = {}): UploadReport => ({
|
||||
from: "2026-09-28", to: "2026-10-27", bytes: 3 * 2 ** 30, requests: 1500,
|
||||
daily: [{ day: "2026-10-26", bytes: 0 }, { day: "2026-10-27", bytes: 200 * 2 ** 20 }],
|
||||
providers: [
|
||||
{ provider: "deepseek", bytes: 2.7 * 2 ** 30, requests: 1300, percent: 90 },
|
||||
{ provider: "relay", bytes: 0.3 * 2 ** 30, requests: 200, percent: 10 },
|
||||
],
|
||||
...over,
|
||||
});
|
||||
|
||||
it("says what was uploaded today and in the month, with the average request, and which provider took it", async () => {
|
||||
render(<UploadUsage port={{ uploadUsage: async () => report() }} />);
|
||||
await screen.findByText("deepseek");
|
||||
expect(screen.getByText(/^200(\.0)? MB$/)).toBeTruthy();
|
||||
expect(screen.getByText(/^3(\.0)? GB,1,?500 次请求,平均每次 2(\.\d)? MB$/)).toBeTruthy();
|
||||
expect(screen.getByText(/2\.7 GB(90%)/)).toBeTruthy();
|
||||
expect(screen.getByText("relay")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("asks for the last thirty days", async () => {
|
||||
const uploadUsage = vi.fn(async () => report());
|
||||
render(<UploadUsage port={{ uploadUsage }} />);
|
||||
await screen.findByText("deepseek");
|
||||
expect(uploadUsage).toHaveBeenCalledWith(30);
|
||||
});
|
||||
|
||||
it("says counting has only just started instead of drawing zeros", async () => {
|
||||
render(<UploadUsage port={{ uploadUsage: async () => report({ bytes: 0, requests: 0, daily: [], providers: [] }) }} />);
|
||||
await screen.findByText("还没有记录:从这个版本起开始统计。");
|
||||
expect(screen.queryByText("今天上传")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows why it could not be read", async () => {
|
||||
render(<UploadUsage port={{ uploadUsage: async () => { throw new HttpError(500, "x", { code: "internal.failed" }); } }} />);
|
||||
expect(await screen.findByRole("alert")).toBeTruthy();
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { bytes, count, pct } from "../i18n/format";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import type { AgentPort, UploadReport } from "../port/port";
|
||||
import { Group } from "./Group";
|
||||
|
||||
const DAYS = 30;
|
||||
|
||||
// What model providers were sent in the last month. Every request carries the
|
||||
// whole conversation, so this grows with context size times request count, not
|
||||
// with how much was typed; the numbers are request bodies, before the
|
||||
// connection's own overhead, and downloads are not counted.
|
||||
export function UploadUsage({ port }: { port: Pick<AgentPort, "uploadUsage"> }) {
|
||||
const [report, setReport] = useState<UploadReport | null>(null);
|
||||
const [failed, setFailed] = useState("");
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
port.uploadUsage(DAYS).then((r) => live && setReport(r), (e) => live && setFailed(reason(e)));
|
||||
return () => { live = false; };
|
||||
}, [port]);
|
||||
const today = report?.daily[report.daily.length - 1]?.bytes ?? 0;
|
||||
return (
|
||||
<Group id="upload-usage"
|
||||
title={t("上传量")}
|
||||
hint={t("发给模型服务商的请求内容大小(不含连接本身的开销,也不含下载)。每次请求都会带上完整的对话,所以它取决于上下文有多长、请求有多少次,缓存命中只省费用、不省流量。")}
|
||||
>
|
||||
{failed && <p className="note" role="alert">{failed}</p>}
|
||||
{report && report.requests === 0 && <p className="note">{t("还没有记录:从这个版本起开始统计。")}</p>}
|
||||
{report && report.requests > 0 && (
|
||||
<>
|
||||
<div className="lrow"><span className="ds">{t("今天上传")}</span><span className="sc">{bytes(today)}</span></div>
|
||||
<div className="lrow">
|
||||
<span className="ds">{t("近 {n} 天上传", { n: DAYS })}</span>
|
||||
<span className="sc">{t("{size},{n} 次请求,平均每次 {avg}", { size: bytes(report.bytes), n: count(report.requests), avg: bytes(Math.round(report.bytes / report.requests)) })}</span>
|
||||
</div>
|
||||
{report.providers.map((p) => (
|
||||
<div className="lrow" key={p.provider}>
|
||||
<span className="ds">{p.provider}</span>
|
||||
<span className="sc">{t("{size}({share})", { size: bytes(p.bytes), share: pct(p.percent / 100) })}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -393,7 +393,12 @@ export function Usage({ port }: { port: AgentPort }) {
|
||||
<div className="uhero-v">{moneyList(report.cost)}</div>
|
||||
{pricedFrom && <span className="uhero-scope">{t("{day} 起 · {n} 天有记录", { day: pricedFrom.slice(5), n: priced.length })}</span>}
|
||||
{report.costEstimated && <span className="uhero-scope">{t("含估算")}</span>}
|
||||
{report.costIncomplete && <span className="uhero-scope">{t("未定价调用未计入")}</span>}
|
||||
{report.costIncomplete && (
|
||||
<span className="uhero-scope">
|
||||
{t("未定价调用未计入")}
|
||||
{report.unpricedCalls ? ` · ${t("其中 {n} 笔未定价", { n: report.unpricedCalls })}` : null}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{report.estimateCost?.length ? (
|
||||
<div className="uhero-est">
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, waitFor } from "@testing-library/react";
|
||||
import "./testkit";
|
||||
import { boot, STORAGE } from "../i18n";
|
||||
import type { AgentPort, UsageReport } from "../port/port";
|
||||
import { Usage } from "./Usage";
|
||||
|
||||
// The kernel's total already knew its money was incomplete; this is the line
|
||||
// that says how many calls it could not price. A total that names only the
|
||||
// floor leaves the reader unable to ask which calls were under it.
|
||||
const setLocale = (lang: "zh" | "en") => { localStorage.setItem(STORAGE, lang); boot(); };
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); setLocale("zh"); });
|
||||
|
||||
const REPORT: UsageReport = {
|
||||
from: "2026-08-10", to: "2026-08-12", tokens: 3_000, requests: 9, turns: 6, cache_hit: 2_400, cache_miss: 600,
|
||||
cost: [{ amount: "4", currency: "CNY" }],
|
||||
// Four calls priced, five the range could not price, for two reasons.
|
||||
costIncomplete: true, unpricedCalls: 5, incompleteReasons: ["missing_price_or_usage", "no_price"],
|
||||
active_days: 3, top_model: "relay/a", top_provider: "relay", models: [], providers: [], daily: [],
|
||||
};
|
||||
|
||||
async function open(report: UsageReport) {
|
||||
const port = { usage: vi.fn().mockResolvedValue(report), providers: vi.fn().mockResolvedValue([]), models: vi.fn().mockResolvedValue([]) } as unknown as AgentPort;
|
||||
const view = render(<Usage port={port} />);
|
||||
await waitFor(() => expect(view.container.querySelector(".uhero")).toBeTruthy());
|
||||
return view;
|
||||
}
|
||||
|
||||
// The scopes beside the figure: the caveats a person reads before trusting it.
|
||||
function scopes(container: HTMLElement): string {
|
||||
return Array.from(container.querySelectorAll(".uhero-scope")).map((el) => el.textContent ?? "").join(" | ");
|
||||
}
|
||||
|
||||
describe("the unpriced-call count on the usage page", () => {
|
||||
it("says how many calls the total could not price, in Chinese", async () => {
|
||||
const { container } = await open(REPORT);
|
||||
expect(scopes(container)).toContain("未定价调用未计入");
|
||||
expect(scopes(container)).toContain("其中 5 笔未定价");
|
||||
});
|
||||
|
||||
it("says it in English too", async () => {
|
||||
setLocale("en");
|
||||
const { container } = await open(REPORT);
|
||||
expect(scopes(container)).toContain("unpriced calls not included");
|
||||
expect(scopes(container)).toContain("5 calls unpriced");
|
||||
});
|
||||
|
||||
it("shows nothing extra for a fully priced range, in either language", async () => {
|
||||
const priced: UsageReport = { ...REPORT, costIncomplete: false, unpricedCalls: 0, incompleteReasons: undefined };
|
||||
for (const lang of ["zh", "en"] as const) {
|
||||
setLocale(lang);
|
||||
const { container, unmount } = await open(priced);
|
||||
expect(scopes(container)).not.toContain(lang === "zh" ? "未定价" : "unpriced");
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not invent a zero count for a payload that predates the field", async () => {
|
||||
const { container } = await open({ ...REPORT, unpricedCalls: undefined });
|
||||
expect(scopes(container)).toContain("未定价调用未计入");
|
||||
expect(scopes(container)).not.toContain("其中 0 笔未定价");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { t } from "../i18n";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { DisplayCurrency } from "./DisplayCurrency";
|
||||
import { Group } from "./Group";
|
||||
import { UploadUsage } from "./UploadUsage";
|
||||
import { Usage } from "./Usage";
|
||||
|
||||
// The report is keyed on the currency choice: its amounts are read once, so a
|
||||
@@ -18,6 +19,7 @@ export function UsageSettings({ port, onChanged }: { port: AgentPort; onChanged:
|
||||
>
|
||||
<Usage key={rev} port={port} />
|
||||
</Group>
|
||||
<UploadUsage port={port} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -370,7 +370,7 @@ describe("WorkbenchPanel", () => {
|
||||
expect(screen.getByRole("tab", { name: "百度热搜" }).getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("keeps the column while another tab is open, and folds it with the last", async () => {
|
||||
it("keeps the column while another tab is open, and offers a new tab with the last", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCloseManual = vi.fn();
|
||||
const pages = [
|
||||
@@ -382,7 +382,8 @@ describe("WorkbenchPanel", () => {
|
||||
expect(onCloseManual).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("tab", { name: "My page" })).toBeTruthy();
|
||||
await user.click(screen.getByRole("button", { name: "关闭 My page" }));
|
||||
expect(onCloseManual).toHaveBeenCalledTimes(1);
|
||||
expect(onCloseManual).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("group", { name: "开始" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the column when its last tab closes while the explorer is open", async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { WorkspaceInfo, WorktreeSelection, WorktreeSources } from "../port/
|
||||
import type { AgentPort, WorkspaceGit } from "../port/port";
|
||||
import { useWorkspaceBranches } from "./useWorkspaceBranches";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { workspaceDirectoryDescription } from "./workspaceDirectory";
|
||||
import { WorkspacePath, WorkspaceBranchName, compactWorkspaceValue, workspaceName } from "./WorkspacePath";
|
||||
import { listenAction } from "./listen";
|
||||
import { beside, pinToViewport, useFollow, zoom } from "./place";
|
||||
@@ -43,6 +44,8 @@ export function WorktreeChip({ sources, canCreate, selection, branch, busy, chan
|
||||
const isolated = workspace?.current === sources.sourceRoot && !!workspace.isolated;
|
||||
const chosen = sources.sources.find((source) => source.ref === selection?.ref);
|
||||
const label = chosen?.name ?? selection?.ref ?? branch;
|
||||
const creationDescription = t("发送消息时创建独立工作树,基于所选分支的已提交版本,不包含未提交修改;端口、数据库等运行资源仍可能共享。");
|
||||
const directoryDescription = selection ? creationDescription : workspaceDirectoryDescription(isolated);
|
||||
const scope = useRef({ port, root: sources.sourceRoot });
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
@@ -96,13 +99,16 @@ export function WorktreeChip({ sources, canCreate, selection, branch, busy, chan
|
||||
const failed = selection ? sourceError : reading === "failed";
|
||||
const loading = !selection && (reading === "idle" || reading === "loading") && locals.length === 0;
|
||||
return <div className="studio-branch-pop">
|
||||
<button ref={trigger} type="button" className="mode plain studio-branch worktree-trigger" data-on={selection || isolated ? "" : undefined} data-pending={moving !== null ? "" : undefined} data-action="workspace.configure" aria-label={t("配置工作目录")} title={selection ? label : branch} aria-haspopup="dialog" aria-expanded={open} disabled={busy || moving !== null} onClick={show}>
|
||||
<StudioIcon name="branch"/><span className="lb">{moving ? t("正在切换到 {branch}…", { branch: moving }) : selection || isolated ? t("独立 worktree · {branch}", { branch: compactWorkspaceValue(label) }) : t("当前目录 · {branch}", { branch: compactWorkspaceValue(branch) })}</span>{changeCount > 0 && <small>{t("{n} 个变更", { n: changeCount })}</small>}<StudioIcon name="down"/>
|
||||
<button ref={trigger} type="button" className="mode plain studio-branch worktree-trigger" data-on={selection || isolated ? "" : undefined} data-pending={moving !== null ? "" : undefined} data-action="workspace.configure" aria-label={t("配置工作目录")} title={`${directoryDescription}\n${selection ? label : branch}\n${sources.sourceRoot}`} aria-haspopup="dialog" aria-expanded={open} disabled={busy || moving !== null} onClick={show}>
|
||||
<StudioIcon name="branch"/><span className="lb">{moving ? t("正在切换到 {branch}…", { branch: moving }) : selection || isolated ? t("独立工作树 · {branch}", { branch: compactWorkspaceValue(label) }) : compactWorkspaceValue(branch)}</span>{changeCount > 0 && <small>{t("{n} 个变更", { n: changeCount })}</small>}<StudioIcon name="down"/>
|
||||
</button>
|
||||
{open && createPortal(<div ref={panel} className="workspace-palette" role="dialog" aria-labelledby={title}>
|
||||
<div className="workspace-palette-heading"><span id={title}>{t("配置工作目录")}</span><button data-action="workspace.configure" className="workspace-palette-close" aria-label={t("关闭工作目录配置")} onClick={close}><StudioIcon name="close"/></button></div>
|
||||
<div className="workspace-palette-identity"><StudioIcon name="folder"/><strong title={sources.sourceRoot}>{workspaceName(sources.sourceRoot)}</strong><span>{t(selection ? "新建 worktree" : isolated ? "独立 worktree" : "当前目录")}</span></div>
|
||||
{canCreate && <div className="worktree-modes"><button data-action="workspace.worktree-toggle" aria-pressed={!selection} disabled={busy || moving !== null} onClick={() => { onSelect(null); setQuery(""); }}><StudioIcon name="folder"/>{t("使用当前目录")}</button><button data-action="workspace.worktree-toggle" aria-pressed={!!selection} disabled={busy || moving !== null || !sources.available} onClick={selectWorktree}><StudioIcon name="branch"/>{t("新建 worktree")}</button></div>}
|
||||
<div className="workspace-palette-identity"><StudioIcon name="folder"/><strong title={sources.sourceRoot}>{workspaceName(sources.sourceRoot)}</strong>{(selection || isolated) && <span title={directoryDescription}>{t("独立工作树")}</span>}</div>
|
||||
{canCreate && <div className="worktree-modes">
|
||||
<button data-action="workspace.worktree-toggle" title={workspaceDirectoryDescription(false)} aria-pressed={!selection} disabled={busy || moving !== null} onClick={() => { onSelect(null); setQuery(""); }}><StudioIcon name="folder"/>{t("当前目录")}{!selection && <StudioIcon name="check"/>}</button>
|
||||
<button data-action="workspace.worktree-toggle" title={creationDescription} aria-pressed={!!selection} disabled={busy || moving !== null || !sources.available} onClick={selectWorktree}><StudioIcon name="branch"/>{t("独立工作树")}{selection && <StudioIcon name="check"/>}</button>
|
||||
</div>}
|
||||
{selection && <div className="workspace-palette-base"><span>{t("起始分支")}</span><strong title={label}>{compactWorkspaceValue(label)}</strong><small>{t("任务分支自动创建")}</small></div>}
|
||||
<div className="workspace-palette-search"><StudioIcon name="search"/><input data-action-change="workspace.source-search" data-action-keydown="workspace.source-navigate" ref={search} type="search" aria-label={t("搜索分支")} placeholder={t(selection ? "搜索起始分支…" : "搜索本地分支…")} value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); panel.current?.querySelector<HTMLElement>('[role="option"]:not(:disabled)')?.focus(); }
|
||||
|
||||
@@ -53,7 +53,7 @@ export function WorktreeSetupPage({ stage, task, files, busy, onRetry, onCancel,
|
||||
const icon = ready || retained ? "check" : failed || uncertain ? "warning" : cancelled ? "close" : "clock";
|
||||
const phase = error || t(phases[stage.phase] || "正在等待状态确认");
|
||||
return <section className="worktree-setup" data-phase={stage.phase} aria-label={t("工作目录准备")}>
|
||||
<header className="worktree-setup-header"><span className="worktree-setup-context"><StudioIcon name="folder"/><strong>{workspaceName(stage.sourceRoot)}</strong><span>{t("独立 worktree")}</span></span><h1 ref={heading} tabIndex={-1} aria-live="polite">{t(headline)}</h1><p className="worktree-setup-description">{t(description)}</p></header>
|
||||
<header className="worktree-setup-header"><span className="worktree-setup-context"><StudioIcon name="folder"/><strong>{workspaceName(stage.sourceRoot)}</strong><span>{t("独立工作树")}</span></span><h1 ref={heading} tabIndex={-1} aria-live="polite">{t(headline)}</h1><p className="worktree-setup-description">{t(description)}</p></header>
|
||||
<div className="worktree-setup-phase"><span>{active ? <span className="worktree-spinner"/> : <StudioIcon name={icon}/>}<span>{phase}</span></span>{active && <span className="worktree-setup-elapsed">{t("已等待 {n} 秒", { n: elapsed })}</span>}</div>
|
||||
{slow && <p className="worktree-setup-slow" role="status">{t("仍在等待创建完成。你可以继续等待、查看详情,或取消创建。")}</p>}
|
||||
{stage.phase === "cleanup_failed" && <p className="worktree-setup-slow" role="note">{t("请先保存任务与附件,在终端核对并处理保留的目录和任务分支。确认处理完成后,在会话菜单中关闭此会话,再从源目录新建会话重试。")}</p>}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.resetModules(); });
|
||||
|
||||
it.each([
|
||||
["Macintosh", "⌘⇧Space", "⌃⌥↑"],
|
||||
["Windows", "Ctrl+Shift+Space", "Ctrl+Alt+↑"],
|
||||
["Linux", "Ctrl+Shift+Space", "Ctrl+Alt+↑"],
|
||||
])("presents accelerators in %s notation", async (userAgent, primary, explicit) => {
|
||||
vi.stubGlobal("navigator", { userAgent });
|
||||
vi.resetModules();
|
||||
// Platform detection happens at module load, after this test supplies the agent.
|
||||
const { acceleratorLabel } = await import("./keys");
|
||||
expect(acceleratorLabel("CommandOrControl+Shift+Space")).toBe(primary);
|
||||
expect(acceleratorLabel("Control+Alt+Up")).toBe(explicit);
|
||||
expect(acceleratorLabel("")).toBe("");
|
||||
});
|
||||
@@ -40,6 +40,10 @@ export function toolErrorDetail(tool: Tool): string {
|
||||
case "video.ffmpeg_missing":
|
||||
case "video.unsupported_container":
|
||||
case "video.too_large":
|
||||
case "shell.mixed_mutation_verification":
|
||||
case "shell.inline_interpreter_not_last":
|
||||
case "batch.skipped_after_failed_change":
|
||||
case "batch.check_after_stopped_call":
|
||||
if (toolRefusalReason(tool)) return "";
|
||||
}
|
||||
return tool.err ?? "";
|
||||
|
||||
@@ -10,10 +10,16 @@ export interface FeedbackDraft {
|
||||
|
||||
const EMPTY: FeedbackDraft = { category: "bug", body: "", contact: "", shots: [] };
|
||||
|
||||
let held: FeedbackDraft = EMPTY;
|
||||
const CONTACT_KEY = "rx-feedback-contact";
|
||||
let held: FeedbackDraft | null = null;
|
||||
|
||||
export function heldDraft(): FeedbackDraft {
|
||||
return held;
|
||||
if (held) return held;
|
||||
try {
|
||||
return { ...EMPTY, contact: localStorage.getItem(CONTACT_KEY) ?? "" };
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
export function holdDraft(next: FeedbackDraft): void {
|
||||
@@ -21,5 +27,13 @@ export function holdDraft(next: FeedbackDraft): void {
|
||||
}
|
||||
|
||||
export function dropDraft(): void {
|
||||
held = EMPTY;
|
||||
held = null;
|
||||
}
|
||||
|
||||
export function rememberContact(contact: string): void {
|
||||
try {
|
||||
localStorage.setItem(CONTACT_KEY, contact);
|
||||
} catch {
|
||||
// Unavailable storage must not turn an accepted report into a failed send.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { boot, STORAGE, t } from "../i18n";
|
||||
import type { GlobalShortcutInfo } from "../port/host";
|
||||
import { acceleratorLabel } from "./keys";
|
||||
|
||||
const fixture = vi.hoisted(() => ({ shell: true, now: { binding: "", status: "disabled" } as GlobalShortcutInfo, apply: vi.fn(), read: vi.fn() }));
|
||||
vi.mock("../port/host", () => ({ host: () => ({ inShell: () => fixture.shell, globalShortcut: fixture.read, setGlobalShortcut: fixture.apply }) }));
|
||||
import { GlobalShortcutSection } from "./GlobalShortcutSection";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear(); localStorage.setItem(STORAGE, "zh"); boot();
|
||||
fixture.shell = true; fixture.now = { binding: "", status: "disabled" };
|
||||
fixture.read.mockImplementation(async () => fixture.now);
|
||||
fixture.apply.mockReset();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
it("is absent in a browser and cannot configure a remote host", async () => {
|
||||
fixture.shell = false; fixture.now = { binding: "", status: "unsupported" };
|
||||
render(<GlobalShortcutSection />);
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
expect(fixture.apply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies immediately through the shell, then disables without localStorage state", async () => {
|
||||
fixture.apply.mockImplementation(async (binding: string) => ({ binding, status: binding ? "registered" : "disabled" }));
|
||||
render(<GlobalShortcutSection />);
|
||||
await screen.findByText(t("全局快捷键已关闭"));
|
||||
await userEvent.type(screen.getByRole("textbox"), "Control+Shift+Space");
|
||||
await userEvent.click(screen.getByRole("button", { name: t("应用快捷键") }));
|
||||
expect(fixture.apply).toHaveBeenLastCalledWith("Control+Shift+Space");
|
||||
expect((await screen.findByRole("status")).textContent).toContain(acceleratorLabel("Control+Shift+Space"));
|
||||
expect(localStorage.length).toBe(1);
|
||||
await userEvent.click(screen.getByRole("button", { name: t("清除快捷键") }));
|
||||
expect(fixture.apply).toHaveBeenLastCalledWith("");
|
||||
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("");
|
||||
expect(screen.getByRole("status").textContent).toBe(t("全局快捷键已关闭"));
|
||||
});
|
||||
|
||||
it.each(["invalid", "unavailable", "save"] as const)("shows %s rebind failure beside the still-active old binding", async error => {
|
||||
fixture.now = { binding: "Control+Space", status: "registered" };
|
||||
fixture.apply.mockResolvedValue({ ...fixture.now, error });
|
||||
render(<GlobalShortcutSection />);
|
||||
await screen.findByText(t("已注册:{value}", { value: acceleratorLabel("Control+Space") }));
|
||||
await userEvent.clear(screen.getByRole("textbox"));
|
||||
await userEvent.type(screen.getByRole("textbox"), "occupied");
|
||||
await userEvent.click(screen.getByRole("button", { name: t("应用快捷键") }));
|
||||
expect(await screen.findByRole("alert")).toBeTruthy();
|
||||
expect(screen.getByRole("status").textContent).toContain(acceleratorLabel("Control+Space"));
|
||||
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("occupied");
|
||||
});
|
||||
|
||||
it("reports startup failure and lets the person retry the saved binding", async () => {
|
||||
fixture.now = { binding: "Control+Space", status: "unavailable" };
|
||||
fixture.apply.mockResolvedValue({ binding: "Control+Space", status: "registered" });
|
||||
render(<GlobalShortcutSection />);
|
||||
await screen.findByText(t("系统未能注册此快捷键:可能已被占用、未获授权,或当前桌面不支持。"));
|
||||
await userEvent.click(screen.getByRole("button", { name: t("应用快捷键") }));
|
||||
expect(fixture.apply).toHaveBeenCalledWith("Control+Space");
|
||||
expect(screen.getByRole("status").textContent).toContain(acceleratorLabel("Control+Space"));
|
||||
});
|
||||
|
||||
it("does not call an IPC transport failure a successful registration", async () => {
|
||||
fixture.apply.mockRejectedValue(new Error("shell unavailable"));
|
||||
render(<GlobalShortcutSection />);
|
||||
await screen.findByText(t("全局快捷键已关闭"));
|
||||
await userEvent.click(screen.getByRole("button", { name: t("应用快捷键") }));
|
||||
expect(await screen.findByRole("alert")).toBeTruthy();
|
||||
expect(screen.getByRole("status").textContent).toBe(t("全局快捷键已关闭"));
|
||||
});
|
||||
|
||||
it("records a modifier chord without submitting it, presents it, and clears it", async () => {
|
||||
fixture.apply.mockImplementation(async (binding: string) => ({ binding, status: binding ? "registered" : "disabled" }));
|
||||
render(<GlobalShortcutSection />);
|
||||
await screen.findByText(t("全局快捷键已关闭"));
|
||||
await userEvent.click(screen.getByRole("button", { name: t("录制快捷键") }));
|
||||
const input = screen.getByRole("textbox") as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(input.readOnly).toBe(true);
|
||||
fireEvent.keyDown(input, { key: "c", code: "KeyC" });
|
||||
fireEvent.keyDown(input, { key: "A", code: "KeyA", shiftKey: true });
|
||||
fireEvent.keyDown(input, { key: " ", code: "Space", shiftKey: true });
|
||||
expect(input.value).toBe("");
|
||||
expect(input.readOnly).toBe(true);
|
||||
fireEvent.keyDown(input, { key: "Control", ctrlKey: true });
|
||||
expect(input.value).toBe("");
|
||||
fireEvent.keyDown(input, { key: " ", code: "Space", ctrlKey: true, shiftKey: true });
|
||||
expect(input.value).toBe("Control+Shift+Space");
|
||||
expect(input.readOnly).toBe(false);
|
||||
expect(screen.getByText(acceleratorLabel("Control+Shift+Space")).tagName).toBe("KBD");
|
||||
expect(fixture.apply).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: t("应用快捷键") }));
|
||||
expect(fixture.apply).toHaveBeenLastCalledWith("Control+Shift+Space");
|
||||
await userEvent.click(screen.getByRole("button", { name: t("清除快捷键") }));
|
||||
expect(fixture.apply).toHaveBeenLastCalledWith("");
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("can cancel capture with Escape or Tab without changing the existing choice", async () => {
|
||||
fixture.now = { binding: "Control+Space", status: "registered" };
|
||||
render(<GlobalShortcutSection />);
|
||||
await screen.findByRole("status");
|
||||
const input = screen.getByRole("textbox") as HTMLInputElement;
|
||||
for (const key of ["Escape", "Tab"]) {
|
||||
await userEvent.click(screen.getByRole("button", { name: t("录制快捷键") }));
|
||||
await userEvent.keyboard(`{${key}}`);
|
||||
expect(input.readOnly).toBe(false);
|
||||
expect(input.value).toBe("Control+Space");
|
||||
}
|
||||
expect(fixture.apply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records Command, Option, navigation and plus keys without Electron grammar", async () => {
|
||||
render(<GlobalShortcutSection />);
|
||||
await screen.findByRole("status");
|
||||
const input = screen.getByRole("textbox") as HTMLInputElement;
|
||||
for (const [key, binding] of [["ArrowUp", "Alt+Super+Up"], ["+", "Alt+Super+Plus"]]) {
|
||||
await userEvent.click(screen.getByRole("button", { name: t("录制快捷键") }));
|
||||
fireEvent.keyDown(input, { key, altKey: true, metaKey: true });
|
||||
expect(input.value).toBe(binding);
|
||||
}
|
||||
expect(fixture.apply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ key: "˚", code: "KeyK", altKey: true }, "Alt+K"],
|
||||
[{ key: "!", code: "Digit1", ctrlKey: true, shiftKey: true }, "Control+Shift+1"],
|
||||
[{ key: "?", code: "Slash", ctrlKey: true, shiftKey: true }, "Control+Shift+/"],
|
||||
])("records a chord when the layout produces $key", async (event, binding) => {
|
||||
render(<GlobalShortcutSection />);
|
||||
await screen.findByRole("status");
|
||||
await userEvent.click(screen.getByRole("button", { name: t("录制快捷键") }));
|
||||
const input = screen.getByRole("textbox") as HTMLInputElement;
|
||||
fireEvent.keyDown(input, event);
|
||||
expect(input.value).toBe(binding);
|
||||
expect(fixture.apply).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import type { SessionStatus } from "../port/port";
|
||||
const answer: { now: GraphicsInfo | null } = { now: null };
|
||||
vi.mock("../port/host", async (original) => {
|
||||
const real = await original<typeof import("../port/host")>();
|
||||
return { ...real, host: () => ({ ...real.host(), graphics: () => Promise.resolve(answer.now) }) };
|
||||
return { ...real, host: () => Object.assign(Object.create(real.host()), { graphics: () => Promise.resolve(answer.now) }) };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -23,7 +23,7 @@ afterEach(() => {
|
||||
boot();
|
||||
});
|
||||
|
||||
async function search(q: string) {
|
||||
async function search(q: string, anchor = "graphics") {
|
||||
render(<Settings
|
||||
hub={new MockHub() as never} port={new MockPort()}
|
||||
status={{ preset: "balanced", toolApprovalMode: "ask" } as SessionStatus}
|
||||
@@ -34,7 +34,7 @@ async function search(q: string) {
|
||||
/>);
|
||||
await act(async () => {});
|
||||
await userEvent.type(screen.getByRole("textbox", { name: t("搜索设置") }), q);
|
||||
return screen.queryAllByRole("option").some((o) => (o as HTMLElement).dataset.target === "graphics");
|
||||
return screen.queryAllByRole("option").some((o) => (o as HTMLElement).dataset.target === anchor);
|
||||
}
|
||||
|
||||
it("does not offer the graphics setting in a browser tab", async () => {
|
||||
@@ -45,3 +45,7 @@ it("offers the graphics setting where the shell answers", async () => {
|
||||
answer.now = { launchedOff: false, savedOff: false, compositing: "enabled" };
|
||||
expect(await search("gpu")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not offer a local shell global shortcut in a browser or network viewer", async () => {
|
||||
expect(await search("global", "global-shortcut")).toBe(false);
|
||||
});
|
||||
@@ -15,6 +15,13 @@ export function chord(key: string): string {
|
||||
return mac ? `⌘${key}` : `Ctrl ${key}`;
|
||||
}
|
||||
|
||||
export function acceleratorLabel(binding: string): string {
|
||||
const names: Record<string, string> = mac
|
||||
? { CommandOrControl: "⌘", CmdOrCtrl: "⌘", Command: "⌘", Cmd: "⌘", Control: "⌃", Ctrl: "⌃", Alt: "⌥", Option: "⌥", Shift: "⇧", Super: "⌘", Meta: "⌘", Space: "Space", Up: "↑", Down: "↓", Left: "←", Right: "→" }
|
||||
: { CommandOrControl: "Ctrl", CmdOrCtrl: "Ctrl", Control: "Ctrl", Command: "Super", Cmd: "Super", Meta: "Super", Option: "Alt", Up: "↑", Down: "↓", Left: "←", Right: "→" };
|
||||
return binding.split("+").map(part => names[part] ?? part).join(mac ? "" : "+");
|
||||
}
|
||||
|
||||
// asksDelete reads the key that deletes whatever holds focus: Delete everywhere,
|
||||
// and ⌘⌫ on macOS, whose main key sends Backspace.
|
||||
export function asksDelete(ev: { key: string; metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean }): boolean {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { expect, it } from "vitest";
|
||||
import type { ModelEntry } from "../port/port";
|
||||
import { accountKey } from "./vendors";
|
||||
import { modelMenu } from "./modelmenu";
|
||||
import { modelLabel, modelMenu } from "./modelmenu";
|
||||
import { UNCONFIGURED_REF } from "./ConnectNote";
|
||||
|
||||
it("groups the composer model menu by saved account order", () => {
|
||||
const models: ModelEntry[] = [
|
||||
@@ -91,3 +92,18 @@ it("puts a relay's own mark on its heading and leaves its model rows to the lett
|
||||
expect(items.find((it) => it.header)?.mark).toBe("openrouter");
|
||||
expect(items.find((it) => it.value === "or/x")?.mark).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names the session's model by alias, then short form, then the id without its provider", () => {
|
||||
const models: ModelEntry[] = [
|
||||
{ ref: "relay/grp/aliased", provider: "relay", vendor: "relay.example", model: "grp/aliased", modelAlias: " 快模型 ", modelShort: "aliased" },
|
||||
{ ref: "relay/grp/short", provider: "relay", vendor: "relay.example", model: "grp/short", modelShort: "short" },
|
||||
{ ref: "relay/plain", provider: "relay", vendor: "relay.example", model: "plain" },
|
||||
];
|
||||
expect(modelLabel(models, { modelRef: "relay/grp/aliased" })).toBe("快模型");
|
||||
expect(modelLabel(models, { modelRef: "relay/grp/short" })).toBe("short");
|
||||
expect(modelLabel(models, { modelRef: "relay/plain" })).toBe("plain");
|
||||
expect(modelLabel(models, { modelRef: "gone/other/model", label: "x" })).toBe("other/model");
|
||||
expect(modelLabel(models, { label: "from-status" })).toBe("from-status");
|
||||
expect(modelLabel(models, null)).toBe("—");
|
||||
expect(modelLabel(models, { modelRef: UNCONFIGURED_REF })).toBe("未连接");
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { ModelEntry } from "../port/port";
|
||||
import type { MenuItem } from "./Menu";
|
||||
import { groupVendors } from "./Models";
|
||||
import { t } from "../i18n";
|
||||
import { UNCONFIGURED_REF } from "./ConnectNote";
|
||||
import { KIND_LABEL } from "./vendors";
|
||||
import { orderAccounts } from "../state/providerorder";
|
||||
|
||||
@@ -13,6 +14,14 @@ export function modelTooltip(models: ModelEntry[], current: string | undefined,
|
||||
return account ? `${account.label} / ${label}` : current ?? label;
|
||||
}
|
||||
|
||||
// modelLabel is how the composer names the session's model: the alias, else the
|
||||
// entry's short form, else the id without its provider.
|
||||
export function modelLabel(models: ModelEntry[], status: { modelRef?: string; label?: string } | null | undefined): string {
|
||||
if (status?.modelRef === UNCONFIGURED_REF) return t("未连接");
|
||||
const m = models.find((x) => x.ref === status?.modelRef);
|
||||
return m?.modelAlias?.trim() || m?.modelShort || (status?.modelRef?.replace(/^[^/]+\//, "") ?? status?.label ?? "—");
|
||||
}
|
||||
|
||||
// Every account gets a heading with its endpoint: the provider name is the
|
||||
// config's word for an entry, not the user's for an endpoint, and two doors onto
|
||||
// one account share it. Rows carry the wire format beside the model name.
|
||||
@@ -27,7 +36,7 @@ export function modelMenu(models: ModelEntry[], order: readonly string[] = [], c
|
||||
for (const m of a.byKind[kind]) {
|
||||
out.push({
|
||||
value: m.ref,
|
||||
label: m.modelAlias?.trim() || m.model,
|
||||
label: m.modelAlias?.trim() || m.modelShort || m.model,
|
||||
desc: m.modelAlias?.trim() ? m.model : undefined,
|
||||
meta: t(KIND_LABEL[kind] ?? kind),
|
||||
badge: m.vision ? t("读图") : undefined,
|
||||
|
||||
@@ -60,7 +60,8 @@ const toggleSidebar = () => userEvent.click(document.querySelector<HTMLElement>(
|
||||
|
||||
async function openAppearance() {
|
||||
await userEvent.click(document.querySelector<HTMLElement>('[data-action="chrome.settings"]')!);
|
||||
await userEvent.click(await screen.findByRole("tab", { name: /外观/ }));
|
||||
await userEvent.click(await screen.findByRole("tab", { name: /^外观/ }));
|
||||
await userEvent.click(await screen.findByRole("tab", { name: /导航与侧栏/ }));
|
||||
await screen.findByRole("group", { name: "图标栏" });
|
||||
}
|
||||
const choice = (name: string) => screen.getByRole("button", { name });
|
||||
|
||||
@@ -304,6 +304,7 @@ export const SETTINGS: SettingEntry[] = [
|
||||
{ section: "versions", anchor: "sponsor", title: "赞助", scope: "machine", apply: "none", keywords: ["捐助", "支付宝", "微信支付", "收款码", "paypal", "donate", "sponsor", "support", "alipay", "wechat"] },
|
||||
{ section: "memory", anchor: "memory", title: "记忆", scope: "chosen", apply: "immediate", keywords: ["记住", "忘记", "事实"] },
|
||||
{ section: "usage", anchor: "currency", title: "费用显示币种", scope: "machine", apply: "immediate", keywords: ["币种", "货币", "人民币", "美元", "currency", "CNY", "USD", "汇率"] },
|
||||
{ section: "usage", anchor: "upload-usage", title: "上传量", scope: "machine", apply: "none", keywords: ["流量", "上传", "带宽", "请求", "upload", "traffic", "bandwidth"] },
|
||||
{ section: "usage", anchor: "usage", title: "用量与成本", scope: "machine", apply: "none", keywords: ["token", "花费", "缓存命中"] },
|
||||
{ section: "storage", anchor: "storage", title: "存储", scope: "machine", apply: "restart", keywords: ["搬家", "迁移", "磁盘", "位置"] },
|
||||
{ section: "advanced", anchor: "elsewhere", title: "本版本尚未提供", scope: "machine", apply: "none", keywords: ["配置文件"] },
|
||||
@@ -319,6 +320,7 @@ export const SETTINGS: SettingEntry[] = [
|
||||
{ section: "appearance", anchor: "legacy-sessions", title: "1.x 旧会话", scope: "machine", apply: "immediate", keywords: ["1.x", "旧会话", "迁移", "隐藏", "legacy", "sessions", "sidebar"] },
|
||||
{ section: "appearance", anchor: "live-grace", title: "进行中列表保留已完成会话", scope: "machine", apply: "immediate", keywords: ["进行中", "完成", "保留", "过滤", "running", "grace", "sidebar"] },
|
||||
{ section: "appearance", anchor: "send-shortcut", title: "发送快捷键", scope: "machine", apply: "immediate", keywords: ["发送", "回车", "换行", "enter", "ctrl", "cmd"] },
|
||||
{ section: "appearance", anchor: "global-shortcut", title: "全局显示 / 隐藏快捷键", scope: "machine", apply: "immediate", keywords: ["global", "show", "hide", "shortcut", "keyboard", "全局", "显示", "隐藏"] },
|
||||
{ section: "appearance", anchor: "keys", title: "快捷键", scope: "machine", apply: "immediate", keywords: ["shortcut", "keyboard", "ctrl", "cmd", "esc", "home", "查找", "搜索", "运行分析", "工作台"] },
|
||||
{ section: "appearance", anchor: "esc-stop", title: "Esc 停止任务", scope: "machine", apply: "immediate", keywords: ["esc", "停止", "中断", "确认", "误触", "stop"] },
|
||||
{ section: "appearance", anchor: "page-sleep", title: "空闲网页休眠", scope: "machine", apply: "immediate", keywords: ["休眠", "睡眠", "空闲", "内存", "收起", "browser", "page", "sleep", "memory"] },
|
||||
|
||||
@@ -41,6 +41,36 @@ const paint = (q: QueueSnapshot, running: boolean) =>
|
||||
const draw = (q: QueueSnapshot) => paint(q, true);
|
||||
const drawIdle = (q: QueueSnapshot) => paint(q, false);
|
||||
|
||||
describe("compact queue presentation", () => {
|
||||
it("keeps action identities and order when edit and withdraw become icons", () => {
|
||||
const html = draw(snapshot({ items: [item(), item({ id: "i2" })] }));
|
||||
expect(html).toContain('title="编辑" aria-label="改"');
|
||||
expect(html).toContain('title="取回" aria-label="取回"');
|
||||
const actions = [...html.matchAll(/data-action="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(actions).toEqual(["queue.pause", "queue.move", "queue.move", "queue.edit", "queue.send-now", "queue.cancel",
|
||||
"queue.move", "queue.move", "queue.edit", "queue.send-now", "queue.cancel"]);
|
||||
expect(html).not.toContain("draggable");
|
||||
});
|
||||
|
||||
it("says paused once and preserves the resume control", () => {
|
||||
const html = draw(snapshot({ paused: true }));
|
||||
expect(html).toContain("发送已暂停");
|
||||
expect(html).not.toContain('data-hold');
|
||||
expect(html).toContain("继续派发");
|
||||
});
|
||||
|
||||
it("keeps long reasons in titles and limits inline reasons to held entries", () => {
|
||||
const why = "long delivery explanation";
|
||||
const queued = draw(snapshot({ items: [item({ state: "queued", blockReason: why })] }));
|
||||
expect(queued).toContain(`title="${why}"`);
|
||||
expect(queued).not.toContain('class="qwhy"');
|
||||
for (const state of ["blocked", "uncertain"] as const) {
|
||||
expect(draw(snapshot({ items: [item({ state, blockReason: why })] })))
|
||||
.toContain(`class="qwhy" title="${why}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("why a held entry stopped", () => {
|
||||
const held = (over: Partial<QueueItem>) => draw(snapshot({ paused: true, items: [item({ state: "uncertain", ...over })] }));
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SETTINGS } from "./prefsnav";
|
||||
import { PAGES, pageOf, firstPage, hiddenAnchors } from "./settingPages";
|
||||
|
||||
describe("the pages of a long settings section", () => {
|
||||
const paged = Object.keys(PAGES) as (keyof typeof PAGES)[];
|
||||
|
||||
it("has at least two pages wherever it has any", () => {
|
||||
for (const section of paged) expect(PAGES[section]!.length, section).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("files every setting of a paged section on exactly one page", () => {
|
||||
for (const section of paged) {
|
||||
const anchors = SETTINGS.filter((e) => e.section === section).map((e) => e.anchor);
|
||||
const filed = PAGES[section]!.flatMap((p) => p.anchors);
|
||||
expect([...filed].sort(), `${section}: every row on one page, no page naming a row it does not have`).toEqual([...anchors].sort());
|
||||
}
|
||||
});
|
||||
|
||||
it("answers which page an anchor is on, and the first page of a section", () => {
|
||||
expect(pageOf("tools", "sandbox")).toBe("sandbox");
|
||||
expect(pageOf("tools", "approval")).toBe(firstPage("tools"));
|
||||
expect(pageOf("session", "preset")).toBeUndefined();
|
||||
expect(firstPage("session")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hides everything a page does not own and nothing it does", () => {
|
||||
const hidden = hiddenAnchors("tools", "sandbox");
|
||||
expect(hidden).toContain("approval");
|
||||
expect(hidden).not.toContain("sandbox");
|
||||
expect(hidden).not.toContain("shell");
|
||||
expect(hiddenAnchors("session", "anything")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Section } from "./prefsnav";
|
||||
|
||||
export interface SettingPage {
|
||||
id: string;
|
||||
label: string;
|
||||
anchors: readonly string[];
|
||||
}
|
||||
|
||||
export const PAGES: Partial<Record<Section, readonly SettingPage[]>> = {
|
||||
tools: [
|
||||
{ id: "approval", label: "批准与规则", anchors: ["approval", "plan-approval", "external-origin", "rules", "tool-list", "present-files"] },
|
||||
{ id: "sandbox", label: "沙箱与执行", anchors: ["sandbox", "shell"] },
|
||||
{ id: "web", label: "联网与浏览器", anchors: ["web-access", "browser-tools"] },
|
||||
{ id: "subagents", label: "子代理与协作", anchors: ["subagent-claims", "subagent-steer", "write-lease"] },
|
||||
],
|
||||
appearance: [
|
||||
{ id: "look", label: "外观与主题", anchors: ["mode", "scheme", "font", "size", "weight", "contrast", "effects", "wallpaper", "language", "advanced-appearance", "graphics"] },
|
||||
{ id: "navigation", label: "导航与侧栏", anchors: ["navrail", "naventries", "session-quick-actions", "legacy-sessions", "live-grace", "open-session-limit", "live-stream-budget", "session-tabs"] },
|
||||
{ id: "conversation", label: "会话显示", anchors: ["new-answer-marker", "folding"] },
|
||||
{ id: "shortcuts", label: "快捷键", anchors: ["send-shortcut", "global-shortcut", "keys", "esc-stop"] },
|
||||
{ id: "window", label: "窗口与性能", anchors: ["window", "notify", "page-sleep"] },
|
||||
],
|
||||
};
|
||||
|
||||
export const pagesOf = (section: Section): readonly SettingPage[] => PAGES[section] ?? [];
|
||||
|
||||
export const firstPage = (section: Section): string | undefined => PAGES[section]?.[0]?.id;
|
||||
|
||||
export const pageOf = (section: Section, anchor: string): string | undefined =>
|
||||
PAGES[section]?.find((p) => p.anchors.includes(anchor))?.id;
|
||||
|
||||
export const hiddenAnchors = (section: Section, page: string): string[] => {
|
||||
const pages = PAGES[section];
|
||||
if (!pages) return [];
|
||||
return pages.filter((p) => p.id !== page).flatMap((p) => [...p.anchors]);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Settings } from "./Settings";
|
||||
@@ -42,7 +42,7 @@ it("reads machine totals locally but keeps project inventory on the active remot
|
||||
const localSkills = vi.spyOn(local, "skills");
|
||||
render(settings(remote, local, "ext"));
|
||||
await screen.findByRole("heading", { name: "扩展" });
|
||||
expect(storage).toHaveBeenCalled();
|
||||
await waitFor(() => expect(storage).toHaveBeenCalled(), { timeout: 4000 });
|
||||
expect(usage).toHaveBeenCalled();
|
||||
expect(remoteStorage).not.toHaveBeenCalled();
|
||||
expect(remoteUsage).not.toHaveBeenCalled();
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Settings } from "./Settings";
|
||||
import type { SessionStatus } from "../port/port";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function draw(at?: string) {
|
||||
render(
|
||||
<Settings
|
||||
hub={new MockHub() as never}
|
||||
port={new MockPort()}
|
||||
at={at}
|
||||
status={{ preset: "balanced", toolApprovalMode: "ask" } as SessionStatus}
|
||||
theme="light" onTheme={() => {}} contrast="" onContrast={() => {}} weight="" onWeight={() => {}}
|
||||
look={{} as never} onLook={() => {}} reloadThemes={() => {}}
|
||||
onClose={vi.fn()} onChanged={() => {}} onError={() => {}}
|
||||
account={null} accountUnread="" reloadAccount={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const shown = (id: string) => {
|
||||
const el = document.querySelector<HTMLElement>(`[data-setting="${id}"]`);
|
||||
return !!el && getComputedStyle(el).display !== "none";
|
||||
};
|
||||
|
||||
describe("pages inside the tools section", () => {
|
||||
it("opens on its first page and shows only what is filed there", async () => {
|
||||
draw("tools");
|
||||
await screen.findByRole("tab", { name: /批准与规则/ });
|
||||
expect(shown("approval")).toBe(true);
|
||||
expect(shown("sandbox")).toBe(false);
|
||||
expect(shown("web-access")).toBe(false);
|
||||
});
|
||||
|
||||
it("moves to another page from the navigation and back", async () => {
|
||||
draw("tools");
|
||||
await userEvent.click(await screen.findByRole("tab", { name: /沙箱与执行/ }));
|
||||
await waitFor(() => expect(shown("sandbox")).toBe(true));
|
||||
expect(shown("approval")).toBe(false);
|
||||
await userEvent.click(screen.getByRole("tab", { name: /批准与规则/ }));
|
||||
await waitFor(() => expect(shown("approval")).toBe(true));
|
||||
expect(shown("sandbox")).toBe(false);
|
||||
});
|
||||
|
||||
it("starts a newly chosen page at the top", async () => {
|
||||
draw("tools");
|
||||
await screen.findByRole("tab", { name: /沙箱与执行/ });
|
||||
screen.getByRole("tabpanel").scrollTop = 900;
|
||||
await userEvent.click(screen.getByRole("tab", { name: /沙箱与执行/ }));
|
||||
await waitFor(() => expect(shown("sandbox")).toBe(true));
|
||||
expect(screen.getByRole("tabpanel").scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("lands on the page a deep link names", async () => {
|
||||
draw("tools:sandbox");
|
||||
await waitFor(() => expect(shown("sandbox")).toBe(true));
|
||||
expect(shown("approval")).toBe(false);
|
||||
expect(screen.getByRole("tab", { name: /沙箱与执行/ }).getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("lands on the page a search result is on", async () => {
|
||||
draw("tools");
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "搜索设置" }), "内置浏览器");
|
||||
await userEvent.click(await screen.findByRole("option", { name: /内置浏览器/ }));
|
||||
await waitFor(() => expect(shown("browser-tools")).toBe(true));
|
||||
expect(shown("approval")).toBe(false);
|
||||
});
|
||||
|
||||
it("gives a short section no pages", async () => {
|
||||
draw("session");
|
||||
await waitFor(() => expect(shown("preset")).toBe(true));
|
||||
expect(document.querySelectorAll(".prefs-page")).toHaveLength(0);
|
||||
expect(shown("preset")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pages inside the appearance section", () => {
|
||||
it("splits it into pages and keeps each setting on one", async () => {
|
||||
draw("appearance");
|
||||
await screen.findByRole("tab", { name: /导航与侧栏/ });
|
||||
expect(shown("mode")).toBe(true);
|
||||
expect(shown("send-shortcut")).toBe(false);
|
||||
await userEvent.click(screen.getByRole("tab", { name: /快捷键/ }));
|
||||
await waitFor(() => expect(shown("send-shortcut")).toBe(true));
|
||||
expect(shown("mode")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what wraps a setting leaves with it", () => {
|
||||
it("hides the disclosure around a setting together with the setting", async () => {
|
||||
draw("appearance:navrail");
|
||||
await waitFor(() => expect(shown("navrail")).toBe(true));
|
||||
const style = [...document.querySelectorAll("style")].map((s) => s.textContent).join("\n");
|
||||
expect(style).toContain('details:has(> [data-setting="wallpaper"])');
|
||||
expect(style).toContain('details:has(> [data-setting="advanced-appearance"])');
|
||||
});
|
||||
});
|
||||
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user