mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98570c95c0 | ||
|
|
5e222f332e | ||
|
|
1cc6bf327a | ||
|
|
c8fcbc138e | ||
|
|
abd3e3a62e | ||
|
|
80baf7e0c5 | ||
|
|
c1ffa46df2 | ||
|
|
36ecb1868a | ||
|
|
022c4db333 | ||
|
|
91858315ca | ||
|
|
14323ca29d | ||
|
|
6458adf7b6 | ||
|
|
5a16e27b59 | ||
|
|
70955832c3 | ||
|
|
6881c9748d | ||
|
|
d11ca0472c | ||
|
|
57784a251c | ||
|
|
4377886f88 | ||
|
|
28cab7d14b | ||
|
|
bd5ea6ddd3 | ||
|
|
6e2ba1056a | ||
|
|
b458854377 | ||
|
|
b29f8bf16b | ||
|
|
d1f852953a | ||
|
|
6287044a20 | ||
|
|
52e9abe311 | ||
|
|
d0bff6bcf5 | ||
|
|
8c42340ea4 | ||
|
|
f45fb56f62 | ||
|
|
127ee9755b | ||
|
|
0c9d5d1fd1 | ||
|
|
50a5186930 | ||
|
|
d51fb03f6b | ||
|
|
7d65acd714 | ||
|
|
0fd8d526e5 | ||
|
|
751f0b13ca | ||
|
|
7273f04d81 | ||
|
|
def94177cd | ||
|
|
e6337ef356 | ||
|
|
d5e7c53401 | ||
|
|
9609216f74 | ||
|
|
8add01aacc | ||
|
|
bdf3784f96 | ||
|
|
186583996d | ||
|
|
af958fdae8 | ||
|
|
148a3eabe7 | ||
|
|
82997a2d8f | ||
|
|
682ecbe392 | ||
|
|
40f3156907 | ||
|
|
966f6ece9b | ||
|
|
a3473882ce | ||
|
|
9260e62870 | ||
|
|
8b4dea758d | ||
|
|
3858f0d51f | ||
|
|
1d723d2a28 | ||
|
|
a43ba964fd | ||
|
|
f7c2506cf2 | ||
|
|
4db40e881c | ||
|
|
6c4f00ce42 | ||
|
|
a76cbec375 | ||
|
|
3a67b488a2 | ||
|
|
553ed22a50 | ||
|
|
889211c785 | ||
|
|
540c14998b | ||
|
|
9fce63aa5a | ||
|
|
c41306961a | ||
|
|
0865bc1071 | ||
|
|
46d63cf414 | ||
|
|
619c5b243d | ||
|
|
1c330cbd9e | ||
|
|
6343cc7970 | ||
|
|
ef0874a111 | ||
|
|
adcc4b4d80 | ||
|
|
caf5c2d45e | ||
|
|
bb273fec87 | ||
|
|
e1c38d3036 | ||
|
|
463e2f76f0 | ||
|
|
172ae8b80f | ||
|
|
d03be98bcb | ||
|
|
decba9d9eb | ||
|
|
3af39d9d0a | ||
|
|
4775113397 | ||
|
|
21a4569b39 | ||
|
|
479c8e7b16 | ||
|
|
c79c5192ee | ||
|
|
d42cdbb434 | ||
|
|
42c3c9f195 | ||
|
|
526908630a | ||
|
|
7d9e9b7b6c | ||
|
|
2dce532bf5 | ||
|
|
083d30bec8 | ||
|
|
d4d8876e92 | ||
|
|
4af626e714 | ||
|
|
ffe06c2db8 | ||
|
|
5671e00f3f | ||
|
|
c3d192dc4b | ||
|
|
5aa737bb95 | ||
|
|
24a775a94c | ||
|
|
b26eddec18 | ||
|
|
aeeff97700 | ||
|
|
15f72be36f | ||
|
|
8bd18a5db4 | ||
|
|
eab502bd8e | ||
|
|
1aa101e6a9 | ||
|
|
495f8ede4c | ||
|
|
de2256dfc4 | ||
|
|
0e63e353fe | ||
|
|
3fa1ea460c | ||
|
|
bf472862ca | ||
|
|
d28a6089fc | ||
|
|
8c669ebe61 | ||
|
|
df14e2f085 | ||
|
|
600b4b8cb4 | ||
|
|
e01aae97fd | ||
|
|
36cb5e6c7b | ||
|
|
aaaaf4d2ef | ||
|
|
d1fe589bf8 | ||
|
|
89935ce452 | ||
|
|
db7a840a3f | ||
|
|
b242c67680 | ||
|
|
8ebd327b12 | ||
|
|
04bdd5702b | ||
|
|
811e335a89 | ||
|
|
7a530b37ee | ||
|
|
ee9f75e286 | ||
|
|
e29812d38f | ||
|
|
e4779b9f95 | ||
|
|
772b2f1de6 | ||
|
|
749e691863 | ||
|
|
e59262c519 | ||
|
|
35003e6628 | ||
|
|
cbac083536 | ||
|
|
cf5fedae64 | ||
|
|
d816d53347 | ||
|
|
5b2b297e6e | ||
|
|
ab68ff145f | ||
|
|
ffccf6877c | ||
|
|
b786e30b7a | ||
|
|
72b725862f | ||
|
|
bc330925c0 | ||
|
|
affb71f9dd | ||
|
|
7639c30135 | ||
|
|
db465ec26f |
No files matched your search
@@ -198,7 +198,8 @@ jobs:
|
||||
|
||||
# Pull requests run a Windows smoke suite: the packages that actually
|
||||
# carry *_windows.go code, the startup/checkpoint packages with portable
|
||||
# filesystem contracts, plus cmd/. The full ./... sweep stays on pushes
|
||||
# filesystem contracts, plus cmd/. Isolation/worktree/mutation have a
|
||||
# complete JSON inventory below. The full ./... sweep stays on pushes
|
||||
# to main-v2; the Unix legs always run the full suite. Keep the job-level
|
||||
# budget above normal 7-9 minute runner variance; each package test binary
|
||||
# remains independently bounded by Go's per-package timeout below.
|
||||
@@ -222,11 +223,76 @@ jobs:
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -count=1 -p 4 -timeout=18m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
|
||||
|
||||
# Worktree rollback is absent from the broad Windows PR smoke list.
|
||||
- name: test (Windows worktree rollback)
|
||||
- name: test (Windows isolation owners)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 3
|
||||
run: go test -count=1 -timeout=2m -run '^TestDiscardUnpublished.*OnWindows$|^TestCreateFromDetachedHEADAndOccupiedBranch$' ./internal/platform/worktree
|
||||
timeout-minutes: 25
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -p 4 -timeout=18m -json ./internal/runtime/isolation/... ./internal/platform/worktree/... ./internal/state/mutation/... | tee "$RUNNER_TEMP/isolation-owners.jsonl" |
|
||||
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $passed |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
(["reasonix/internal/runtime/isolation", "reasonix/internal/platform/worktree", "reasonix/internal/state/mutation"] - $passed | length) == 0 and
|
||||
(["TestResultOperationUsesCrossProcessOwnership",
|
||||
"TestActualResultOperationHoldsOwnershipThroughWriteAndCleanup/restore",
|
||||
"TestActualResultOperationHoldsOwnershipThroughWriteAndCleanup/remove",
|
||||
"TestApplyPathsWithUntrustedModesPreservesTheIndex/content",
|
||||
"TestApplyPathsWithUntrustedModesPreservesTheIndex/source_index_mode",
|
||||
"TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_mode",
|
||||
"TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_type",
|
||||
"TestApplyPathsEmulatedLinksUseSnapshotFilters",
|
||||
"TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings",
|
||||
"TestLegacyWaitDoesNotConsumeExecutionCapacity"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/isolation-owners.jsonl"
|
||||
|
||||
- name: upload Windows isolation test inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: isolation-owners-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/isolation-owners.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows background resource owners)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 25
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -p 2 -timeout=18m -json ./internal/tools/jobs/... ./internal/runtime/delegation/... | tee "$RUNNER_TEMP/background-resources.jsonl" |
|
||||
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
(["reasonix/internal/tools/jobs", "reasonix/internal/runtime/delegation"] - $packages | length) == 0 and
|
||||
(["TestJobFinalizationCoversInvokedAndUninvoked/success",
|
||||
"TestJobFinalizationCoversInvokedAndUninvoked/error",
|
||||
"TestJobFinalizationCoversInvokedAndUninvoked/panic",
|
||||
"TestJobFinalizationCoversInvokedAndUninvoked/closed",
|
||||
"TestJobFinalizationCoversInvokedAndUninvoked/invalid_session",
|
||||
"TestJobFinalizationCoversInvokedAndUninvoked/invalid_kind",
|
||||
"TestJobFinalizationFailuresAttemptAllCallbacksAndKeepCauses/invoked",
|
||||
"TestJobFinalizationFailuresAttemptAllCallbacksAndKeepCauses/uninvoked",
|
||||
"TestJobFinalizationPrecedesCompletionPublication",
|
||||
"TestJobFinalizationCloseBetweenPublicationAndLaunch",
|
||||
"TestJobFinalizationWaitsForClosureAfterLogicalCancellation",
|
||||
"TestBackgroundNoLaunchFinalizesOwnedResources/task/closed_manager",
|
||||
"TestBackgroundNoLaunchFinalizesOwnedResources/task/invalid_session",
|
||||
"TestBackgroundNoLaunchFinalizesOwnedResources/fleet/closed_manager",
|
||||
"TestBackgroundNoLaunchFinalizesOwnedResources/fleet/invalid_session"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/background-resources.jsonl"
|
||||
|
||||
- name: upload Windows background resource inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: background-resources-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/background-resources.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
# The general Windows PR smoke list intentionally omits internal/contract/tool.
|
||||
# Keep the session-temp portability contract covered without widening the
|
||||
@@ -315,7 +381,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
run: go test -race ./internal/runtime/agent/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/...
|
||||
run: go test -race ./internal/runtime/agent/... ./internal/runtime/delegation/... ./internal/runtime/isolation/... ./internal/platform/worktree/... ./internal/state/mutation/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/...
|
||||
|
||||
- name: test -race (full)
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
@@ -43,20 +43,20 @@ jobs:
|
||||
if: matrix.branch != ''
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
|
||||
- name: Perform CodeQL Analysis
|
||||
if: matrix.branch == ''
|
||||
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
# Results from a checked-out line are filed under that line's ref, not the
|
||||
# default branch the schedule happened to run from.
|
||||
- name: Perform CodeQL Analysis (${{ matrix.branch }})
|
||||
if: matrix.branch != ''
|
||||
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
ref: refs/heads/${{ matrix.branch }}
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
||||
@@ -159,9 +159,10 @@ jobs:
|
||||
node perf/composer-toolbar-row.mjs
|
||||
node perf/action-icons.mjs
|
||||
node perf/rowhit.mjs
|
||||
node perf/flowgrip-quiet.mjs
|
||||
node perf/flow-narrow.mjs
|
||||
node perf/prefs-wide-centered.mjs
|
||||
node perf/tab-pip.mjs
|
||||
node perf/syntax-tokens.mjs
|
||||
node perf/tab-close-inside.mjs
|
||||
node perf/context-card-hover.mjs
|
||||
node perf/sessquick.mjs
|
||||
node perf/sessionrows.mjs
|
||||
|
||||
@@ -32,15 +32,15 @@
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.14",
|
||||
"@lezer/highlight": "^1.2.5",
|
||||
"@mantine/charts": "9.7.2",
|
||||
"@mantine/core": "9.7.2",
|
||||
"@mantine/hooks": "9.7.2",
|
||||
"@mantine/charts": "9.7.3",
|
||||
"@mantine/core": "9.7.3",
|
||||
"@mantine/hooks": "9.7.3",
|
||||
"@noble/curves": "2.4.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"dompurify": "^3.4.17",
|
||||
"katex": "^0.19.0",
|
||||
"lucide-react": "^1.60.0",
|
||||
"maplibre-gl": "6.14.0",
|
||||
"lucide-react": "^1.61.0",
|
||||
"maplibre-gl": "6.15.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"papaparse": "^5.7.0",
|
||||
@@ -68,7 +68,7 @@
|
||||
"jsdom": "^30.1.3",
|
||||
"playwright": "^1.64.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.4",
|
||||
"vitest": "^5.0.4"
|
||||
"vite": "^8.3.5",
|
||||
"vitest": "^5.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail) => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}: ${JSON.stringify(detail)}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
const textKeys = ["title", "count", "name", "arg", "normalization", "originalInput"];
|
||||
const weightKeys = textKeys.filter(key => key !== "title" && key !== "count");
|
||||
const browser = await chromium.launch();
|
||||
for (const lang of ["zh", "en"]) {
|
||||
for (const fold of [false, true]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push(e.message));
|
||||
await page.goto(`${BASE}?pref=${lang}${fold ? "&fold" : ""}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(async () => {
|
||||
const frame = () => new Promise((r) => requestAnimationFrame(r));
|
||||
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "Inspect the file" });
|
||||
window.__feed({ kind: "text", text: "I will inspect it first." });
|
||||
window.__feed({ kind: "message", text: "I will inspect it first." });
|
||||
const tool = {
|
||||
id: "typography-read", name: "read_file", args: '{"path":"src/main.go"}',
|
||||
argumentNormalization: { reason: "json_string_unwrapped", original: '"{\\"path\\":\\"src/main.go\\"}"', effective: '{"path":"src/main.go"}' },
|
||||
};
|
||||
window.__feed({ kind: "tool_dispatch", tool });
|
||||
window.__feed({ kind: "tool_result", tool: { ...tool, output: "package main", durationMs: 120 } });
|
||||
await frame();
|
||||
window.__feed({ kind: "text", text: "The file declares package main." });
|
||||
window.__feed({ kind: "message", text: "The file declares package main." });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
});
|
||||
await page.waitForSelector(".activity-title");
|
||||
while (await page.locator(".activity-group:not([open]) > summary").count()) {
|
||||
await page.locator(".activity-group:not([open]) > summary").first().click();
|
||||
}
|
||||
await page.waitForSelector(".activity-body .hl .nm", { state: "visible" });
|
||||
await page.locator(".activity-body .tool-disclosure > summary .nm").first().click();
|
||||
await page.waitForSelector(".activity-call-input > details > summary", { state: "visible" });
|
||||
check(`${lang}/${fold}: original input starts folded`, await page.locator(".activity-call-input > details").evaluate((el) => !el.open));
|
||||
await page.locator(".activity-call-input > details > summary").click();
|
||||
check(`${lang}/${fold}: original input opens with its source`, await page.locator(".activity-call-input > details pre").textContent() === '"{\\"path\\":\\"src/main.go\\"}"');
|
||||
const measure = async () => {
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
return page.evaluate(() => {
|
||||
const style = (selector) => {
|
||||
const s = getComputedStyle(document.querySelector(selector));
|
||||
return { size: parseFloat(s.fontSize), weight: Number(s.fontWeight), family: s.fontFamily };
|
||||
};
|
||||
return {
|
||||
title: style(".activity-title"), count: style(".activity-count"),
|
||||
name: style(".activity-body .hl .nm"), arg: style(".activity-body .hl .arg"),
|
||||
normalization: style(".activity-call-input > p"), originalInput: style(".activity-call-input > details > summary"),
|
||||
sidebar: style(".sesstitle"), answer: style('.call[data-k="say"] .out .txt'),
|
||||
};
|
||||
});
|
||||
};
|
||||
const base = await measure();
|
||||
check(`${lang}/${fold}: activity labels preserve the 11px/500 default`, base.title.size === 11 && base.count.size === 11 && base.title.weight === 500 && base.count.weight === 500, { title: base.title, count: base.count });
|
||||
const pick = async (action, index) => {
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]').click());
|
||||
await page.waitForSelector(".prefs-nav button");
|
||||
await page.evaluate(() => document.getElementById("prefs-appearance").click());
|
||||
const variable = action === "appearance.reading-size" ? "--read" : "--fs-scale";
|
||||
const expected = action === "appearance.reading-size"
|
||||
? `${(lang === "zh" ? [12, 13, 15, 17] : [11.5, 12.5, 14.5, 16.5])[index]}px` : "1.3";
|
||||
await page.locator(`[data-action="${action}"]`).nth(index).click();
|
||||
await page.waitForFunction(([name, value]) => document.documentElement.style.getPropertyValue(name) === value, [variable, expected]);
|
||||
await page.keyboard.press("Escape");
|
||||
};
|
||||
await pick("appearance.reading-size", 3);
|
||||
const large = await measure();
|
||||
const ratio = large.answer.size / base.answer.size;
|
||||
const tag = `${lang}/${fold ? "folded turn" : "activity"}`;
|
||||
for (const key of textKeys) {
|
||||
check(`${tag} ${key} follows body size`, Math.abs(large[key].size / base[key].size - ratio) < .02, { before: base[key].size, after: large[key].size, ratio });
|
||||
}
|
||||
check(`${tag} body size leaves sidebar alone`, large.sidebar.size === base.sidebar.size, large.sidebar);
|
||||
await pick("appearance.text-size", 3);
|
||||
const uiLarge = await measure();
|
||||
for (const key of textKeys) {
|
||||
const expectedRatio = key === "title" || key === "count" ? 1.3 : 1;
|
||||
check(`${tag} ${key} ${expectedRatio === 1 ? "independent of" : "scales with"} interface size`,
|
||||
Math.abs(uiLarge[key].size / large[key].size - expectedRatio) < .02, uiLarge[key]);
|
||||
}
|
||||
check(`${tag} interface size still enlarges sidebar`, uiLarge.sidebar.size > large.sidebar.size, uiLarge.sidebar);
|
||||
await page.evaluate(() => { document.documentElement.dataset.weight = "light"; });
|
||||
const light = await measure();
|
||||
await page.evaluate(() => { document.documentElement.dataset.weight = "heavy"; });
|
||||
const heavy = await measure();
|
||||
for (const key of weightKeys) {
|
||||
check(`${tag} ${key} follows weight preference`, heavy[key].weight > light[key].weight, { light: light[key].weight, heavy: heavy[key].weight });
|
||||
}
|
||||
check(`${tag} activity labels retain one 500 weight`, heavy.title.weight === 500 && heavy.count.weight === 500, { title: heavy.title, count: heavy.count });
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty("--ui", "Georgia, serif");
|
||||
document.documentElement.style.setProperty("--mono", "Menlo, monospace");
|
||||
});
|
||||
const fonts = await measure();
|
||||
check(`${tag} labels use prose family; commands use mono`, fonts.title.family.startsWith("Georgia") && fonts.name.family.startsWith("Menlo") && fonts.arg.family.startsWith("Menlo"), fonts);
|
||||
await page.evaluate(() => {
|
||||
window.__feed({ kind: "turn_started", authoredTurn: 2, msgIndex: 1, text: "Inspect failure and running labels" });
|
||||
const failed = { id: "scale-failure", name: "bash", args: '{"command":"false"}' };
|
||||
window.__feed({ kind: "tool_dispatch", tool: failed });
|
||||
window.__feed({ kind: "tool_result", tool: { ...failed, err: "exit status 1", output: "failed", durationMs: 120 } });
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id: "scale-running", name: "bash", args: '{"command":"sleep 10"}' } });
|
||||
});
|
||||
await page.waitForSelector(".activity-running");
|
||||
for (const step of [0, 1, 2, 3]) {
|
||||
await pick("appearance.reading-size", step);
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
const labels = await page.locator(".activity-group[data-running] > summary").evaluate(el =>
|
||||
[...el.querySelectorAll(".activity-title, .activity-running, .activity-errors, .activity-count")].map(span => {
|
||||
const css = getComputedStyle(span);
|
||||
return { label: span.className, size: parseFloat(css.fontSize), weight: Number(css.fontWeight), lineHeight: css.lineHeight };
|
||||
}));
|
||||
const read = (lang === "zh" ? [12, 13, 15, 17] : [11.5, 12.5, 14.5, 16.5])[step];
|
||||
const expected = 11 * 1.3 * read / (lang === "zh" ? 13 : 12.5);
|
||||
check(`${tag} body step ${step}: title/running/failed/count compose text and reading scales at weight 500`,
|
||||
labels.length === 4 && labels.every(label => Math.abs(label.size - expected) < .01 && label.weight === 500 && label.lineHeight === labels[0].lineHeight), labels);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) throw new Error(`${fails.length} checks failed: ${fails.join(", ")}`);
|
||||
@@ -1,37 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=0";
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 860 } });
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto(base, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".flowgrip-l");
|
||||
const width = () => page.locator(".compose").evaluate((el) => Math.round(el.getBoundingClientRect().width));
|
||||
const start = await width();
|
||||
const drag = async (dx) => {
|
||||
const box = await page.locator(".flowgrip-l").boundingBox();
|
||||
await page.mouse.move(box.x + 3, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 3 + dx, box.y + box.height / 2, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
return width();
|
||||
};
|
||||
const mid = await drag(40);
|
||||
assert.ok(mid < start && mid >= 640, `a drag inward goes under the readable measure (${start} -> ${mid})`);
|
||||
const floor = await drag(400);
|
||||
assert.equal(floor, 640, `the column stops at 640 (${floor})`);
|
||||
const slider = page.locator(".flowgrip-l");
|
||||
assert.equal(await slider.getAttribute("aria-valuemin"), "640");
|
||||
await slider.focus();
|
||||
await page.keyboard.press("Home");
|
||||
await page.waitForTimeout(200);
|
||||
assert.equal(await width(), start, "Home returns to the readable measure");
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(`PASS the column is continuous from 640 up: ${start} -> ${mid} -> ${floor} -> ${start}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=0";
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 860 }, colorScheme: scheme });
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto(base, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".flowgrip-l");
|
||||
const paint = (selector) => page.locator(selector).evaluate((el) => getComputedStyle(el, "::before").backgroundColor);
|
||||
const alpha = (css) => (css.startsWith("rgba") ? Number(css.split(",")[3].replace(")", "")) : css === "transparent" ? 0 : 1);
|
||||
await page.mouse.move(800, 400);
|
||||
for (const side of ["l", "r"]) {
|
||||
assert.equal(alpha(await paint(`.flowgrip-${side}`)), 0, `${scheme}: the ${side} grip draws nothing at rest`);
|
||||
}
|
||||
const box = await page.locator(".flowgrip-l").boundingBox();
|
||||
await page.mouse.move(box.x + 3, box.y + box.height / 2);
|
||||
await page.waitForTimeout(400);
|
||||
assert.ok(alpha(await paint(".flowgrip-l")) > 0, `${scheme}: the grip appears under the pointer`);
|
||||
const before = await page.evaluate(() => Math.round(document.querySelector(".compose").getBoundingClientRect().width));
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x - 60, box.y + box.height / 2, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
const after = await page.evaluate(() => Math.round(document.querySelector(".compose").getBoundingClientRect().width));
|
||||
assert.ok(after > before, `${scheme}: a hidden grip still resizes the column (${before} -> ${after})`);
|
||||
await page.mouse.move(800, 400);
|
||||
await page.waitForTimeout(400);
|
||||
assert.equal(alpha(await paint(".flowgrip-l")), 0, `${scheme}: it hides again once the pointer leaves`);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(`PASS ${scheme}: grips are invisible at rest, appear under the pointer, and still resize`);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import { createRoot } from "react-dom/client";
|
||||
import { boot as bootLang } from "../src/i18n";
|
||||
import { track as trackWidth } from "../src/ui/viewport";
|
||||
import { pushToast } from "../src/state/toasts";
|
||||
import { apply as applyPack } from "../src/ui/theme";
|
||||
import { mount as mountEditor } from "./syntax-mount";
|
||||
import "../src/styles/tokens.css";
|
||||
import "../src/styles/app.css";
|
||||
import "../src/styles/dock.css";
|
||||
import "../src/styles/studio.css";
|
||||
import "../src/styles/flowgrip.css";
|
||||
import "../src/styles/changedpill.css";
|
||||
import "../src/styles/askskip.css";
|
||||
import "../src/styles/pastekeep.css";
|
||||
@@ -607,9 +608,11 @@ declare global {
|
||||
__refuse: (method: string, message: string) => void;
|
||||
// Put a notice in the window's corner the way a failed setting does.
|
||||
__toast: (text: string) => void;
|
||||
__syntax: { apply: typeof applyPack; mount: typeof mountEditor };
|
||||
}
|
||||
}
|
||||
window.__toast = pushToast;
|
||||
window.__syntax = { apply: applyPack, mount: mountEditor };
|
||||
window.__feed = (ev) => hub.feeds.forEach((p) => p.feed(ev));
|
||||
window.__panes = () => hub.feeds.size;
|
||||
window.__previewFiles = (files) => hub.feeds.forEach((port) => {
|
||||
|
||||
@@ -61,7 +61,7 @@ try {
|
||||
assert.equal(await page.locator(".user-image button").evaluate(el => el === document.activeElement), true);
|
||||
await page.locator(".activity-group > summary").click();
|
||||
const tool = page.locator('[data-call="image-shot"] .tool-disclosure');
|
||||
await tool.locator("summary").click();
|
||||
await tool.locator("summary .nm").click();
|
||||
await page.getByRole("button", { name: "查看图片 工具截图 1", exact: true }).click();
|
||||
assert.equal(await page.getByRole("dialog").locator("img").getAttribute("src"), data);
|
||||
await page.getByRole("button", { name: "关闭图片预览" }).click();
|
||||
|
||||
@@ -65,7 +65,7 @@ const probe = (page) =>
|
||||
};
|
||||
});
|
||||
|
||||
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action", ".view-action"];
|
||||
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".view-action"];
|
||||
|
||||
// Opens the fold when there is one, then presses nothing: asks whether each control is on screen with nothing over it.
|
||||
async function reachable(page) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// An enlarged settings window centres its content column between the nav and
|
||||
// the window edge: the room left over is shared, not piled up on the right.
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const width of [1920, 2560]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
|
||||
await page.waitForSelector(".prefs-nav button");
|
||||
await page.getByRole("button", { name: "放大设置窗口" }).click();
|
||||
await page.waitForTimeout(400);
|
||||
const room = await page.evaluate(() => {
|
||||
const main = document.querySelector(".prefs-main").getBoundingClientRect();
|
||||
const col = document.querySelector(".prefs-main .prefs-col").getBoundingClientRect();
|
||||
return { left: Math.round(col.left - main.left), right: Math.round(main.right - col.right), width: Math.round(col.width) };
|
||||
});
|
||||
assert.ok(room.width <= 1200, `the column keeps its cap (${room.width})`);
|
||||
assert.ok(Math.abs(room.left - room.right) <= 2, `${width}px: the room is shared evenly (left ${room.left}, right ${room.right})`);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(`PASS ${width}px: column ${room.width}px with ${room.left}px each side`);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import CodeEditor from "../src/ui/CodeEditor";
|
||||
|
||||
// A real editor on a given text, mounted into a given element, for the guard
|
||||
// that reads what a pack's syntax colours do to it.
|
||||
export function mount(host: HTMLElement, path: string, value: string, dark: boolean) {
|
||||
createRoot(host).render(<CodeEditor path={path} value={value} dark={dark} onChange={() => {}} onSave={() => {}} readOnly />);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// A pack's nine syntax colours must reach both places code is drawn — the file
|
||||
// editor and a rendered code block — and a pack that names none must leave both
|
||||
// exactly as they are. Only the browser resolves the variables, so only the
|
||||
// browser can say so.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const NAMES = ["keyword", "string", "number", "function", "comment", "type", "operator", "punctuation", "variable"];
|
||||
const TOKEN = { keyword: "synKeyword", string: "synString", number: "synNumber", function: "synFunction", comment: "synComment", type: "synType", operator: "synOperator", punctuation: "synPunctuation", variable: "synVariable" };
|
||||
const HEX = ["#C01010", "#10A010", "#1010C0", "#C0A010", "#10A0A0", "#A010A0", "#808010", "#108060", "#6040C0"];
|
||||
const SAMPLE = 'class Box {}\n// note\nfunction add(a) { return a + 1 }\nconst s = "x"; run(3);\n';
|
||||
const HLJS = {
|
||||
keyword: "hljs-keyword", string: "hljs-string", number: "hljs-number", function: "hljs-title", comment: "hljs-comment",
|
||||
type: "hljs-type", operator: "hljs-operator", punctuation: "hljs-punctuation", variable: "hljs-variable",
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 800 }, colorScheme: scheme });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
const out = await page.evaluate(async ({ scheme, sample, hljs, names, token, hex }) => {
|
||||
const theme = window.__syntax;
|
||||
const { mount } = theme;
|
||||
const host = document.createElement("div");
|
||||
host.style.cssText = "position:fixed;left:0;top:0;width:700px;height:260px;z-index:2147483647";
|
||||
document.body.appendChild(host);
|
||||
mount(host, "probe.js", sample, scheme === "dark");
|
||||
const block = document.createElement("div");
|
||||
block.className = "md";
|
||||
block.style.cssText = "position:fixed;left:0;top:300px;width:700px;z-index:2147483647";
|
||||
block.innerHTML = `<pre class="term"><code>${names.map((n) => `<span class="${hljs[n]}" data-k="${n}">${n}</span>`).join(" ")}</code></pre>`;
|
||||
document.body.appendChild(block);
|
||||
for (let i = 0; i < 100 && !host.querySelector(".cm-line span"); i++) await new Promise((r) => setTimeout(r, 100));
|
||||
const rgb = (el) => getComputedStyle(el).color;
|
||||
const spanOf = (text, nth = 0) => [...host.querySelectorAll(".cm-line span")].filter((s) => s.textContent === text)[nth];
|
||||
const probe = {
|
||||
keyword: spanOf("class"), string: spanOf('"x"'), number: spanOf("3"), function: spanOf("run"), comment: spanOf("// note"),
|
||||
type: spanOf("Box"), operator: spanOf("+"), punctuation: spanOf("{"), variable: spanOf("a", 1) ?? spanOf("a"),
|
||||
};
|
||||
const read = () => ({
|
||||
editor: Object.fromEntries(names.map((n) => [n, probe[n] ? rgb(probe[n]) : null])),
|
||||
block: Object.fromEntries(names.map((n) => [n, rgb(block.querySelector(`[data-k="${n}"]`))])),
|
||||
inherited: rgb(block.querySelector("code")),
|
||||
});
|
||||
const tokens = (all) => Object.fromEntries(names.filter((n) => all || ["keyword", "string", "number", "function"].includes(n)).map((n) => [token[n], hex[names.indexOf(n)]]));
|
||||
const base = { bg: "#FFFFFF", bgSoft: "#F4F4F4", fg: "#1A1A1A", fgDim: "#5A5A5A", fgFaint: "#767676", accent: "#0066CC" };
|
||||
const pack = (all) => ({ id: "syn", name: "Syn", tokens: { light: { ...base, ...tokens(all) }, dark: { ...base, ...tokens(all) } } });
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
const answer = {};
|
||||
theme.apply(null, scheme);
|
||||
answer.plain = read();
|
||||
answer.defaults = { faint: "", text: "", muted: "", fn: "" };
|
||||
const swatch = document.createElement("i");
|
||||
document.body.appendChild(swatch);
|
||||
for (const [k, v] of [["faint", "--faint"], ["text", "--text"], ["muted", "--muted"], ["fn", "--syn-fn"]]) { swatch.style.color = `var(${v})`; answer.defaults[k] = getComputedStyle(swatch).color; }
|
||||
theme.apply(pack(false), scheme);
|
||||
answer.four = read();
|
||||
answer.fourDefaults = {};
|
||||
swatch.style.color = "var(--syn-fn)"; answer.fourDefaults.fn = getComputedStyle(swatch).color;
|
||||
swatch.style.color = "var(--text)"; answer.fourDefaults.text = getComputedStyle(swatch).color;
|
||||
swatch.style.color = "var(--muted)"; answer.fourDefaults.muted = getComputedStyle(swatch).color;
|
||||
swatch.style.color = "var(--faint)"; answer.fourDefaults.faint = getComputedStyle(swatch).color;
|
||||
theme.apply(pack(true), scheme);
|
||||
answer.nine = read();
|
||||
theme.apply(null, scheme);
|
||||
void root;
|
||||
return answer;
|
||||
}, { scheme, sample: SAMPLE, hljs: HLJS, names: NAMES, token: TOKEN, hex: HEX });
|
||||
|
||||
const asRgb = (h) => { const n = parseInt(h.slice(1), 16); return `rgb(${n >> 16}, ${(n >> 8) & 255}, ${n & 255})`; };
|
||||
for (const surface of ["editor", "block"]) {
|
||||
const seen = NAMES.map((n) => out.nine[surface][n]);
|
||||
check(`${scheme} ${surface}: nine syntax tokens give nine distinct colours`, new Set(seen).size === 9, seen.join(" "));
|
||||
for (const n of NAMES) check(`${scheme} ${surface}: ${n} takes the pack's colour`, out.nine[surface][n] === asRgb(HEX[NAMES.indexOf(n)]), `${out.nine[surface][n]} wanted ${asRgb(HEX[NAMES.indexOf(n)])}`);
|
||||
}
|
||||
// A pack that names only the four old tokens leaves each new one on what that surface drew before.
|
||||
const d = out.fourDefaults;
|
||||
check(`${scheme} editor: comment keeps the faint ink`, out.four.editor.comment === d.faint);
|
||||
check(`${scheme} editor: type keeps the function colour`, out.four.editor.type === d.fn);
|
||||
for (const n of ["operator", "punctuation", "variable"]) check(`${scheme} editor: ${n} keeps the text ink`, out.four.editor[n] === d.text, `${out.four.editor[n]} vs ${d.text}`);
|
||||
check(`${scheme} block: comment keeps the faint ink`, out.four.block.comment === d.faint);
|
||||
check(`${scheme} block: type keeps the function colour`, out.four.block.type === d.fn);
|
||||
for (const n of ["operator", "punctuation"]) check(`${scheme} block: ${n} keeps the muted ink`, out.four.block[n] === d.muted, `${out.four.block[n]} vs ${d.muted}`);
|
||||
check(`${scheme} block: a variable keeps the ink it inherits`, out.four.block.variable === out.four.inherited, `${out.four.block.variable} vs ${out.four.inherited}`);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -0,0 +1,59 @@
|
||||
// A pane tab's close button has to stay inside its own tab whatever the tab
|
||||
// carries: past the tab's edge it sits on the next tab, and a press closes
|
||||
// nothing. The folder and host labels share the row with the title, so a long
|
||||
// one is what pushes it out. Only the browser lays this out.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const LONG = "一个很长很长很长很长很长很长很长很长很长的会话标题";
|
||||
const cases = [
|
||||
["a long title", { title: LONG }],
|
||||
["a long title in a long folder", { title: LONG, ws: "streamhub-02-pipelines-smc-workspace" }],
|
||||
["a long folder and a long host", { title: LONG, ws: "streamhub-02-pipelines-smc-workspace", host: "build-runner-eu-west-long-hostname" }],
|
||||
];
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 760 } });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
for (const [label, tab] of cases) {
|
||||
const got = await page.evaluate(({ title, ws, host }) => {
|
||||
document.querySelector(".probe-tabs")?.remove();
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "panetabs probe-tabs";
|
||||
bar.style.cssText = "position:fixed;left:0;top:0;right:0;z-index:2147483647";
|
||||
const one = (t) => `<div class="ptab" aria-selected="${t === title}"><i class="pip"></i><span class="ptab-nm">${t}</span>`
|
||||
+ (ws ? `<span class="ptab-ws">${ws}</span>` : "") + (host ? `<span class="ptab-host">${host}</span>` : "")
|
||||
+ `<button class="ptab-x">×</button></div>`;
|
||||
bar.innerHTML = one(title) + one("hi");
|
||||
document.body.appendChild(bar);
|
||||
const first = bar.querySelector(".ptab");
|
||||
const box = first.getBoundingClientRect();
|
||||
const x = first.querySelector(".ptab-x").getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(x.left + x.width / 2, x.top + x.height / 2);
|
||||
return {
|
||||
inside: x.left >= box.left - 0.5 && x.right <= box.right + 0.5,
|
||||
reached: hit === first.querySelector(".ptab-x"),
|
||||
title: Math.round(first.querySelector(".ptab-nm").getBoundingClientRect().width),
|
||||
detail: `tab ${Math.round(box.left)}–${Math.round(box.right)}, close ${Math.round(x.left)}–${Math.round(x.right)}`,
|
||||
};
|
||||
}, tab);
|
||||
check(`${label}: the close button stays inside its tab`, got.inside, got.detail);
|
||||
check(`${label}: a press on the close button reaches it`, got.reached);
|
||||
check(`${label}: the title keeps room to be read`, got.title >= 48, `${got.title}px`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
Generated
+138
-138
@@ -69,14 +69,14 @@ importers:
|
||||
specifier: ^1.2.5
|
||||
version: 1.2.5
|
||||
'@mantine/charts':
|
||||
specifier: 9.7.2
|
||||
version: 9.7.2(@mantine/[email protected].2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].2([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))
|
||||
specifier: 9.7.3
|
||||
version: 9.7.3(@mantine/[email protected].3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].3([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))
|
||||
'@mantine/core':
|
||||
specifier: 9.7.2
|
||||
version: 9.7.2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
specifier: 9.7.3
|
||||
version: 9.7.3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@mantine/hooks':
|
||||
specifier: 9.7.2
|
||||
version: 9.7.2([email protected])
|
||||
specifier: 9.7.3
|
||||
version: 9.7.3([email protected])
|
||||
'@noble/curves':
|
||||
specifier: 2.4.0
|
||||
version: 2.4.0
|
||||
@@ -90,11 +90,11 @@ importers:
|
||||
specifier: ^0.19.0
|
||||
version: 0.19.0
|
||||
lucide-react:
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0(@types/[email protected])([email protected])
|
||||
specifier: ^1.61.0
|
||||
version: 1.61.0(@types/[email protected])([email protected])
|
||||
maplibre-gl:
|
||||
specifier: 6.14.0
|
||||
version: 6.14.0
|
||||
specifier: 6.15.0
|
||||
version: 6.15.0
|
||||
papaparse:
|
||||
specifier: ^5.7.0
|
||||
version: 5.7.0
|
||||
@@ -109,7 +109,7 @@ importers:
|
||||
version: 19.3.1([email protected])
|
||||
react-map-gl:
|
||||
specifier: 8.1.3
|
||||
version: 8.1.3([email protected]4.0)([email protected]([email protected]))([email protected])
|
||||
version: 8.1.3([email protected]5.0)([email protected]([email protected]))([email protected])
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/[email protected])([email protected])
|
||||
@@ -161,7 +161,7 @@ importers:
|
||||
version: 19.3.0(@types/[email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.1.2
|
||||
version: 6.1.2([email protected].4(@types/[email protected])([email protected]))
|
||||
version: 6.1.2([email protected].5(@types/[email protected])([email protected]))
|
||||
jsdom:
|
||||
specifier: ^30.1.3
|
||||
version: 30.1.3(@noble/[email protected])
|
||||
@@ -172,11 +172,11 @@ importers:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
vite:
|
||||
specifier: ^8.3.4
|
||||
version: 8.3.4(@types/[email protected])([email protected])
|
||||
specifier: ^8.3.5
|
||||
version: 8.3.5(@types/[email protected])([email protected])
|
||||
vitest:
|
||||
specifier: ^5.0.4
|
||||
version: 5.0.4(@types/[email protected])([email protected](@noble/[email protected]))([email protected].4(@types/[email protected])([email protected]))
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.5(@types/[email protected])([email protected](@noble/[email protected]))([email protected].5(@types/[email protected])([email protected]))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -580,24 +580,24 @@ packages:
|
||||
'@lit/[email protected]':
|
||||
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
|
||||
|
||||
'@mantine/[email protected].2':
|
||||
resolution: {integrity: sha512-biwAZ4RDSmhy2dqeyqBK1XlFg3+eZGYyt3bEDuTBu/at/fbAguhIsmFTbqYjqMcTpWNyX9BNxJ+mhMFslee1eQ==}
|
||||
'@mantine/[email protected].3':
|
||||
resolution: {integrity: sha512-p8SMOVEmQATcHIszSYdKdU6MBq8Ozdm+QJeFz+Zrtpquv0+ZVHIb262emZV544a7UmwOGdMN5DxmhQ8lewrbvg==}
|
||||
peerDependencies:
|
||||
'@mantine/core': 9.7.2
|
||||
'@mantine/hooks': 9.7.2
|
||||
'@mantine/core': 9.7.3
|
||||
'@mantine/hooks': 9.7.3
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
recharts: '>=3.2.1'
|
||||
|
||||
'@mantine/[email protected].2':
|
||||
resolution: {integrity: sha512-+cggJDD0PO057SwZU/dKlWPjp0LaAn1nEM9W1m/Ia4Cjb/Hy9DjZ4I7R7xirp/eZ6xnV7OqeGmPFSJ8zeGa5kQ==}
|
||||
'@mantine/[email protected].3':
|
||||
resolution: {integrity: sha512-wYJ9Bd82a7HqwJ+hubFqUIocBGMuw6txzdPnnleHvv1h0NlnkESevK/WHTRh4FtOw33B5N2ADT34s3x1/8d8jg==}
|
||||
peerDependencies:
|
||||
'@mantine/hooks': 9.7.2
|
||||
'@mantine/hooks': 9.7.3
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
|
||||
'@mantine/[email protected].2':
|
||||
resolution: {integrity: sha512-11emsRp/Mg+vfaKVyoMwiMdCHmO/15XOL6JG9fACkclVZnLPBVvRIaDdRBudsR3ALGZ5iTzrArPJDjjoeO6X4w==}
|
||||
'@mantine/[email protected].3':
|
||||
resolution: {integrity: sha512-GMHoMS1qul1dBLIHd/N9ef2Pr8UKYYHfuM+g2ej3gYqU0kH4sTiR9FNHksoTQi0PzATRoaNlUYjjMe9rbDRw5A==}
|
||||
peerDependencies:
|
||||
react: ^19.2.0
|
||||
|
||||
@@ -620,8 +620,8 @@ packages:
|
||||
'@mapbox/[email protected]':
|
||||
resolution: {integrity: sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==}
|
||||
|
||||
'@maplibre/geojson-vt@6.1.2':
|
||||
resolution: {integrity: sha512-2ZYhobdAY+y4/P/z1xe0698165LywD0yDdWugdu3NRUrajgVWut+tLkcz7i/RJguMTURwK7gxwmwYMFJbgbQFw==}
|
||||
'@maplibre/geojson-vt@6.3.0':
|
||||
resolution: {integrity: sha512-3Zw2jsU9pIJG3G2CQFbLjWj2MlAHodhD7cSY+15iywUuLmmkgzcKRnueVRZ4AcC/5wOtfhidcJAAzBpoBNs3Xg==}
|
||||
|
||||
'@maplibre/[email protected]':
|
||||
resolution: {integrity: sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==}
|
||||
@@ -723,8 +723,8 @@ packages:
|
||||
resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@oxc-project/[email protected]2.0':
|
||||
resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==}
|
||||
'@oxc-project/[email protected]5.0':
|
||||
resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==}
|
||||
|
||||
'@preact/[email protected]':
|
||||
resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==}
|
||||
@@ -740,98 +740,98 @@ packages:
|
||||
react-redux:
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -1115,8 +1115,8 @@ packages:
|
||||
oxc-transform-react:
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected].4':
|
||||
resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==}
|
||||
'@vitest/[email protected].5':
|
||||
resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9 || ^3.0.0
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -1126,8 +1126,8 @@ packages:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected].4':
|
||||
resolution: {integrity: sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==}
|
||||
'@vitest/[email protected].5':
|
||||
resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
@@ -1613,8 +1613,8 @@ packages:
|
||||
resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
[email protected]0.0:
|
||||
resolution: {integrity: sha512-UqxOW0FgAfDyb9VmjKb6/BkOzW6yfhf/QNboKtS2NDsscnNRW2CDzlyhbMKBQ1ontmcxuIDqHJ2AVgFR3NI+GA==}
|
||||
[email protected]1.0:
|
||||
resolution: {integrity: sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
@@ -1629,8 +1629,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==}
|
||||
|
||||
[email protected]4.0:
|
||||
resolution: {integrity: sha512-L7U9y9TyPJ3P/JJbW+eH+A3FzdPq+3fnZFqqeCwf/S6cRGganHwVI5b/C8/zOYzreUzsloDPZVVaMQA94wOt4A==}
|
||||
[email protected]5.0:
|
||||
resolution: {integrity: sha512-kxFetbvUHDOVaAP1CYb226Mdk2QhzIPddqM0xJTFD35wu78Mpd6NM7+H68ALjc6Oa0kAOX5EAdMKyvD2u3qfcg==}
|
||||
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
|
||||
|
||||
[email protected]:
|
||||
@@ -2008,8 +2008,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==}
|
||||
|
||||
[email protected]2:
|
||||
resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==}
|
||||
[email protected]5:
|
||||
resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
@@ -2211,13 +2211,13 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
[email protected].4:
|
||||
resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==}
|
||||
[email protected].5:
|
||||
resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
'@vitejs/devtools': ^0.7.1
|
||||
'@vitejs/devtools': ^0.7.1 || ^0.8.0
|
||||
esbuild: ^0.27.0 || ^0.28.0
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
@@ -2254,20 +2254,20 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
[email protected].4:
|
||||
resolution: {integrity: sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==}
|
||||
[email protected].5:
|
||||
resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==}
|
||||
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 5.0.4
|
||||
'@vitest/browser-preview': 5.0.4
|
||||
'@vitest/browser-playwright': 5.0.5
|
||||
'@vitest/browser-preview': 5.0.5
|
||||
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
|
||||
'@vitest/coverage-istanbul': 5.0.4
|
||||
'@vitest/coverage-v8': 5.0.4
|
||||
'@vitest/ui': 5.0.4
|
||||
'@vitest/coverage-istanbul': 5.0.5
|
||||
'@vitest/coverage-v8': 5.0.5
|
||||
'@vitest/ui': 5.0.5
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -2783,18 +2783,18 @@ snapshots:
|
||||
dependencies:
|
||||
'@lit-labs/ssr-dom-shim': 1.6.0
|
||||
|
||||
'@mantine/[email protected].2(@mantine/[email protected].2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].2([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))':
|
||||
'@mantine/[email protected].3(@mantine/[email protected].3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].3([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@mantine/core': 9.7.2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@mantine/hooks': 9.7.2([email protected])
|
||||
'@mantine/core': 9.7.3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@mantine/hooks': 9.7.3([email protected])
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected])
|
||||
recharts: 3.10.1(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
|
||||
|
||||
'@mantine/[email protected].2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
'@mantine/[email protected].3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.27.20([email protected]([email protected]))([email protected])
|
||||
'@mantine/hooks': 9.7.2([email protected])
|
||||
'@mantine/hooks': 9.7.3([email protected])
|
||||
clsx: 2.1.1
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected])
|
||||
@@ -2804,7 +2804,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
'@mantine/[email protected].2([email protected])':
|
||||
'@mantine/[email protected].3([email protected])':
|
||||
dependencies:
|
||||
react: 19.3.1
|
||||
|
||||
@@ -2824,7 +2824,7 @@ snapshots:
|
||||
'@types/geojson': 7946.0.16
|
||||
pbf: 5.1.2
|
||||
|
||||
'@maplibre/geojson-vt@6.1.2':
|
||||
'@maplibre/geojson-vt@6.3.0':
|
||||
dependencies:
|
||||
kdbush: 4.1.0
|
||||
|
||||
@@ -2912,7 +2912,7 @@ snapshots:
|
||||
|
||||
'@noble/[email protected]': {}
|
||||
|
||||
'@oxc-project/[email protected]2.0': {}
|
||||
'@oxc-project/[email protected]5.0': {}
|
||||
|
||||
'@preact/[email protected]': {}
|
||||
|
||||
@@ -2928,49 +2928,49 @@ snapshots:
|
||||
react: 19.3.1
|
||||
react-redux: 9.3.0(@types/[email protected])([email protected])([email protected])
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]': {}
|
||||
@@ -3152,29 +3152,29 @@ snapshots:
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected])
|
||||
|
||||
'@vis.gl/[email protected]([email protected]4.0)([email protected]([email protected]))([email protected])':
|
||||
'@vis.gl/[email protected]([email protected]5.0)([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@maplibre/maplibre-gl-style-spec': 19.3.3
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected])
|
||||
optionalDependencies:
|
||||
maplibre-gl: 6.14.0
|
||||
maplibre-gl: 6.15.0
|
||||
|
||||
'@vitejs/[email protected]([email protected].4(@types/[email protected])([email protected]))':
|
||||
'@vitejs/[email protected]([email protected].5(@types/[email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.3.4(@types/[email protected])([email protected])
|
||||
vite: 8.3.5(@types/[email protected])([email protected])
|
||||
|
||||
'@vitest/[email protected].4([email protected].4(@types/[email protected])([email protected]))':
|
||||
'@vitest/[email protected].5([email protected].5(@types/[email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
'@vitest/spy': 5.0.4
|
||||
'@vitest/spy': 5.0.5
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 1.4.3
|
||||
optionalDependencies:
|
||||
vite: 8.3.4(@types/[email protected])([email protected])
|
||||
vite: 8.3.5(@types/[email protected])([email protected])
|
||||
|
||||
'@vitest/[email protected].4': {}
|
||||
'@vitest/[email protected].5': {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -3682,7 +3682,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]0.0(@types/[email protected])([email protected]):
|
||||
[email protected]1.0(@types/[email protected])([email protected]):
|
||||
dependencies:
|
||||
react: 19.3.1
|
||||
optionalDependencies:
|
||||
@@ -3694,13 +3694,13 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
[email protected]4.0:
|
||||
[email protected]5.0:
|
||||
dependencies:
|
||||
'@mapbox/point-geometry': 1.1.0
|
||||
'@mapbox/tiny-sdf': 2.3.0
|
||||
'@mapbox/unitbezier': 1.0.0
|
||||
'@mapbox/vector-tile': 3.0.0
|
||||
'@maplibre/geojson-vt': 6.1.2
|
||||
'@maplibre/geojson-vt': 6.3.0
|
||||
'@maplibre/maplibre-gl-style-spec': 26.4.4
|
||||
'@maplibre/mlt': 1.3.0
|
||||
'@maplibre/vt-pbf': 4.3.2
|
||||
@@ -4175,14 +4175,14 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]4.0)([email protected]([email protected]))([email protected]):
|
||||
[email protected]([email protected]5.0)([email protected]([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@vis.gl/react-mapbox': 8.1.3([email protected]([email protected]))([email protected])
|
||||
'@vis.gl/react-maplibre': 8.1.3([email protected]4.0)([email protected]([email protected]))([email protected])
|
||||
'@vis.gl/react-maplibre': 8.1.3([email protected]5.0)([email protected]([email protected]))([email protected])
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected])
|
||||
optionalDependencies:
|
||||
maplibre-gl: 6.14.0
|
||||
maplibre-gl: 6.15.0
|
||||
|
||||
[email protected](@types/[email protected])([email protected]):
|
||||
dependencies:
|
||||
@@ -4365,26 +4365,26 @@ snapshots:
|
||||
dependencies:
|
||||
protocol-buffers-schema: 3.6.1
|
||||
|
||||
[email protected]2:
|
||||
[email protected]5:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.152.0
|
||||
'@oxc-project/types': 0.155.0
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.12
|
||||
'@rolldown/binding-android-arm64': 1.2.12
|
||||
'@rolldown/binding-darwin-arm64': 1.2.12
|
||||
'@rolldown/binding-darwin-x64': 1.2.12
|
||||
'@rolldown/binding-freebsd-x64': 1.2.12
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.12
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.12
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.12
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.12
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.12
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.12
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.15
|
||||
'@rolldown/binding-android-arm64': 1.2.15
|
||||
'@rolldown/binding-darwin-arm64': 1.2.15
|
||||
'@rolldown/binding-darwin-x64': 1.2.15
|
||||
'@rolldown/binding-freebsd-x64': 1.2.15
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.15
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.15
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.15
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.15
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.15
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.15
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.15
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.15
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.15
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.15
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -4620,22 +4620,22 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
[email protected].4(@types/[email protected])([email protected]):
|
||||
[email protected].5(@types/[email protected])([email protected]):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
postcss: 8.5.30
|
||||
rolldown: 1.2.12
|
||||
rolldown: 1.2.15
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.5
|
||||
esbuild: 0.28.2
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected].4(@types/[email protected])([email protected](@noble/[email protected]))([email protected].4(@types/[email protected])([email protected])):
|
||||
[email protected].5(@types/[email protected])([email protected](@noble/[email protected]))([email protected].5(@types/[email protected])([email protected])):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.4([email protected].4(@types/[email protected])([email protected]))
|
||||
'@vitest/mocker': 5.0.5([email protected].5(@types/[email protected])([email protected]))
|
||||
chai: 6.3.0
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
@@ -4646,7 +4646,7 @@ snapshots:
|
||||
tinybench: 6.2.2
|
||||
tinyexec: 1.3.1
|
||||
tinyglobby: 0.2.17
|
||||
vite: 8.3.4(@types/[email protected])([email protected])
|
||||
vite: 8.3.5(@types/[email protected])([email protected])
|
||||
why-is-node-running: 3.2.1
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.5
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MARKET_ACTIONS } from "./actions_market";
|
||||
import { SCHEDULE_ACTIONS } from "./actions_schedule";
|
||||
import { DOCUMENT_ACTIONS } from "./actions_document";
|
||||
import { FEEDBACK_ACTIONS } from "./actions_feedback";
|
||||
|
||||
// The census oracle is independent of production literals so disagreements fail.
|
||||
// An id names the intent; data-target and data-value name its entity and choice.
|
||||
@@ -149,6 +150,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "transcript.fold", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold-preset", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold-preview", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.thinking-preview", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.account", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "metrics.details", kind: "view", target: "optional", proof: "interaction" },
|
||||
{ id: "metrics.hide-amounts", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -283,15 +285,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "community.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "sponsor.paypal", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "sponsor.qr", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.tab", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.category", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.body", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.preview", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.name", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.contact", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.capture", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
...FEEDBACK_ACTIONS,
|
||||
{ id: "side.collapse", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "side.toggle", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "side.quote", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -304,42 +298,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "toast.dismiss", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "model.group", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "model.group-clear", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.add", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.remove", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.another", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.thread.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.thread.toggle", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.reply.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.body", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.logs", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-section", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.filter", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link-confirm", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link-cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-join-choice", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-offer-dismiss", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-unlink", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-unlink-other", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.board-join", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-leave", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-release", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-name", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.withdraw-open", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.withdraw-cancel", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.withdraw-confirm", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "paste.save", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.link", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.focus", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.expand", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "backup.category", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "backup.label", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "backup.passphrase", kind: "view", target: "optional", proof: "interaction" },
|
||||
@@ -577,6 +536,10 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "workspace.editor", kind: "repeatable", target: "none", proof: "authority-effect" },
|
||||
{ id: "wallpaper.remove", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "wallpaper.change", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "wallpaper.scene", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "wallpaper.sound", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "wallpaper.volume", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "wallpaper.parallax", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "theme.activate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "theme.import", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "theme.folder", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
@@ -619,7 +582,6 @@ export const ACTIONS: UIAction[] = [
|
||||
// Dragging the column's edge is a view adjustment held on this machine,
|
||||
// not a setting written to the kernel: it changes what is drawn, not what
|
||||
// the transcript says.
|
||||
{ id: "appearance.reading-width", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.background", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.font", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
// Kept where the window keeps them: these are this browser's, and no kernel
|
||||
@@ -790,6 +752,7 @@ export const ACTIONS: UIAction[] = [
|
||||
// ── The two side panels. Reached from the keyboard here, and from each
|
||||
// gutter's own grip, which this pass has not annotated yet.
|
||||
{ id: "rail.star", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "rail.pick", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "rail.toggle", kind: "view", target: "none", proof: "interaction" },
|
||||
...SCHEDULE_ACTIONS,
|
||||
...DOCUMENT_ACTIONS,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { UIAction } from "./actions";
|
||||
|
||||
export const FEEDBACK_ACTIONS: UIAction[] = [
|
||||
{ id: "feedback.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.tab", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.category", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.body", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.preview", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.name", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.contact", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.capture", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.add", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.remove", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.another", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.thread.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.thread.toggle", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.reply.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.body", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.logs", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-section", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.filter", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link-confirm", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link-cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-join-choice", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-offer-dismiss", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-unlink", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-unlink-other", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.board-join", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-leave", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-release", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-name", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.withdraw-open", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.withdraw-cancel", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.withdraw-confirm", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.link", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.focus", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.expand", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.close", kind: "view", target: "none", proof: "interaction" },
|
||||
];
|
||||
@@ -39,6 +39,7 @@ export const EN: Record<string, string> = {
|
||||
"正在读取历史消息…": "Reading history messages…",
|
||||
"历史加载失败 · 重试": "History could not be loaded · Retry",
|
||||
"加载更早的消息": "Load earlier messages",
|
||||
"更早还有 {n} 条消息,点击加载": "{n} more messages before these — click to load",
|
||||
"正在查找较早消息…": "Searching earlier messages…",
|
||||
"部分历史尚未读取": "Some history has not been read",
|
||||
"此检查点不包含文件快照": "This checkpoint contains no file snapshots",
|
||||
@@ -283,7 +284,6 @@ export const EN: Record<string, string> = {
|
||||
"收起度量栏": "Collapse the metrics rail",
|
||||
"展开度量栏": "Show the metrics rail",
|
||||
"调整工作区栏宽度": "Resize the workspace rail",
|
||||
"调整正文字宽": "Resize the reading column",
|
||||
"调整度量栏宽度": "Resize the metrics rail",
|
||||
"从列表移除": "Remove from the list",
|
||||
"改为输入路径": "Type a path instead",
|
||||
@@ -703,6 +703,14 @@ export const EN: Record<string, string> = {
|
||||
"字体预览 · 中文 Aa Bb 0123": "Typeface preview · Aa Bb 0123",
|
||||
"壁纸": "Wallpaper",
|
||||
"选择图片或视频…": "Choose an image or video…",
|
||||
"音量": "Volume",
|
||||
"导入 Wallpaper Engine…": "Import Wallpaper Engine…",
|
||||
"文件夹里需要 project.json 和 scene.pkg": "The folder needs project.json and scene.pkg",
|
||||
"Wallpaper Engine 场景": "Wallpaper Engine scene",
|
||||
"未命名场景": "Untitled scene",
|
||||
"打开声音": "Unmute sound",
|
||||
"关闭声音": "Mute sound",
|
||||
"场景以主图和声音播放;粒子和着色器效果暂未再现。": "The scene plays its backdrop and sound; particles and shader effects are not reproduced yet.",
|
||||
"换一张…": "Choose another…",
|
||||
"浓度": "Strength",
|
||||
"压暗": "Dim",
|
||||
@@ -998,6 +1006,10 @@ export const EN: Record<string, string> = {
|
||||
"这些地址直连": "Reach these addresses directly",
|
||||
"测试目标": "Test target",
|
||||
"无法读取网络配置。": "Could not read the network config.",
|
||||
"正在读取网络配置…": "Loading network settings…",
|
||||
"测试已保存配置": "Test saved settings",
|
||||
"有未保存的改动;连接测试使用已保存的配置。": "There are unsaved changes. Connection tests use the saved settings.",
|
||||
"已保存配置的测试结果": "Test results for saved settings",
|
||||
|
||||
// ── 命令解释器 ───────────────────────────────────────────────────
|
||||
"当前生效": "In effect",
|
||||
|
||||
@@ -270,6 +270,11 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"不支持该格式,请改用图片(PNG、JPEG、WebP、AVIF、GIF)或视频(MP4、WebM)":
|
||||
"That format is not supported; use an image (PNG, JPEG, WebP, AVIF, GIF) or a video (MP4, WebM)",
|
||||
"图片内容为空": "The image is empty",
|
||||
"场景包过大,请压缩至 {limit} MB 以内": "That scene package is too large — bring it under {limit} MB",
|
||||
"无法读取这个 Wallpaper Engine 场景": "This Wallpaper Engine scene could not be read",
|
||||
"只支持 Scene 类型的 Wallpaper Engine 壁纸": "Only Scene wallpapers from Wallpaper Engine are supported",
|
||||
"这个场景里没有可用的主图": "This scene has no backdrop to show",
|
||||
"不支持该音频格式": "That audio format is not supported",
|
||||
"压缩阈值未能保存:{detail}": "The compaction threshold was not saved: {detail}",
|
||||
"本次请求未包含阈值,未做任何修改": "The request carried no threshold, so nothing changed",
|
||||
"{host} 上的内核没有应答,连接可能断了": "The kernel on {host} is not answering — the connection may be down",
|
||||
|
||||
@@ -1133,7 +1133,9 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"超出一屏的命令输出与文件内容": "Command output and file contents longer than a screen",
|
||||
"压缩简报": "Compaction brief",
|
||||
"上下文压缩后,模型接下来依据的那份简报": "The brief the model works from after context is compacted",
|
||||
"收起并预览首句": "Folded, with first sentence",
|
||||
"收起时预览思考开头": "Preview the beginning when collapsed",
|
||||
"在思考标题下方显示一行开头文字;思考进行中也显示,展开后隐藏。": "Show one line of opening text below the thinking header, including while thinking is in progress. Hidden when expanded.",
|
||||
"先检查配置,再决定改哪里。": "Check the configuration first, then decide what to change.",
|
||||
"进行中展开": "Open while running",
|
||||
"失败时展开": "Open on failure",
|
||||
"改动时展开": "Open on file change",
|
||||
|
||||
@@ -46,4 +46,9 @@ export const EN_TOOL_BLURBS: Record<string, string> = {
|
||||
"派出一个子代理处理一项专项子任务,只返回它的最终答案。": "Start a sub-agent for a focused sub-task and return only its final answer.",
|
||||
"获取一个网址并返回文本内容;HTML 会被精简为可读文本。": "Fetch a URL and return its text; HTML is reduced to readable text.",
|
||||
"搜索网页获取最新信息,返回摘要和来源。": "Search the web for current information and return a summary with sources.",
|
||||
"向更强的模型求助:它读完到目前为止的对话后,用方案、诊断或审查回答你的问题;它自己不执行任何东西。": "Ask a stronger model for advice: it reads the conversation so far and answers with a plan, a diagnosis or a review; it runs nothing itself.",
|
||||
"把一个明确的编码任务并行做 2–4 遍,每遍在独立的 git 工作树里;再由评审比较,只把最好的一份写回工作区。": "Run one well-specified coding task 2–4 times in parallel, each in its own git worktree, then have a judge compare them and write only the best back into the workspace.",
|
||||
"把已有的最终文件以可点击的卡片展示给你,包括命令行生成的文档;路径须在工作区或已授权的文件夹内,打开的是当前文件而不是快照,也不保证内容正确。": "Present existing final files to you as clickable cards, including shell-created documents; paths must be in the workspace or authorized folders, it opens the current files rather than snapshots, and it does not certify correctness.",
|
||||
"读取已连接的 MCP 服务器自己列出的一份资源(文档、记录、文件);服务器没列出的地址会被拒绝,返回的是服务器的数据,不是你的指令。": "Read one resource (a document, a record, a file) that a connected MCP server lists itself; an address the server does not list is refused, and what comes back is the server's data, not your instructions.",
|
||||
"运行一小段 Starlark(类 Python)脚本来调用多个工具,几步相互依赖的操作只需一次往返,只返回脚本打印的内容;每次调用都和模型直接调用一样受检查和审批。": "Run a short Starlark (Python-like) script that calls several tools, so dependent steps take one round trip and only what the script prints comes back; every call is checked and approved as if the model made it directly.",
|
||||
};
|
||||
@@ -448,6 +448,11 @@ const SAID: Record<string, string> = {
|
||||
// ── 壁纸 ─────────────────────────────────────────────────────────
|
||||
"wallpaper.unsupported_type": "不支持该格式,请改用图片(PNG、JPEG、WebP、AVIF、GIF)或视频(MP4、WebM)",
|
||||
"wallpaper.empty": "图片内容为空",
|
||||
"wallpaper.scene_too_large": "场景包过大,请压缩至 {limit} MB 以内",
|
||||
"wallpaper.scene_invalid": "无法读取这个 Wallpaper Engine 场景",
|
||||
"wallpaper.scene_unsupported": "只支持 Scene 类型的 Wallpaper Engine 壁纸",
|
||||
"wallpaper.scene_no_image": "这个场景里没有可用的主图",
|
||||
"wallpaper.audio_unsupported": "不支持该音频格式",
|
||||
|
||||
// ── 来不及了 ──────────────────────────────────────
|
||||
"steer.already_applied": "该条已发送给模型,无法撤回",
|
||||
|
||||
@@ -8,7 +8,6 @@ import "./styles/app.css";
|
||||
import "./styles/composition-picker.css";
|
||||
import "./styles/dock.css";
|
||||
import "./styles/studio.css";
|
||||
import "./styles/flowgrip.css";
|
||||
import "./styles/dialogcenter.css";
|
||||
import "./styles/stophint.css";
|
||||
import "./styles/phonecomposer.css";
|
||||
@@ -204,6 +203,7 @@ pick().then(
|
||||
// drop: a window with no drop target mounted still has to refuse a file, or
|
||||
// the webview navigates to it and the app is replaced by what was dropped.
|
||||
installFileDrop();
|
||||
try { localStorage.removeItem("rx-flow-w"); } catch { /* storage is a convenience */ }
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<RemoteAwareApp hub={hub} remote={new URLSearchParams(location.search).get("device")?.trim() || null} />
|
||||
|
||||
@@ -91,10 +91,24 @@ export interface Wallpaper {
|
||||
// Content-addressed, so the bytes at this address never change.
|
||||
url: string;
|
||||
// A picture is drawn as the window's background, a video as an element
|
||||
// behind it; the kernel decides which from the type it was uploaded as.
|
||||
kind: "image" | "video";
|
||||
// behind it; a scene adds its soundtrack and pointer parallax. The kernel
|
||||
// decides which from the type it was uploaded as.
|
||||
kind: "image" | "video" | "scene";
|
||||
// A Wallpaper Engine scene's title and soundtrack, when it has one.
|
||||
title?: string;
|
||||
audio?: string;
|
||||
audios?: string[];
|
||||
parallax?: number;
|
||||
muted?: boolean;
|
||||
volume?: number;
|
||||
opacity: number;
|
||||
dim: number;
|
||||
focusX: number;
|
||||
focusY: number;
|
||||
}
|
||||
|
||||
export interface SceneFolder {
|
||||
project: File;
|
||||
scene: File;
|
||||
preview?: File;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { MockLook } from "./mock_look";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("the fixture's scene import", () => {
|
||||
it("reads the project title and keeps the scene shape", async () => {
|
||||
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: vi.fn(() => "blob:scene") });
|
||||
const look = new MockLook();
|
||||
const got = await look.uploadScene({
|
||||
project: { size: 20, text: async () => '{"title":"花海"}' } as unknown as File,
|
||||
scene: { size: 10 } as unknown as File,
|
||||
});
|
||||
expect(got.wallpaper).toMatchObject({
|
||||
kind: "scene",
|
||||
title: "花海",
|
||||
parallax: 0.02,
|
||||
muted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a folder without the two files", async () => {
|
||||
const look = new MockLook();
|
||||
await expect(
|
||||
look.uploadScene({ project: { size: 0 } as File, scene: { size: 1 } as File }),
|
||||
).rejects.toMatchObject({ reason: { code: "wallpaper.scene_invalid" } });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HttpError } from "./port";
|
||||
import type { Appearance } from "./port";
|
||||
import type { Appearance, SceneFolder } from "./port";
|
||||
import { STORAGE as LANG } from "../i18n";
|
||||
|
||||
// The appearance half of the fixture. It is held in memory rather than written
|
||||
@@ -49,4 +49,32 @@ export class MockLook {
|
||||
async clearWallpaper(): Promise<void> {
|
||||
this.look = { ...this.look, wallpaper: undefined };
|
||||
}
|
||||
|
||||
async uploadScene(folder: SceneFolder): Promise<Appearance> {
|
||||
if (!folder.project.size || !folder.scene.size) {
|
||||
throw new HttpError(422, "the folder is incomplete", { code: "wallpaper.scene_invalid" });
|
||||
}
|
||||
let title = "";
|
||||
try {
|
||||
title = (JSON.parse(await folder.project.text()) as { title?: string }).title?.trim() ?? "";
|
||||
} catch {
|
||||
// A malformed project still leaves a scene the fixture can show.
|
||||
}
|
||||
this.look = {
|
||||
...this.look,
|
||||
wallpaper: {
|
||||
url: URL.createObjectURL(folder.preview ?? folder.scene),
|
||||
kind: "scene",
|
||||
title: title || "Wallpaper Engine scene",
|
||||
parallax: 0.02,
|
||||
muted: true,
|
||||
volume: 1,
|
||||
opacity: 0.5,
|
||||
dim: 0.55,
|
||||
focusX: 0.5,
|
||||
focusY: 0.5,
|
||||
},
|
||||
};
|
||||
return this.appearance();
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import type { Attachment, DroppedRef, KeptText } from "./attachment";
|
||||
export { DeliveryError, HttpError };
|
||||
export type { DeliveryFault };
|
||||
export type { Attachment, DroppedRef, KeptText };
|
||||
|
||||
import type { AccountState, AccountUser, DeviceGrant } from "./account";
|
||||
import type { Adjudications } from "./adjudication";
|
||||
import type { BackupApplyResult, BackupCatalog, BackupCategory, BackupCreated, BackupCreateRequest, BackupFileCatalog, BackupFileExport, BackupPlan } from "./backup";
|
||||
@@ -49,7 +48,7 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
|
||||
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
|
||||
import type { Appearance, ThemeImport, ThemePack } from "./look";
|
||||
import type { Appearance, SceneFolder, ThemeImport, ThemePack } from "./look";
|
||||
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FeedbackLevelNotices, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, ToolSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import type { ScheduleBoard, ScheduleDraft, SchedulePreview, ScheduleResultView, ScheduleView } from "./schedule";
|
||||
import type { Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
|
||||
@@ -759,6 +758,7 @@ export interface AgentPort extends WorkspacePreviewPort {
|
||||
appearance(): Promise<Appearance>;
|
||||
saveAppearance(look: Appearance): Promise<Appearance>;
|
||||
uploadWallpaper(blob: Blob): Promise<Appearance>;
|
||||
uploadScene(folder: SceneFolder): Promise<Appearance>;
|
||||
clearWallpaper(): Promise<void>;
|
||||
// Where the user put each extension surface, keyed "<pluginId>:<surfaceId>".
|
||||
// It outranks what the extension asked for; an empty slot hands the decision
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SseHttp } from "./sse_http";
|
||||
import type { Appearance } from "./port";
|
||||
import { base64Of, guardUpload, tooLarge, UPLOAD_LIMIT_MB } from "./upload";
|
||||
import type { Appearance, SceneFolder } from "./port";
|
||||
|
||||
// The appearance half of the port. Chained the way the fixture's faces are, so
|
||||
// SsePort satisfies AgentPort in one declaration and each subject keeps its own
|
||||
@@ -21,6 +22,20 @@ export class SseLook extends SseHttp {
|
||||
dim: look.wallpaper?.dim ?? 0,
|
||||
focusX: look.wallpaper?.focusX ?? 0.5,
|
||||
focusY: look.wallpaper?.focusY ?? 0.5,
|
||||
muted: look.wallpaper?.muted ?? false,
|
||||
volume: look.wallpaper?.volume ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
async uploadScene(folder: SceneFolder) {
|
||||
guardUpload(folder.scene.size, UPLOAD_LIMIT_MB.scene, tooLarge("wallpaper.scene_too_large", "limit"));
|
||||
if (folder.preview) {
|
||||
guardUpload(folder.preview.size, UPLOAD_LIMIT_MB.scene, tooLarge("wallpaper.scene_too_large", "limit"));
|
||||
}
|
||||
return this.post0<Appearance>("/appearance/wallpaper/scene", {
|
||||
project: await base64Of(folder.project),
|
||||
scene: await base64Of(folder.scene),
|
||||
preview: folder.preview ? await base64Of(folder.preview) : "",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,17 @@ describe("an upload past the kernel's cap is refused before it is read", () => {
|
||||
expect(calls[0].init.body).toBe(big);
|
||||
});
|
||||
|
||||
it("scene folders, before the package is read", async () => {
|
||||
const fetched = noNetwork();
|
||||
const read = vi.fn(async () => new ArrayBuffer(0));
|
||||
const err = await new SsePort("", "r1")
|
||||
.uploadScene({ project: sized(1, read), scene: sized(200, read) })
|
||||
.catch((e) => e);
|
||||
expect(err.reason).toMatchObject({ code: "wallpaper.scene_too_large", params: { limit: 32 } });
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
expect(fetched).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("theme packs, counting every file picked", async () => {
|
||||
const fetched = noNetwork();
|
||||
const read = vi.fn(async () => new ArrayBuffer(0));
|
||||
|
||||
@@ -5,7 +5,7 @@ import { HttpError } from "./http_error";
|
||||
// before the file is read: turning one into base64 is several copies of it in
|
||||
// the renderer's memory, and the kernel would refuse it anyway. A Go test pins
|
||||
// these numbers to the kernel's.
|
||||
export const UPLOAD_LIMIT_MB = { attachment: 25, image: 10, theme: 48 } as const;
|
||||
export const UPLOAD_LIMIT_MB = { attachment: 25, image: 10, scene: 32, theme: 48 } as const;
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { createContext, useContext, useRef, useSyncExternalStore } from "react";
|
||||
import type { Item } from "./session_types";
|
||||
import { foldModes, onFoldModesChange, type Fold, type FoldMode, type FoldModes } from "./prefs";
|
||||
import { foldModes, onFoldModesChange, showsThinkingPreview, type Fold, type FoldMode, type FoldModes } from "./prefs";
|
||||
import { receiptPrefOf, type ReceiptPref, type ReceiptState } from "./receiptpref";
|
||||
|
||||
export function useFoldModes(): FoldModes {
|
||||
return useSyncExternalStore(onFoldModesChange, foldModes, foldModes);
|
||||
}
|
||||
|
||||
export function useThinkingPreview(): boolean {
|
||||
return useSyncExternalStore(onFoldModesChange, showsThinkingPreview, showsThinkingPreview);
|
||||
}
|
||||
|
||||
/** The receipt preference of the kernel this pane talks to; a transcript drawn
|
||||
* outside a pane reads the standalone one. */
|
||||
export const ReceiptPrefContext = createContext<ReceiptPref>(receiptPrefOf());
|
||||
|
||||
@@ -195,16 +195,13 @@ export function setLiveGraceMinutes(minutes: number): void {
|
||||
liveGraceListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
// How each foldable part of the transcript starts. "live" opens while the part
|
||||
// is still being written and folds once it is done; "failed" opens only a step
|
||||
// that failed; "changed" opens only what wrote a file. A block the reader opened
|
||||
// or closed keeps that choice.
|
||||
// Expansion defaults apply until the reader opens or closes a block.
|
||||
export type Fold = "thinking" | "activity" | "steps" | "output" | "compaction";
|
||||
export type FoldMode = "folded" | "peek" | "live" | "failed" | "changed" | "open";
|
||||
export type FoldMode = "folded" | "live" | "failed" | "changed" | "open";
|
||||
export type FoldModes = Readonly<Record<Fold, FoldMode>>;
|
||||
|
||||
export const FOLD_CHOICES: Readonly<Record<Fold, readonly FoldMode[]>> = {
|
||||
thinking: ["folded", "peek", "live", "open"],
|
||||
thinking: ["folded", "live", "open"],
|
||||
activity: ["folded", "live", "changed", "open"],
|
||||
steps: ["folded", "failed", "changed", "open"],
|
||||
output: ["folded", "open"],
|
||||
@@ -220,8 +217,9 @@ export const FOLD_DEFAULTS: FoldModes = {
|
||||
};
|
||||
|
||||
const FOLD_KEY = "rx-transcript-fold";
|
||||
type FoldPreferences = { modes: FoldModes; thinkingPreview: boolean };
|
||||
|
||||
function readFolds(): FoldModes {
|
||||
function readFolds(): FoldPreferences {
|
||||
let saved: Record<string, unknown> = {};
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(FOLD_KEY) ?? "{}");
|
||||
@@ -234,17 +232,25 @@ function readFolds(): FoldModes {
|
||||
const v = saved[kind];
|
||||
if (typeof v === "string" && (FOLD_CHOICES[kind] as readonly string[]).includes(v)) out[kind] = v as FoldMode;
|
||||
}
|
||||
return out;
|
||||
return { modes: out, thinkingPreview: saved.thinkingPreview === true || saved.thinking === "peek" };
|
||||
}
|
||||
|
||||
let folds: FoldModes | null = null;
|
||||
let folds: FoldPreferences | null = null;
|
||||
const foldListeners = new Set<() => void>();
|
||||
|
||||
export function foldModes(): FoldModes {
|
||||
function foldPreferences(): FoldPreferences {
|
||||
folds ??= readFolds();
|
||||
return folds;
|
||||
}
|
||||
|
||||
export function foldModes(): FoldModes {
|
||||
return foldPreferences().modes;
|
||||
}
|
||||
|
||||
export function showsThinkingPreview(): boolean {
|
||||
return foldPreferences().thinkingPreview;
|
||||
}
|
||||
|
||||
export function onFoldModesChange(fn: () => void): () => void {
|
||||
foldListeners.add(fn);
|
||||
return () => {
|
||||
@@ -257,9 +263,17 @@ export function setFoldModes(patch: Partial<FoldModes>): void {
|
||||
for (const kind of Object.keys(patch) as Fold[]) {
|
||||
if (!FOLD_CHOICES[kind].includes(next[kind])) next[kind] = FOLD_DEFAULTS[kind];
|
||||
}
|
||||
saveFolds({ ...foldPreferences(), modes: next });
|
||||
}
|
||||
|
||||
export function setShowsThinkingPreview(on: boolean): void {
|
||||
saveFolds({ ...foldPreferences(), thinkingPreview: on });
|
||||
}
|
||||
|
||||
function saveFolds(next: FoldPreferences): void {
|
||||
folds = next;
|
||||
try {
|
||||
localStorage.setItem(FOLD_KEY, JSON.stringify(next));
|
||||
localStorage.setItem(FOLD_KEY, JSON.stringify({ ...next.modes, thinkingPreview: next.thinkingPreview }));
|
||||
} catch {
|
||||
/* the choice holds for this window and is forgotten on the next */
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.resetModules();
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
// Dynamic imports exercise a fresh preference cache after a window reload.
|
||||
describe("thinking preview persistence", () => {
|
||||
it("migrates a saved peek into collapsed thinking with previews, preserving sibling modes", async () => {
|
||||
localStorage.setItem("rx-transcript-fold", JSON.stringify({ thinking: "peek", activity: "open", steps: "changed" }));
|
||||
const prefs = await import("./prefs");
|
||||
expect(prefs.foldModes()).toMatchObject({ thinking: "folded", activity: "open", steps: "changed" });
|
||||
expect(prefs.showsThinkingPreview()).toBe(true);
|
||||
prefs.setShowsThinkingPreview(false);
|
||||
vi.resetModules();
|
||||
const reloaded = await import("./prefs");
|
||||
expect(reloaded.showsThinkingPreview()).toBe(false);
|
||||
expect(reloaded.foldModes()).toMatchObject({ thinking: "folded", activity: "open", steps: "changed" });
|
||||
});
|
||||
|
||||
it("persists the preview independently of later folding choices", async () => {
|
||||
const prefs = await import("./prefs");
|
||||
prefs.setShowsThinkingPreview(true);
|
||||
prefs.setFoldModes({ thinking: "live", activity: "folded" });
|
||||
vi.resetModules();
|
||||
const reloaded = await import("./prefs");
|
||||
expect(reloaded.showsThinkingPreview()).toBe(true);
|
||||
expect(reloaded.foldModes()).toMatchObject({ thinking: "live", activity: "folded" });
|
||||
});
|
||||
|
||||
it.each(["not-json", "null", "[]", '{"thinking":"unknown","thinkingPreview":"true"}'])("handles unreadable or invalid preferences: %s", async (raw) => {
|
||||
localStorage.setItem("rx-transcript-fold", raw);
|
||||
const prefs = await import("./prefs");
|
||||
expect(prefs.foldModes()).toEqual(prefs.FOLD_DEFAULTS);
|
||||
expect(prefs.showsThinkingPreview()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a migrated choice usable when storage cannot be written", async () => {
|
||||
localStorage.setItem("rx-transcript-fold", '{"thinking":"peek"}');
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Unavailable", "SecurityError"); });
|
||||
const prefs = await import("./prefs");
|
||||
expect(prefs.showsThinkingPreview()).toBe(true);
|
||||
prefs.setShowsThinkingPreview(false);
|
||||
prefs.setFoldModes({ thinking: "live" });
|
||||
expect(prefs.showsThinkingPreview()).toBe(false);
|
||||
expect(prefs.foldModes().thinking).toBe("live");
|
||||
});
|
||||
});
|
||||
@@ -1690,3 +1690,9 @@ A sub-agent row, by the list's own width:
|
||||
|
||||
- The panel stays anchored above its own composer. Collapsing it leaves a header with separate expand and close controls so the conversation can be read without discarding the side question.
|
||||
- The mounted content keeps its draft, quotes, answers and pending request while collapsed. Errors, quotes and replies share the scroll region; the input stays outside it.
|
||||
|
||||
## Collapsed thinking
|
||||
|
||||
- Thinking expansion and collapsed previews are independent display preferences. Folding presets change expansion only; manual disclosure choices keep their priority.
|
||||
- The optional preview occupies one line below the header, with a left rule and width-based ellipsis. It shows the opening text, including during streaming, rather than following the newest tokens.
|
||||
- The preview remains inside the disclosure summary so it is visible and clickable while collapsed. Expanded reasoning replaces it; the settings demonstration uses the same preview styling.
|
||||
@@ -276,6 +276,18 @@ pack states a shape, never a number the layout has to agree with.
|
||||
- `--read` is the conversation body's own size, separate from interface zoom, so
|
||||
"I cannot read this" adjusts one thing. The value here is the first-frame
|
||||
fallback; the real default is `look.ts`'s `readDefault()`.
|
||||
- Execution content derives size from `--read`. Appearance also projects
|
||||
`--read-scale` as the selected body size divided by the language's default.
|
||||
- The root's `--activity-scale` multiplies that ratio by the interface text scale.
|
||||
Activity groups inherit the product as their local `--fs-scale`, including
|
||||
work folded at turn completion.
|
||||
- Composing at the root avoids self-reference and prevents nested activity
|
||||
groups from multiplying the scale again.
|
||||
- Activity title, running, failed and action counts retain one 11px/500 scale,
|
||||
1.5 line-height and the running chip's 2px vertical padding. Interface and
|
||||
body-size changes scale all four equally; the common 500 weight stays fixed.
|
||||
Tool rows and input details retain reading/weight preferences.
|
||||
- Labels use `--ui`; tool names, paths and input use `--mono`.
|
||||
- `--column` is the reading column's width. The composer is laid at it in
|
||||
every measure; the transcript takes it through `--doc`.
|
||||
- `--doc` is the column the transcript is laid in: `--column`, or the whole
|
||||
|
||||
@@ -705,7 +705,7 @@
|
||||
.scroll[data-pane="browser"] { display: flex; flex-direction: column; overflow: hidden }
|
||||
.scroll[data-pane="browser"][hidden] { display: none }
|
||||
|
||||
.pbody { flex: 1; min-height: 0; display: flex; flex-direction: column }
|
||||
.pbody { flex: 1; min-height: 0; display: flex; flex-direction: column; position: relative }
|
||||
.pviews { flex: 1; min-height: 0; display: flex; flex-direction: column }
|
||||
.pane > .scroll[data-pane="browser"] { flex: 1; min-height: 0 }
|
||||
/* The pages full width: the column holding the conversation is empty then,
|
||||
@@ -1080,8 +1080,8 @@
|
||||
@keyframes bob { 0%, 100% { opacity: .22; transform: translateY(0) } 50% { opacity: 1; transform: translateY(-3px) } }
|
||||
|
||||
.think { margin-top: 5px }
|
||||
.think > summary { display: inline-flex; max-width: 100%; align-items: baseline; gap: 8px }
|
||||
.think .peek { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--faint) }
|
||||
.think > summary { display: flex; flex-direction: column; max-width: 100%; gap: 0 }
|
||||
.thinking-preview { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 5px 0 0 14px; padding-left: 10px; border-left: 1px solid var(--hair); font: var(--w-reg) var(--fs-ui)/1.68 var(--ui); color: var(--faint) }
|
||||
.think .tk {
|
||||
margin-top: 5px; padding-left: 14px;
|
||||
font: var(--w-reg) var(--fs-ui)/1.68 var(--ui); color: var(--faint); white-space: pre-wrap;
|
||||
@@ -2356,7 +2356,7 @@
|
||||
animation: sheetin var(--t-panel) var(--ease-spring) both;
|
||||
}
|
||||
.prefs-sheet[data-wide] { width: 100%; height: 100% }
|
||||
.prefs-sheet[data-wide] .prefs-main .prefs-col { max-width: min(1200px, 100%) }
|
||||
.prefs-sheet[data-wide] .prefs-main .prefs-col { max-width: min(1200px, 100%); margin-inline: auto }
|
||||
|
||||
@keyframes prefsin { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes sheetin { from { transform: translateY(14px) scale(.985) } to { transform: none } }
|
||||
@@ -3132,7 +3132,7 @@
|
||||
.mp-source-line { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint) }
|
||||
.mp-host { font-family: var(--mono); color: var(--text) }
|
||||
.mp-foreign { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px }
|
||||
.mp-row select { padding: 6px 8px; font-size: var(--fs-meta) }
|
||||
.mp-row select { padding: 6px 8px; border: 1px solid var(--border); border-radius: var(--r-xs); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-meta) var(--mono) } .mp-row[data-own] select { border-color: var(--accent) }
|
||||
.ml-row .me-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text) }
|
||||
.ml-row input::placeholder { color: var(--ghost) }
|
||||
.ml-row[data-own] .unit-field:has(input:not(:placeholder-shown)) { border-color: var(--accent) }
|
||||
@@ -3893,12 +3893,12 @@
|
||||
.code-more .studio-icon { width: 13px; height: 13px; transform: rotate(90deg); transition: transform var(--m-line) }
|
||||
.code-more[aria-expanded="true"] .studio-icon { transform: rotate(-90deg) }
|
||||
.md pre.term code { font: inherit; color: inherit; white-space: pre }
|
||||
.md pre.term .hljs-comment, .md pre.term .hljs-quote { color: var(--faint); font-style: italic }
|
||||
.md pre.term :is(.hljs-punctuation, .hljs-operator, .hljs-meta, .hljs-params, .hljs-subst) { color: var(--muted) }
|
||||
.md pre.term .hljs-comment, .md pre.term .hljs-quote { color: var(--syn-comment, var(--faint)); font-style: italic }
|
||||
.md pre.term :is(.hljs-meta, .hljs-params, .hljs-subst) { color: var(--muted) } .md pre.term .hljs-punctuation { color: var(--syn-punc, var(--muted)) } .md pre.term .hljs-operator { color: var(--syn-op, var(--muted)) } .md pre.term .hljs-variable { color: var(--syn-var, inherit) }
|
||||
.md pre.term :is(.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-doctag, .hljs-variable.language_) { color: var(--syn-key) }
|
||||
.md pre.term :is(.hljs-string, .hljs-regexp, .hljs-char.escape_, .hljs-addition, .hljs-attribute, .hljs-meta .hljs-string) { color: var(--syn-str) }
|
||||
.md pre.term :is(.hljs-number, .hljs-symbol, .hljs-bullet, .hljs-link, .hljs-selector-attr, .hljs-template-variable) { color: var(--syn-num) }
|
||||
.md pre.term :is(.hljs-title, .hljs-name, .hljs-section, .hljs-built_in, .hljs-type, .hljs-class, .hljs-attr, .hljs-property, .hljs-tag, .hljs-selector-class, .hljs-selector-id) { color: var(--syn-fn) }
|
||||
.md pre.term :is(.hljs-title, .hljs-name, .hljs-section, .hljs-built_in, .hljs-attr, .hljs-property, .hljs-tag, .hljs-selector-class, .hljs-selector-id) { color: var(--syn-fn) } .md pre.term :is(.hljs-type, .hljs-class) { color: var(--syn-type, var(--syn-fn)) }
|
||||
.md pre.term .hljs-deletion { color: var(--del) }
|
||||
.md pre.term :is(.hljs-strong, .hljs-title.function_, .hljs-title.class_) { font-weight: var(--w-med) }
|
||||
.md pre.term .hljs-emphasis { font-style: italic }
|
||||
@@ -4413,11 +4413,11 @@
|
||||
.ptab:hover { color: var(--text); background: var(--overlay) }
|
||||
.ptab[aria-selected="true"] { color: var(--text); background: var(--surface); box-shadow: inset 0 1px 0 var(--border) }
|
||||
.ptab:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px }
|
||||
.ptab-nm { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis }
|
||||
.ptab-nm { flex: 1; min-width: 4em; overflow: hidden; text-overflow: ellipsis }
|
||||
.ptab-x { margin-left: auto }
|
||||
.ptab-ws { flex: none; font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--ghost) }
|
||||
|
||||
.ptab-host { flex: none; font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--net) }
|
||||
.ptab-ws, .ptab-host { flex: 0 1 auto; min-width: 0; max-width: 40%; overflow: hidden; text-overflow: ellipsis }
|
||||
.ptab-ws { font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--ghost) }
|
||||
.ptab-host { font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--net) }
|
||||
.ptab-host::before { content: "◦"; margin-right: 2px; opacity: .7 }
|
||||
.ptab .pip { flex: none; background: var(--ghost) }
|
||||
|
||||
@@ -5614,6 +5614,8 @@
|
||||
.compose .row { --m-step: 30ms }
|
||||
}
|
||||
|
||||
.wvol-pop { max-height: 0; opacity: 0; overflow: hidden; transition: max-height var(--t-panel) var(--ease), opacity var(--t-panel) var(--ease) }
|
||||
.wvol:hover .wvol-pop, .wvol:focus-within .wvol-pop { max-height: 108px; opacity: 1 }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-delay: 0s !important;
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
.pbody { position: relative }
|
||||
|
||||
.flowgrips { position: absolute; inset: 0; z-index: 3; pointer-events: none }
|
||||
.flowgrip {
|
||||
position: absolute; top: 0; bottom: 0; width: 7px;
|
||||
cursor: col-resize; touch-action: none; pointer-events: auto;
|
||||
}
|
||||
.flowgrip-l { left: max(0px, calc(var(--grip-left, 0px) - 18px)) }
|
||||
.flowgrip-r { right: max(0px, calc(var(--grip-right, 0px) - 18px)) }
|
||||
.flowgrip::before {
|
||||
content: ""; position: absolute; top: 50%; left: 3px; width: 1px; height: 38px;
|
||||
translate: 0 -50%;
|
||||
border-radius: 1px;
|
||||
background: transparent;
|
||||
transition: background var(--m-card) var(--ease), height var(--m-card) var(--ease), transform var(--m-card) var(--ease);
|
||||
}
|
||||
.flowgrip:hover::before { background: var(--border); height: 64px; transform: scaleX(2) }
|
||||
.flowgrip:focus-visible { outline: none }
|
||||
.flowgrip:focus-visible::before { background: var(--focus); height: 64px; transform: scaleX(2) }
|
||||
body[data-flowdrag] .flowgrip::before { background: var(--accent); height: 64px; transform: scaleX(2) }
|
||||
body[data-flowdrag], body[data-flowdrag] * { cursor: col-resize }
|
||||
body[data-flowdrag] { user-select: none }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.flowgrip::before { transition: none }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// `.railhost` is a zero-height sticky layer over the transcript, so it turns
|
||||
// pointer events off and every child that a person can press has to turn them
|
||||
// back on. Forgetting that is invisible to jsdom — it does no hit testing — and
|
||||
// shows up only as "the rows cannot be clicked" in a real browser.
|
||||
const SHEETS = import.meta.glob("./{app,studio}.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
|
||||
|
||||
interface Rule {
|
||||
selector: string;
|
||||
decls: Map<string, string>;
|
||||
}
|
||||
|
||||
function rules(css: string): Rule[] {
|
||||
const out: Rule[] = [];
|
||||
for (const m of css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
const decls = new Map<string, string>();
|
||||
for (const part of m[2].split(";")) {
|
||||
const at = part.indexOf(":");
|
||||
if (at > 0) decls.set(part.slice(0, at).trim(), part.slice(at + 1).trim());
|
||||
}
|
||||
out.push({ selector: m[1].trim(), decls });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The declarations that apply to `className`, across every sheet, in source order. */
|
||||
function declsOf(className: string): Map<string, string> {
|
||||
const merged = new Map<string, string>();
|
||||
for (const css of Object.values(SHEETS)) {
|
||||
for (const rule of rules(css)) {
|
||||
if (!rule.selector.split(",").some((s) => s.trim() === `.${className}`)) continue;
|
||||
for (const [k, v] of rule.decls) merged.set(k, v);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
describe("the conversation rail can be pressed", () => {
|
||||
it("turns pointer events off on the layer that floats over the transcript", () => {
|
||||
expect(declsOf("railhost").get("pointer-events")).toBe("none");
|
||||
});
|
||||
|
||||
// Anything a person can press inside that layer has to opt back in. The rail
|
||||
// itself always did; the two surfaces added with it — the unloaded-history
|
||||
// marker and the opened run — did not, and their rows fell through to the
|
||||
// transcript underneath.
|
||||
for (const surface of ["srail", "srail-earlier", "srail-sub"]) {
|
||||
it(`lets .${surface} take the pointer`, () => {
|
||||
expect(declsOf(surface).get("pointer-events"), `.${surface} sits inside pointer-events: none`).toBe("auto");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -219,7 +219,7 @@ body { background: var(--page); }
|
||||
|
||||
/* Composer */
|
||||
.compose {
|
||||
width: min(var(--flow-w, var(--column)), calc(100% - var(--srail-w) - 42px));
|
||||
width: min(var(--column), calc(100% - var(--srail-w) - 42px));
|
||||
max-width: none;
|
||||
margin: 0 auto 14px;
|
||||
padding: 14px 15px 11px;
|
||||
@@ -714,9 +714,9 @@ body { background: var(--page) !important; }
|
||||
background: color-mix(in srgb, var(--text) 38%, transparent);
|
||||
}
|
||||
.srail-m[data-on] > i { background: #91a7df; }
|
||||
.srail-peek { right: 42px; width: min(300px, calc(100vw - var(--rail-w) - 78px)); }
|
||||
.srail-peek { right: 42px; width: min(300px, calc(100vw - var(--rail-w) - 78px)); } .srail-earlier { right: 12px; width: 3px; height: 10px; margin-top: -5px; z-index: 2; padding: 0; border: 0; border-radius: 2px; background: color-mix(in srgb, var(--text) 26%, transparent); cursor: pointer; pointer-events: auto; } .srail-earlier:hover { background: var(--accent-ink) } .srail-sub { position: absolute; right: 42px; z-index: 6; width: 220px; max-height: 260px; overflow-y: auto; padding: 6px; border: 1px solid var(--border); border-radius: 10px; background: var(--float); box-shadow: var(--shadow-float); transform: translateY(-50%); pointer-events: auto; } .srail-subhd { padding: 2px 6px 4px; color: var(--faint); font: 500 calc(10px * var(--fs-scale, 1))/1.4 var(--ui); } .srail-subrow { display: block; width: 100%; padding: 4px 6px; border: 0; border-radius: 6px; background: none; color: var(--text); font: 450 calc(11px * var(--fs-scale, 1))/1.45 var(--ui); text-align: start; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .srail-subrow:hover { background: var(--overlay) }
|
||||
.flow {
|
||||
width: min(var(--flow-w, var(--doc)), calc(100% - var(--srail-w) - 44px));
|
||||
width: min(var(--doc), calc(100% - var(--srail-w) - 44px));
|
||||
margin-inline: auto;
|
||||
position: relative;
|
||||
left: 0;
|
||||
@@ -725,7 +725,7 @@ body { background: var(--page) !important; }
|
||||
.flow[data-empty] { display: flex; align-items: center; justify-content: center; }
|
||||
.flow:not([data-empty]) {
|
||||
flex: 0 0 auto;
|
||||
width: min(var(--flow-w, var(--doc)), calc(100% - var(--srail-w) - 34px));
|
||||
width: min(var(--doc), calc(100% - var(--srail-w) - 34px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 36px;
|
||||
box-sizing: border-box;
|
||||
@@ -868,7 +868,7 @@ body { background: var(--page) !important; }
|
||||
color: var(--text); font-family: var(--ui); font-size: var(--read); font-weight: var(--w-reg); line-height: 1.72;
|
||||
letter-spacing: 0; -webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.activity-group { margin: var(--sp-4) 0 0; overflow: clip; }
|
||||
.activity-group { --fs-scale: var(--activity-scale, 1); margin: var(--sp-4) 0 0; overflow: clip; }
|
||||
.activity-group > summary {
|
||||
min-height: 28px; padding: 0; display: flex; align-items: center; gap: var(--sp-3);
|
||||
color: var(--faint); font-size: calc(11px * var(--fs-scale, 1)); font-weight: 500; line-height: 1.5;
|
||||
@@ -920,17 +920,17 @@ body { background: var(--page) !important; }
|
||||
}
|
||||
.activity-body .hl .nm {
|
||||
order: -9; flex: 0 1 auto; min-width: 3em; max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
color: #d1d3cd; font: 520 calc(10.5px * var(--fs-scale, 1))/1.3 var(--mono) !important;
|
||||
color: #d1d3cd; font: var(--w-med) calc(var(--read) * .85)/1.3 var(--mono) !important;
|
||||
}
|
||||
.activity-body .hl .arg {
|
||||
order: -8; flex: 1 8 auto; color: var(--faint); font: 450 calc(10.5px * var(--fs-scale, 1))/1.3 var(--mono) !important;
|
||||
order: -8; flex: 1 8 auto; color: var(--faint); font: var(--w-read, var(--w-reg)) calc(var(--read) * .85)/1.3 var(--mono) !important;
|
||||
}
|
||||
.activity-body .hl :is(.src, .who, .tag) { order: -7; flex: 0 3 auto; min-width: 0; max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: calc(8.5px * var(--fs-scale, 1)); }
|
||||
.activity-body .cost { order: -6; margin-left: auto; gap: 5px; color: var(--ghost); font-size: calc(9px * var(--fs-scale, 1)); }
|
||||
.activity-body .hl :is(.src, .who, .tag) { order: -7; flex: 0 3 auto; min-width: 0; max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: calc(var(--read) * .7); }
|
||||
.activity-body .cost { order: -6; margin-left: auto; gap: 5px; color: var(--ghost); font-size: calc(var(--read) * .75); }
|
||||
.activity-body .cost .took { display: none; }
|
||||
.activity-note {
|
||||
margin: 0; padding: 3px 12px 7px; border-top: 0;
|
||||
color: var(--faint); font: 450 calc(9px * var(--fs-scale, 1))/1.5 var(--ui);
|
||||
color: var(--faint); font: var(--w-read, var(--w-reg)) calc(var(--read) * .75)/1.5 var(--ui);
|
||||
}
|
||||
.flow { --out-peek: 4rem; }
|
||||
.flow .call:not([data-k="me"]):not([data-k="say"]) { margin-block: 0 2px; }
|
||||
@@ -984,16 +984,16 @@ body { background: var(--page) !important; }
|
||||
.activity-call-result { display: flex; align-items: center; gap: 8px; }
|
||||
.activity-call-meta {
|
||||
min-height: 25px; justify-content: space-between; padding-right: 5px;
|
||||
color: var(--ghost); font: 450 calc(9px * var(--fs-scale, 1))/1.4 var(--ui);
|
||||
color: var(--ghost); font: var(--w-read, var(--w-reg)) calc(var(--read) * .75)/1.4 var(--ui);
|
||||
}
|
||||
.activity-call-input { padding: 5px 5px 8px 0; border-top: 1px solid var(--hair); }
|
||||
.activity-call-input > header,
|
||||
.activity-call-result { min-height: 23px; color: var(--ghost); font: 500 calc(9px * var(--fs-scale, 1))/1.3 var(--ui); }
|
||||
.activity-call-result { min-height: 23px; color: var(--ghost); font: var(--w-read, var(--w-reg)) calc(var(--read) * .75)/1.3 var(--ui); }
|
||||
.activity-call-input > header b,
|
||||
.activity-call-result b { color: var(--muted); font-weight: 560; }
|
||||
.activity-call-input > header span { margin-left: auto; color: var(--ghost); font: 500 calc(8px * var(--fs-scale, 1))/1 var(--mono); }
|
||||
.activity-call-result b { color: var(--muted); font-weight: var(--w-med); }
|
||||
.activity-call-input > header span { margin-left: auto; color: var(--ghost); font: var(--w-med) calc(var(--read) * .65)/1 var(--mono); }
|
||||
.activity-call-input > p,
|
||||
.activity-call-input > details > summary { display: flex; align-items: center; min-height: 23px; margin: 0; color: var(--ghost); font: 500 calc(9px * var(--fs-scale, 1))/1.3 var(--ui); }
|
||||
.activity-call-input > details > summary { display: flex; align-items: center; min-height: 23px; margin: 0; color: var(--ghost); font: var(--w-read, var(--w-reg)) calc(var(--read) * .75)/1.3 var(--ui); }
|
||||
.activity-call-input > details > summary { gap: 7px; list-style: none; cursor: pointer; user-select: none; }
|
||||
.activity-call-input > details > summary::-webkit-details-marker { display: none; }
|
||||
.activity-call-input > details > summary::before { content: ""; width: 5px; height: 5px; flex: none;
|
||||
@@ -1003,12 +1003,12 @@ body { background: var(--page) !important; }
|
||||
.activity-call-input pre {
|
||||
max-height: 170px; margin: 2px 0 0; padding: 8px 10px; overflow: auto;
|
||||
border: 1px solid var(--hair); border-radius: var(--r-md); background: var(--face-code);
|
||||
color: var(--muted); font: var(--w-reg) calc(11.5px * var(--fs-scale, 1))/1.6 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere;
|
||||
color: var(--muted); font: var(--w-reg) calc(var(--read) * .9)/1.6 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere;
|
||||
}
|
||||
.activity-call-result { padding: 6px 5px 3px 0; border-top: 1px solid var(--hair); }
|
||||
.activity-call-result button {
|
||||
margin-left: auto; padding: 3px 5px; border: 0; border-radius: 4px;
|
||||
background: transparent; color: var(--ghost); cursor: pointer; font: 500 calc(8.5px * var(--fs-scale, 1))/1 var(--ui);
|
||||
background: transparent; color: var(--ghost); cursor: pointer; font: var(--w-med) calc(var(--read) * .7)/1 var(--ui);
|
||||
}
|
||||
.activity-call-result button:hover { color: #cfd1ca; background: var(--float); }
|
||||
.tool-disclosure[open] > summary .tool-fold { transform: rotate(225deg) translate(-1px, 1px); }
|
||||
@@ -1074,7 +1074,7 @@ body { background: var(--page) !important; }
|
||||
.flow .copy:hover { color: var(--muted); background: transparent; }
|
||||
|
||||
.compose {
|
||||
width: min(var(--flow-w, var(--column)), calc(100% - var(--srail-w) - 42px));
|
||||
width: min(var(--column), calc(100% - var(--srail-w) - 42px));
|
||||
margin-bottom: 35px;
|
||||
padding: 15px 16px 10px;
|
||||
border-radius: 15px;
|
||||
@@ -1174,7 +1174,7 @@ body { background: var(--page) !important; }
|
||||
.btn.send:hover { background: var(--accent); transform: translateY(-1px); }
|
||||
.btn.send:disabled { background: var(--hair); color: var(--ghost); }
|
||||
.composeaux,
|
||||
.composenotes { width: min(var(--flow-w, var(--column)), calc(100% - var(--srail-w) - 42px)); margin-inline: auto; }
|
||||
.composenotes { width: min(var(--column), calc(100% - var(--srail-w) - 42px)); margin-inline: auto; }
|
||||
.composenotes { color: var(--ghost); }
|
||||
.studio-meterrail {
|
||||
position: absolute; z-index: 12; left: 16px; right: 16px; bottom: -32px; min-height: 27px;
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
--fs-lead: calc(15px * var(--fs-scale, 1)); --fs-title: calc(17px * var(--fs-scale, 1)); --fs-display: calc(22px * var(--fs-scale, 1));
|
||||
|
||||
--read: 16px;
|
||||
--activity-scale: calc(var(--fs-scale, 1) * var(--read-scale, 1));
|
||||
|
||||
--column: 760px; --doc: min(100%, var(--column)); --wide: min(100%, 1120px);
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ import { useLinkRouting } from "./links";
|
||||
import { Pane, type PaneReport } from "./Pane";
|
||||
import { clearDraftForSession } from "./drafts";
|
||||
import { DOCK, Gutter, RAIL, dockMax, keepWidth, widthOf } from "./Gutter";
|
||||
import { useColumn } from "./FlowResizer";
|
||||
import { useWindowKeys, type Shortcut } from "./windowkeys";
|
||||
import { useEscStop } from "../state/escstop";
|
||||
import { startUpdateWatch } from "../state/updatewatch";
|
||||
@@ -50,7 +49,7 @@ import { useZoomKeys } from "./zoomkeys";
|
||||
import { rememberActivePane, savedActivePane } from "./activepane";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { PeerThreadsProvider } from "./peerthreads";
|
||||
import { Backdrop } from "./Backdrop";import { useAddWorkspace } from "./addws";
|
||||
import { WallpaperStage } from "./WallpaperStage";import { useAddWorkspace } from "./addws";
|
||||
import { AddWorkspacePrompt } from "./AddWorkspacePrompt";
|
||||
import { PaneTabs } from "./PaneTabs";
|
||||
import { paneReader } from "../state/paneread";
|
||||
@@ -398,10 +397,6 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
useEffect(() => onRoomWidth((width) => setDockLimit(dockMax(width))), []);
|
||||
const shownDockW = Math.min(dockW, dockLimit);
|
||||
|
||||
// The reading column owns its width and what it takes from the sidebars; the
|
||||
// window only says how much room there is.
|
||||
const col = useColumn(railW, shownDockW, RAIL.min, DOCK.min);
|
||||
|
||||
const onRailW = useCallback((w: number) => { setRailW(w); keepWidth(RAIL, w); }, []);
|
||||
const onDockW = useCallback((w: number) => { setDockW(w); keepWidth(DOCK, w); }, []);
|
||||
|
||||
@@ -563,7 +558,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
data-apv={report.status?.toolApprovalMode ?? "ask"}
|
||||
data-prefs={settings ? "" : undefined}
|
||||
data-tabs={runtimes.length > 1 ? "" : undefined}
|
||||
style={{ "--rail-open": `${col.rail}px`, "--dock-open": `${col.dock}px`, ...(col.dragged > 0 ? { "--flow-w": `${col.width}px` } : null) } as CSSProperties}
|
||||
style={{ "--rail-open": `${railW}px`, "--dock-open": `${shownDockW}px` } as CSSProperties}
|
||||
>
|
||||
{ask && <RemoteAsk ask={ask} onAnswer={answerRemote} />}
|
||||
<BrowserLogin />
|
||||
@@ -574,7 +569,6 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
status={report.status}
|
||||
title={tabs.find((tab) => tab.rt.id === active)?.title ?? report.title}
|
||||
steer={report.steer}
|
||||
onSettings={showPrefs}
|
||||
account={account}
|
||||
theme={scheme}
|
||||
onTheme={() => setTheme(scheme === "dark" ? "light" : "dark")}
|
||||
@@ -583,7 +577,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
hub={hub} onError={fail}
|
||||
/>
|
||||
|
||||
<Backdrop sky={!!pack?.sky && effects !== "reduced"} wallpaper={look?.wallpaper} />
|
||||
<WallpaperStage sky={!!pack?.sky && effects !== "reduced"} look={look} onLook={onLook} />
|
||||
|
||||
<div className="cols">
|
||||
{navRail.drawn && (
|
||||
@@ -710,10 +704,9 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
onFeedback={openFeedback}
|
||||
onOpenSession={openSessionFor(rt.id)}
|
||||
theme={scheme}
|
||||
dockW={col.dock}
|
||||
dockW={shownDockW}
|
||||
dockMax={dockLimit}
|
||||
onDockW={onDockW}
|
||||
column={col}
|
||||
manualBrowser={rt.id === active && browser}
|
||||
onManualBrowser={setBrowser}
|
||||
/>
|
||||
|
||||
@@ -29,6 +29,8 @@ import { EscStopSetting } from "./EscStopSetting";
|
||||
import { PageSleepSetting } from "./PageSleepSetting";
|
||||
import { ThemeImport } from "./ThemeImport";
|
||||
import { ThemeRemove } from "./ThemeRemove";
|
||||
import { WallpaperSceneImport } from "./WallpaperSceneImport";
|
||||
import { WallpaperVolumeSlider } from "./WallpaperVolumeSlider";
|
||||
import { useThemeInventory } from "./useThemeInventory";
|
||||
|
||||
// "" follows the machine; the rest are explicit, the same shape the light/dark
|
||||
@@ -413,6 +415,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
</span>
|
||||
{t(look.wallpaper ? "换一张…" : "选择图片或视频…")}
|
||||
</button>
|
||||
<WallpaperSceneImport port={port} look={look} onLook={onLook} setPaper={setPaper} onError={setFailed} />
|
||||
{/* Composited the way the window is: the picture, then page colour
|
||||
over it. The scrim fades out with the strength, or a picture
|
||||
turned down to nothing would still be sitting under a dark wash. */}
|
||||
@@ -507,6 +510,9 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
/>
|
||||
<span className="now">{pct(look.wallpaper.focusY)}</span>
|
||||
</label>
|
||||
{(look.wallpaper.audios?.length || look.wallpaper.audio) && (
|
||||
<WallpaperVolumeSlider volume={look.wallpaper.volume} onChange={setPaper} />
|
||||
)}
|
||||
{crop.known && !(crop.x && crop.y) && (
|
||||
<p className="note">
|
||||
{crop.x
|
||||
|
||||
@@ -29,7 +29,6 @@ interface Props {
|
||||
status: SessionStatus | null;
|
||||
title?: string;
|
||||
steer: number;
|
||||
onSettings: (section?: string) => void;
|
||||
account: AccountState | null;
|
||||
theme: string;
|
||||
onTheme: () => void;
|
||||
@@ -44,7 +43,7 @@ interface Props {
|
||||
onError?: (e: unknown) => void;
|
||||
}
|
||||
|
||||
export function Chrome({ port, status, title, steer, onSettings, account, host, theme, onTheme, onFind, view, rows, surfaces, onView, hub, onError }: Props) {
|
||||
export function Chrome({ port, status, title, steer, account, host, theme, onTheme, onFind, view, rows, surfaces, onView, hub, onError }: Props) {
|
||||
const root = status?.workspaceRoot || status?.cwd || "";
|
||||
const project = root ? base(root) : "—";
|
||||
// The folder mark opens the project itself. Only a shell that can enter a
|
||||
@@ -149,9 +148,6 @@ export function Chrome({ port, status, title, steer, onSettings, account, host,
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="2.7" /><path d="M8 1.7v1.4M8 12.9v1.4M1.7 8h1.4M12.9 8h1.4M3.5 3.5l1 1M11.5 11.5l1 1M3.5 12.5l1-1M11.5 4.5l1-1" /></svg>
|
||||
)}
|
||||
</TooltipButton>
|
||||
<TooltipButton className="top-action" data-action="settings.section" data-value="tools" onClick={() => onSettings("tools")} aria-label={t("沙盒")} title={t("沙盒与运行边界")}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m8 2.5 5 2.7v5.6l-5 2.7-5-2.7V5.2Z" /><path d="m3 5.2 5 2.7 5-2.7M8 7.9v5.6" /></svg><span>{t("沙盒")}</span>
|
||||
</TooltipButton>
|
||||
{port && <ViewSwitch view={view} rows={rows} surfaces={surfaces} onView={onView} />}
|
||||
</div>
|
||||
<span className="account-presence" title={account?.signedIn ? account.user?.label : t("未登录")} />
|
||||
|
||||
@@ -55,11 +55,12 @@ const highlight = HighlightStyle.define([
|
||||
{ tag: [tags.keyword, tags.tagName, tags.modifier, tags.operatorKeyword], color: "var(--syn-key)" },
|
||||
{ tag: [tags.string, tags.regexp, tags.special(tags.string), tags.attributeValue], color: "var(--syn-str)" },
|
||||
{ tag: [tags.number, tags.bool, tags.null, tags.atom], color: "var(--syn-num)" },
|
||||
{
|
||||
tag: [tags.function(tags.variableName), tags.function(tags.propertyName), tags.typeName, tags.className],
|
||||
color: "var(--syn-fn)",
|
||||
},
|
||||
{ tag: [tags.comment, tags.meta], color: "var(--faint)", fontStyle: "italic" },
|
||||
{ tag: [tags.function(tags.variableName), tags.function(tags.propertyName)], color: "var(--syn-fn)" },
|
||||
{ tag: [tags.typeName, tags.className], color: "var(--syn-type, var(--syn-fn))" },
|
||||
{ tag: [tags.comment, tags.meta], color: "var(--syn-comment, var(--faint))", fontStyle: "italic" },
|
||||
{ tag: [tags.operator, tags.compareOperator, tags.arithmeticOperator, tags.logicOperator, tags.bitwiseOperator, tags.updateOperator, tags.definitionOperator, tags.derefOperator, tags.controlOperator], color: "var(--syn-op, var(--text))" },
|
||||
{ tag: [tags.punctuation, tags.separator, tags.bracket, tags.angleBracket, tags.squareBracket, tags.paren, tags.brace], color: "var(--syn-punc, var(--text))" },
|
||||
{ tag: [tags.variableName, tags.propertyName, tags.definition(tags.variableName)], color: "var(--syn-var, var(--text))" },
|
||||
{ tag: tags.heading, fontWeight: "bold" },
|
||||
{ tag: tags.link, textDecoration: "underline" },
|
||||
]);
|
||||
|
||||
@@ -247,7 +247,8 @@ describe("screenshots", () => {
|
||||
const node = target === "heading" ? document.querySelector("#fbk-title")! : document.activeElement!;
|
||||
fireEvent.paste(node, { clipboardData: { files: [png("dialog.png")] } });
|
||||
expect(await screen.findByAltText("dialog.png")).toBeTruthy();
|
||||
expect(heldDraft().shots.map((s) => s.name)).toEqual(["dialog.png"]);
|
||||
// The draft is held from an effect, which can run after the thumbnail is on screen.
|
||||
await waitFor(() => expect(heldDraft().shots.map((s) => s.name)).toEqual(["dialog.png"]));
|
||||
});
|
||||
|
||||
it("does not route a paste on My feedback into the hidden report", async () => {
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, renderHook } from "@testing-library/react";
|
||||
import "./testkit";
|
||||
import { barsFit, flowCeiling, flowFloor, flowMin, FlowGrips, useColumn, type Column } from "./FlowResizer";
|
||||
|
||||
const RAIL_MIN = 232;
|
||||
const DOCK_MIN = 320;
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("the reading column's two ends", () => {
|
||||
it("holds the readable measure as the floor and the window as the ceiling", () => {
|
||||
// 1600px window, both sidebars at their floors: 1048px is left for the text,
|
||||
// which is more than the readable measure, so 760 holds the floor.
|
||||
expect(flowCeiling(1600, RAIL_MIN, DOCK_MIN)).toBe(1048);
|
||||
expect(flowFloor(1600, RAIL_MIN, DOCK_MIN)).toBe(760);
|
||||
|
||||
// A wide window leaves more still; the ceiling follows it, the floor does not.
|
||||
expect(flowCeiling(2400, RAIL_MIN, DOCK_MIN)).toBe(1848);
|
||||
expect(flowFloor(2400, RAIL_MIN, DOCK_MIN)).toBe(760);
|
||||
|
||||
// On a window too small to hold the readable measure, the window becomes the
|
||||
// floor rather than a promise of room the layout cannot hand over.
|
||||
expect(flowFloor(1200, RAIL_MIN, DOCK_MIN)).toBe(648);
|
||||
});
|
||||
|
||||
it("never reports a floor above its ceiling, however narrow the window", () => {
|
||||
for (const room of [900, 1200, 1600, 2400, 3440]) {
|
||||
expect(flowFloor(room, RAIL_MIN, DOCK_MIN)).toBeLessThanOrEqual(flowCeiling(room, RAIL_MIN, DOCK_MIN));
|
||||
}
|
||||
});
|
||||
|
||||
it("lets a hand go narrower than the readable measure, down to 640", () => {
|
||||
expect(flowMin(1600, RAIL_MIN, DOCK_MIN)).toBe(640);
|
||||
expect(flowMin(2400, RAIL_MIN, DOCK_MIN)).toBe(640);
|
||||
// On a window whose floor is already below 640, the floor wins.
|
||||
expect(flowMin(1100, RAIL_MIN, DOCK_MIN)).toBe(flowFloor(1100, RAIL_MIN, DOCK_MIN));
|
||||
for (const room of [400, 900, 1200, 1600, 2400]) {
|
||||
expect(flowMin(room, RAIL_MIN, DOCK_MIN)).toBeLessThanOrEqual(flowFloor(room, RAIL_MIN, DOCK_MIN));
|
||||
}
|
||||
});
|
||||
|
||||
it("never reports a ceiling below zero", () => {
|
||||
expect(flowCeiling(400, RAIL_MIN, DOCK_MIN)).toBe(0);
|
||||
expect(flowFloor(400, RAIL_MIN, DOCK_MIN)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the two sidebars making room for the column", () => {
|
||||
it("leaves them alone while there is room for both", () => {
|
||||
expect(barsFit(2400, 760, 316, 316, RAIL_MIN, DOCK_MIN)).toEqual({ rail: 316, dock: 316 });
|
||||
});
|
||||
|
||||
it("pins the rail at its floor rather than growing the other past its setting", () => {
|
||||
// 1300 - 760 leaves 540, under the 552 the two floors need. The rail is
|
||||
// pinned at its own floor; the workbench keeps the 316 it was set to rather
|
||||
// than growing into the room the rail gave up.
|
||||
const { rail, dock } = barsFit(1300, 760, 316, 316, RAIL_MIN, DOCK_MIN);
|
||||
expect(rail).toBe(RAIL_MIN);
|
||||
expect(dock).toBe(316);
|
||||
expect(rail + dock).toBeLessThan(316 + 316);
|
||||
});
|
||||
|
||||
it("never narrows a sidebar past its own floor, and never past its setting", () => {
|
||||
for (const room of [1200, 1300, 1600, 2000, 2400]) {
|
||||
for (const ask of [760, 1000, 1400, 1800]) {
|
||||
const { rail, dock } = barsFit(room, ask, 400, 340, RAIL_MIN, DOCK_MIN);
|
||||
expect(rail).toBeGreaterThanOrEqual(RAIL_MIN);
|
||||
expect(dock).toBeGreaterThanOrEqual(DOCK_MIN);
|
||||
expect(rail).toBeLessThanOrEqual(400);
|
||||
expect(dock).toBeLessThanOrEqual(340);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("recomputes from the settings, so narrowing hands the same room back", () => {
|
||||
// Derived from the settings on every call, never from the previous result:
|
||||
// that is what makes this reversible and keeps either sidebar from creeping
|
||||
// wider than the width it was set to.
|
||||
const squeezed = barsFit(1350, 760, 316, 316, RAIL_MIN, DOCK_MIN);
|
||||
const released = barsFit(2400, 760, 316, 316, RAIL_MIN, DOCK_MIN);
|
||||
expect(squeezed.rail + squeezed.dock).toBeLessThan(632);
|
||||
expect(released).toEqual({ rail: 316, dock: 316 });
|
||||
expect(barsFit(2400, 760, 316, 316, RAIL_MIN, DOCK_MIN)).toEqual(released);
|
||||
});
|
||||
|
||||
it("answers for a width a hand is still holding, not only a committed one", () => {
|
||||
// A drag asks this on every frame. Committing on release only is what let
|
||||
// the column grow under sidebars that had not moved yet, so the answer has
|
||||
// to exist before anything is stored.
|
||||
localStorage.clear();
|
||||
const { result } = renderHook(() => useColumn(316, 400, RAIL_MIN, DOCK_MIN));
|
||||
const bars = result.current.fit(result.current.ceiling);
|
||||
expect(bars.rail).toBeGreaterThanOrEqual(RAIL_MIN);
|
||||
expect(bars.dock).toBeGreaterThanOrEqual(DOCK_MIN);
|
||||
expect(bars.rail + bars.dock).toBeLessThan(316 + 400);
|
||||
// The same rule the committed numbers obey, so committing cannot jump.
|
||||
expect(bars).toEqual(barsFit(document.body.clientWidth, result.current.ceiling, 316, 400, RAIL_MIN, DOCK_MIN));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the column's remembered width", () => {
|
||||
it("starts at the floor when nothing has been dragged", () => {
|
||||
localStorage.clear();
|
||||
const { result } = renderHook(() => useColumn(316, 316, RAIL_MIN, DOCK_MIN));
|
||||
expect(result.current.dragged).toBe(0);
|
||||
expect(result.current.width).toBe(result.current.floor);
|
||||
// Untouched means untouched: no inline variable outranks the measure setting.
|
||||
expect(result.current.rail).toBe(316);
|
||||
expect(result.current.dock).toBe(316);
|
||||
});
|
||||
|
||||
it("remembers what a hand set, and holds it between the two ends", () => {
|
||||
localStorage.clear();
|
||||
const { result } = renderHook(() => useColumn(316, 316, RAIL_MIN, DOCK_MIN));
|
||||
act(() => result.current.commit(5000));
|
||||
// The number is kept as it was asked for...
|
||||
expect(result.current.dragged).toBe(5000);
|
||||
expect(localStorage.getItem("rx-flow-w")).toBe("5000");
|
||||
// ...while what is drawn stays inside the range this window allows.
|
||||
expect(result.current.width).toBeGreaterThanOrEqual(result.current.floor);
|
||||
expect(result.current.width).toBeLessThanOrEqual(result.current.ceiling);
|
||||
});
|
||||
|
||||
it("keeps a remembered width below the narrowest from shrinking the column", () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem("rx-flow-w", "100");
|
||||
const { result } = renderHook(() => useColumn(316, 316, RAIL_MIN, DOCK_MIN));
|
||||
expect(result.current.width).toBe(result.current.min);
|
||||
// Asked for, still remembered — the clamp is about what is drawn.
|
||||
expect(result.current.dragged).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a drag that ends without a release", () => {
|
||||
const into = (over: Partial<Column> = {}) => {
|
||||
const box = document.createElement("div");
|
||||
box.className = "app";
|
||||
document.body.appendChild(box);
|
||||
const column: Column = {
|
||||
width: 760, floor: 760, min: 640, ceiling: 1048, rail: 316, dock: 316, dragged: 0,
|
||||
commit: () => {},
|
||||
fit: (w) => barsFit(2400, w, 316, 316, RAIL_MIN, DOCK_MIN),
|
||||
...over,
|
||||
};
|
||||
render(<FlowGrips column={column} />, { container: box });
|
||||
return box;
|
||||
};
|
||||
|
||||
const grip = (box: HTMLElement) => {
|
||||
const el = box.querySelector(".flowgrip-l") as HTMLElement;
|
||||
el.setPointerCapture = () => {};
|
||||
fireEvent.pointerDown(el, { button: 0, clientX: 400, pointerId: 1 });
|
||||
return el;
|
||||
};
|
||||
|
||||
it("leaves no width behind when a first drag is cancelled", () => {
|
||||
// `App` writes `--flow-w` on `.app` only once a width has been committed.
|
||||
// A cancelled first drag must therefore take the value away again: setting
|
||||
// it back would leave a pixel width nobody owns, and the column would stay
|
||||
// pinned to it while the window changed around it.
|
||||
const box = into({ dragged: 0 });
|
||||
const el = grip(box);
|
||||
box.style.setProperty("--flow-w", "900px");
|
||||
fireEvent.pointerCancel(el, { pointerId: 1 });
|
||||
expect(box.style.getPropertyValue("--flow-w")).toBe("");
|
||||
// The sidebars are owned by `App` from the start, so those come back.
|
||||
expect(box.style.getPropertyValue("--rail-open")).toBe("316px");
|
||||
});
|
||||
|
||||
it("puts a committed width back when a later drag is cancelled", () => {
|
||||
const box = into({ dragged: 900, width: 900 });
|
||||
const el = grip(box);
|
||||
box.style.setProperty("--flow-w", "980px");
|
||||
fireEvent.pointerCancel(el, { pointerId: 1 });
|
||||
expect(box.style.getPropertyValue("--flow-w")).toBe("900px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the keys walk the same path the hand does", () => {
|
||||
const column = (over: Partial<Column> = {}): Column => ({
|
||||
width: 900, floor: 760, min: 640, ceiling: 1048, rail: 300, dock: 400, dragged: 900,
|
||||
commit: () => {},
|
||||
fit: (w) => barsFit(2400, w, 300, 400, RAIL_MIN, DOCK_MIN),
|
||||
...over,
|
||||
});
|
||||
|
||||
const press = (edge: "l" | "r", key: string, shiftKey = false, from = 900) => {
|
||||
const seen: number[] = [];
|
||||
const { container, unmount } = render(
|
||||
<FlowGrips column={column({ width: from, commit: (w) => seen.push(w) })} />,
|
||||
);
|
||||
fireEvent.keyDown(container.querySelector(`.flowgrip-${edge}`)!, { key, shiftKey });
|
||||
unmount();
|
||||
return seen;
|
||||
};
|
||||
|
||||
it("widens towards its own side on both handles", () => {
|
||||
// Left handle, ArrowLeft: the edge travels away from the text, so it grows.
|
||||
expect(press("l", "ArrowLeft")).toEqual([912]);
|
||||
expect(press("l", "ArrowRight")).toEqual([888]);
|
||||
// The right handle mirrors it — the same outward direction, the same result.
|
||||
expect(press("r", "ArrowRight")).toEqual([912]);
|
||||
expect(press("r", "ArrowLeft")).toEqual([888]);
|
||||
});
|
||||
|
||||
it("takes a longer step with Shift and jumps to the floor with Home", () => {
|
||||
expect(press("l", "ArrowLeft", true)).toEqual([948]);
|
||||
expect(press("l", "Home")).toEqual([760]);
|
||||
});
|
||||
|
||||
it("asks for nothing at either end rather than committing what it cannot reach", () => {
|
||||
// Already as wide as the window allows: widening again commits nothing.
|
||||
expect(press("l", "ArrowLeft", false, 1048)).toEqual([]);
|
||||
// Already at the narrowest: narrowing again commits nothing.
|
||||
expect(press("l", "ArrowRight", false, 640)).toEqual([]);
|
||||
// The readable measure is no longer the end of the road.
|
||||
expect(press("l", "ArrowRight", false, 760)).toEqual([748]);
|
||||
});
|
||||
});
|
||||
@@ -1,324 +0,0 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { onRoomWidth } from "./viewport";
|
||||
|
||||
/** The width the "readable" measure names, in pixels.
|
||||
*
|
||||
* The stylesheet spells the same number as `--column`, which `--doc` reads
|
||||
* through `min(100%, …)`: on a window too narrow for it the window wins, which
|
||||
* is why the floor below is computed against the ceiling rather than used on
|
||||
* its own. */
|
||||
const STANDARD = 760;
|
||||
|
||||
/** The narrowest a hand may draw the column. It sits below the readable measure
|
||||
* so the width is continuous rather than two presets with a gap between them;
|
||||
* the readable measure stays the default and what Home returns to. */
|
||||
const NARROWEST = 640;
|
||||
|
||||
/** Where the dragged width is remembered. It belongs to this machine's screen,
|
||||
* like the two sidebars' own widths, so it is stored beside them rather than in
|
||||
* the kernel's config. */
|
||||
export const FLOW_KEY = "rx-flow-w";
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||||
|
||||
/** What the column may grow to: the window, less the room the two sidebars need
|
||||
* at their narrowest. Past that there is nothing left to give, and on a window
|
||||
* too small to hold even that, the ceiling falls with it rather than promising
|
||||
* room the layout cannot hand over. */
|
||||
export const flowCeiling = (room: number, railMin: number, dockMin: number) =>
|
||||
Math.max(0, Math.round(room - railMin - dockMin));
|
||||
|
||||
/** What the column may shrink to: the readable measure, or the whole window when
|
||||
* the window is the smaller of the two. A drag runs between this and the
|
||||
* ceiling, and neither end depends on where the column currently sits — so a
|
||||
* column drawn at full width can still be pulled back to the readable measure,
|
||||
* and one at the readable measure can still be pulled out. */
|
||||
export const flowFloor = (room: number, railMin: number, dockMin: number) =>
|
||||
Math.min(STANDARD, flowCeiling(room, railMin, dockMin));
|
||||
|
||||
/** What a hand may shrink the column to: `NARROWEST`, or the floor when the
|
||||
* window is already narrower than that. */
|
||||
export const flowMin = (room: number, railMin: number, dockMin: number) =>
|
||||
Math.min(NARROWEST, flowFloor(room, railMin, dockMin));
|
||||
|
||||
/** How wide each sidebar is drawn while the column asks for `ask`.
|
||||
*
|
||||
* They give way in proportion to the widths they were set to, down to their own
|
||||
* floors and no further. Narrowing the column again hands the room back in that
|
||||
* same proportion — which is why neither ends up wider than its setting: the
|
||||
* proportions are recomputed from the settings each time, never from the last
|
||||
* width drawn. */
|
||||
export function barsFit(room: number, ask: number, rail: number, dock: number, railMin: number, dockMin: number) {
|
||||
const want = rail + dock;
|
||||
const left = Math.max(railMin + dockMin, Math.round(room - ask));
|
||||
if (want <= left) return { rail, dock };
|
||||
// Both are clamped to what they were set to as well as to their floors: taking
|
||||
// room from one must never hand the other more than its own setting.
|
||||
const railFit = clamp(Math.round((left * rail) / want), railMin, Math.min(rail, left - dockMin));
|
||||
return { rail: railFit, dock: Math.min(dock, Math.max(dockMin, left - railFit)) };
|
||||
}
|
||||
|
||||
/** The column's two ends, and what the sidebars are drawn at once it asks. */
|
||||
export interface Column {
|
||||
width: number;
|
||||
/** The readable measure: what an untouched window draws and Home returns to. */
|
||||
floor: number;
|
||||
/** The narrowest a drag or key press may reach. */
|
||||
min: number;
|
||||
ceiling: number;
|
||||
rail: number;
|
||||
dock: number;
|
||||
/** Absent until a hand has moved the column: an untouched window keeps
|
||||
* whatever the measure setting asks for, and that setting is read by the
|
||||
* stylesheet, not by this number. */
|
||||
dragged: number;
|
||||
commit: (w: number) => void;
|
||||
/** How wide the sidebars are drawn while the column asks for `w`.
|
||||
*
|
||||
* The same rule as `rail`/`dock`, but for a width a hand is still moving:
|
||||
* a drag has to ask this on every frame, because the committed numbers only
|
||||
* catch up when the pointer is released — and until they do, the column
|
||||
* would grow into room the sidebars are still occupying. */
|
||||
fit: (w: number) => { rail: number; dock: number };
|
||||
}
|
||||
|
||||
/** Everything the reading column's width is made of: the number a hand set, the
|
||||
* two ends it is held between, and how much room that leaves the sidebars.
|
||||
*
|
||||
* It lives here rather than in the window so the window stays a window: this is
|
||||
* one feature's whole state, and the rules it obeys are the three pure
|
||||
* functions above it. */
|
||||
export function useColumn(railW: number, dockW: number, railMin: number, dockMin: number): Column {
|
||||
const [dragged, setDragged] = useState(() => {
|
||||
const saved = Number(localStorage.getItem(FLOW_KEY));
|
||||
return Number.isFinite(saved) && saved > 0 ? saved : 0;
|
||||
});
|
||||
const [room, setRoom] = useState(() => document.body.clientWidth);
|
||||
useEffect(() => onRoomWidth(setRoom), []);
|
||||
|
||||
const floor = flowFloor(room, railMin, dockMin);
|
||||
const min = flowMin(room, railMin, dockMin);
|
||||
const ceiling = flowCeiling(room, railMin, dockMin);
|
||||
// A window that shrank since the drag may leave the remembered number outside
|
||||
// the range it is now allowed; the drawn column, and the sidebars beside it,
|
||||
// both follow what is allowed rather than what was once asked for.
|
||||
const width = dragged > 0 ? clamp(dragged, min, ceiling) : floor;
|
||||
const bars = dragged > 0 ? barsFit(room, width, railW, dockW, railMin, dockMin) : { rail: railW, dock: dockW };
|
||||
|
||||
const commit = useCallback((w: number) => {
|
||||
setDragged(w);
|
||||
localStorage.setItem(FLOW_KEY, String(Math.round(w)));
|
||||
}, []);
|
||||
|
||||
// The same rule the committed numbers above obey, asked for a width the
|
||||
// pointer is still holding. A drag reads this on every frame: committing on
|
||||
// release only is what let the column run under sidebars that had not moved
|
||||
// yet, so the room was taken from nothing.
|
||||
const fit = useCallback(
|
||||
(w: number) => barsFit(room, w, railW, dockW, railMin, dockMin),
|
||||
[room, railW, dockW, railMin, dockMin],
|
||||
);
|
||||
|
||||
// The same object for as long as the numbers are the same. Every pane is
|
||||
// memoised on its props, so a fresh object each render would look like news to
|
||||
// all of them and re-draw every transcript in the window on an unrelated
|
||||
// state change — the one thing the pane memo exists to prevent.
|
||||
return useMemo(
|
||||
() => ({ width, floor, min, ceiling, rail: bars.rail, dock: bars.dock, dragged, commit, fit }),
|
||||
[width, floor, min, ceiling, bars.rail, bars.dock, dragged, commit, fit],
|
||||
);
|
||||
}
|
||||
|
||||
/** The two edges, as things a pointer can take hold of.
|
||||
*
|
||||
* They are placed against the column rather than by repeating its width rule
|
||||
* here: that rule depends on what the scroller reserves for the scrollbar, and a
|
||||
* copy of it would drift from the original the first time either changed.
|
||||
*
|
||||
* The box to measure against is this element's own parent, not a ref handed
|
||||
* down: a child's layout effects run before its parent's ref is attached, so
|
||||
* asking the parent through a ref would read null and never try again. */
|
||||
export function FlowGrips({ column }: { column: Column }) {
|
||||
const grips = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const box = grips.current;
|
||||
const outer = box?.parentElement;
|
||||
if (!box || !outer) return;
|
||||
const place = () => {
|
||||
const flow = outer.querySelector(".flow");
|
||||
if (!flow) return;
|
||||
const a = outer.getBoundingClientRect();
|
||||
const b = flow.getBoundingClientRect();
|
||||
box.style.setProperty("--grip-left", `${Math.round(b.left - a.left)}px`);
|
||||
box.style.setProperty("--grip-right", `${Math.round(a.right - b.right)}px`);
|
||||
};
|
||||
place();
|
||||
const ro = new ResizeObserver(place);
|
||||
ro.observe(outer);
|
||||
const flow = outer.querySelector(".flow");
|
||||
if (flow) ro.observe(flow);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flowgrips" ref={grips}>
|
||||
{(["l", "r"] as const).map((edge) => (
|
||||
<FlowGrip key={edge} edge={edge} column={column} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Put a width on the window while a hand still holds the edge: the column
|
||||
* itself, and the two sidebars giving way in proportion to what they were set
|
||||
* to.
|
||||
*
|
||||
* Everything is written to the `.app` element, never to `:root`. Once a width
|
||||
* has been committed, `App` keeps `--flow-w` on `.app` inline, and an inherited
|
||||
* `:root` value can never outrank its own descendant — which is why every drag
|
||||
* after the first one moved nothing at all. */
|
||||
function paint(app: HTMLElement | null, w: number, column: Column) {
|
||||
const bars = column.fit(w);
|
||||
const target = app ?? document.documentElement;
|
||||
target.style.setProperty("--flow-w", `${w}px`);
|
||||
target.style.setProperty("--rail-open", `${bars.rail}px`);
|
||||
target.style.setProperty("--dock-open", `${bars.dock}px`);
|
||||
}
|
||||
|
||||
function FlowGrip({ edge, column }: { edge: "l" | "r"; column: Column }) {
|
||||
// The drag's own state, kept out of React so a pointer that moves sixty times
|
||||
// a second does not re-render the transcript behind it.
|
||||
const drag = useRef<{ x0: number; start: number; pending: number; app: HTMLElement | null } | null>(null);
|
||||
const raf = useRef(0);
|
||||
|
||||
const down = useCallback(
|
||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
drag.current = {
|
||||
x0: e.clientX,
|
||||
start: column.width,
|
||||
pending: column.width,
|
||||
app: e.currentTarget.closest(".app") as HTMLElement | null,
|
||||
};
|
||||
document.body.dataset.flowdrag = edge;
|
||||
},
|
||||
[edge, column.width],
|
||||
);
|
||||
|
||||
const move = useCallback(
|
||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const d = drag.current;
|
||||
if (!d) return;
|
||||
const dx = e.clientX - d.x0;
|
||||
// Both edges move the same number, so the column grows at both ends and
|
||||
// the pointer travels half of what it gains.
|
||||
d.pending = clamp(d.start + (edge === "l" ? -dx : dx) * 2, column.min, column.ceiling);
|
||||
if (!raf.current) {
|
||||
raf.current = requestAnimationFrame(() => {
|
||||
raf.current = 0;
|
||||
const cur = drag.current;
|
||||
if (cur) paint(cur.app, cur.pending, column);
|
||||
});
|
||||
}
|
||||
},
|
||||
[edge, column],
|
||||
);
|
||||
|
||||
// Everything a drag leaves behind in the document, undone in one place: the
|
||||
// pending frame and the flag the stylesheet reads to turn every cursor into a
|
||||
// resize one. A drag that ends any way at all has to pass through this, or the
|
||||
// window keeps a col-resize cursor over everything with no way to clear it.
|
||||
const stop = useCallback(() => {
|
||||
cancelAnimationFrame(raf.current);
|
||||
raf.current = 0;
|
||||
drag.current = null;
|
||||
delete document.body.dataset.flowdrag;
|
||||
}, []);
|
||||
|
||||
const up = useCallback(() => {
|
||||
const d = drag.current;
|
||||
if (!d) return;
|
||||
const wanted = d.pending;
|
||||
// Leave the numbers where the hand left them: the commit below re-renders
|
||||
// with the same values, and the frame in between must not fall back to the
|
||||
// width the column started at.
|
||||
paint(d.app, wanted, column);
|
||||
stop();
|
||||
column.commit(wanted);
|
||||
}, [column, stop]);
|
||||
|
||||
// The drag never committed, so the width the reader let go of is dropped: the
|
||||
// column and both sidebars go back to the numbers React is holding.
|
||||
const cancel = useCallback(() => {
|
||||
const d = drag.current;
|
||||
if (!d) return;
|
||||
if (d.app) {
|
||||
// A width that was never committed has no owner on `.app`: `App` writes
|
||||
// `--flow-w` itself only once something has been dragged. Putting a pixel
|
||||
// value back here would leave it owned by nobody, so React would never
|
||||
// remove it and the column would stay pinned to that width while the
|
||||
// window was free to change around it.
|
||||
if (column.dragged > 0) d.app.style.setProperty("--flow-w", `${column.width}px`);
|
||||
else d.app.style.removeProperty("--flow-w");
|
||||
d.app.style.setProperty("--rail-open", `${column.rail}px`);
|
||||
d.app.style.setProperty("--dock-open", `${column.dock}px`);
|
||||
}
|
||||
stop();
|
||||
}, [column, stop]);
|
||||
|
||||
// Switching conversation, closing the tab or collapsing the pane mid-drag
|
||||
// unmounts this element with the pointer still down. Nothing would arrive to
|
||||
// release it, so the cleanup does what the release would have: the width it
|
||||
// had reached is dropped rather than committed — the reader never let go.
|
||||
useEffect(() => cancel, [cancel]);
|
||||
|
||||
// The keys walk the same path the hand does: an arrow towards this edge's own
|
||||
// side of the window widens the column, so the left handle's ArrowLeft and the
|
||||
// right handle's ArrowRight both mean "wider". Reversing it per handle would
|
||||
// make one of the two teach the opposite of what dragging it does.
|
||||
const key = useCallback(
|
||||
(e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const dir = e.key === "ArrowLeft" ? -1 : e.key === "ArrowRight" ? 1 : 0;
|
||||
if (dir) {
|
||||
e.preventDefault();
|
||||
const grow = edge === "l" ? -dir : dir;
|
||||
const next = clamp(column.width + grow * (e.shiftKey ? 48 : 12), column.min, column.ceiling);
|
||||
if (next !== column.width) column.commit(next);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Home") {
|
||||
e.preventDefault();
|
||||
column.commit(column.floor);
|
||||
}
|
||||
},
|
||||
[edge, column],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flowgrip flowgrip-${edge}`}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t("调整正文字宽")}
|
||||
aria-valuenow={Math.round(column.width)}
|
||||
aria-valuemin={column.min}
|
||||
aria-valuemax={column.ceiling}
|
||||
tabIndex={0}
|
||||
data-action-pointerdown="appearance.reading-width"
|
||||
data-action-pointermove="appearance.reading-width"
|
||||
data-action-pointerup="appearance.reading-width"
|
||||
data-action-pointercancel="appearance.reading-width"
|
||||
data-action-keydown="appearance.reading-width"
|
||||
onKeyDown={key}
|
||||
onPointerDown={down}
|
||||
onPointerMove={move}
|
||||
onPointerUp={up}
|
||||
onPointerCancel={cancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { FOLD_CHOICES, FOLD_DEFAULTS, setFoldModes, type Fold, type FoldMode, type FoldModes } from "../state/prefs";
|
||||
import { startsOpen, useFoldModes } from "../state/foldpref";
|
||||
import { FOLD_CHOICES, FOLD_DEFAULTS, setFoldModes, setShowsThinkingPreview, type Fold, type FoldMode, type FoldModes } from "../state/prefs";
|
||||
import { startsOpen, useFoldModes, useThinkingPreview } from "../state/foldpref";
|
||||
import { ApplyNote } from "./Group";
|
||||
import { Switch } from "./Switch";
|
||||
|
||||
const PARTS: [Fold, string, string][] = [
|
||||
["thinking", "思考过程", "模型作答前的推理"],
|
||||
@@ -14,7 +15,6 @@ const PARTS: [Fold, string, string][] = [
|
||||
|
||||
const MODE: Record<FoldMode, string> = {
|
||||
folded: "收起",
|
||||
peek: "收起并预览首句",
|
||||
live: "进行中展开",
|
||||
failed: "失败时展开",
|
||||
changed: "改动时展开",
|
||||
@@ -37,6 +37,7 @@ const same = (a: FoldModes, b: FoldModes) => PARTS.every(([k]) => a[k] === b[k])
|
||||
* reader has already opened or closed keeps that choice. */
|
||||
export function Folding() {
|
||||
const modes = useFoldModes();
|
||||
const thinkingPreview = useThinkingPreview();
|
||||
const [hot, setHot] = useState<Fold | null>(null);
|
||||
const [live, setLive] = useState(false);
|
||||
const preset = PRESETS.find(([, , p]) => same(p, modes))?.[0];
|
||||
@@ -57,25 +58,37 @@ export function Folding() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FoldPreview modes={modes} hot={hot} live={live} onLive={setLive} />
|
||||
<FoldPreview modes={modes} thinkingPreview={thinkingPreview} hot={hot} live={live} onLive={setLive} />
|
||||
|
||||
<div className="grp-items fold-rows" onMouseLeave={() => setHot(null)}>
|
||||
{PARTS.map(([kind, label, why]) => (
|
||||
<div key={kind} className="fold-row" data-hot={hot === kind ? "" : undefined}
|
||||
onMouseEnter={() => setHot(kind)} onFocus={() => setHot(kind)}>
|
||||
<span className="tx">
|
||||
<span className="lb">{t(label)}</span>
|
||||
<span className="ds">{t(why)}</span>
|
||||
</span>
|
||||
<div className="seg" data-text role="radiogroup" aria-label={t(label)}>
|
||||
{FOLD_CHOICES[kind].map((mode) => (
|
||||
<button key={mode} role="radio" data-action="transcript.fold" data-value={`${kind}:${mode}`}
|
||||
aria-checked={modes[kind] === mode} onClick={() => setFoldModes({ [kind]: mode })}>
|
||||
{t((kind === "output" && OUTPUT_MODE[mode]) || MODE[mode])}
|
||||
</button>
|
||||
))}
|
||||
<Fragment key={kind}>
|
||||
<div className="fold-row" data-hot={hot === kind ? "" : undefined}
|
||||
onMouseEnter={() => setHot(kind)} onFocus={() => setHot(kind)}>
|
||||
<span className="tx">
|
||||
<span className="lb">{t(label)}</span>
|
||||
<span className="ds">{t(why)}</span>
|
||||
</span>
|
||||
<div className="seg" data-text role="radiogroup" aria-label={t(label)}>
|
||||
{FOLD_CHOICES[kind].map((mode) => (
|
||||
<button key={mode} role="radio" data-action="transcript.fold" data-value={`${kind}:${mode}`}
|
||||
aria-checked={modes[kind] === mode} onClick={() => setFoldModes({ [kind]: mode })}>
|
||||
{t((kind === "output" && OUTPUT_MODE[mode]) || MODE[mode])}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{kind === "thinking" && (
|
||||
<div className="lrow" onMouseEnter={() => setHot(kind)} onFocus={() => setHot(kind)}>
|
||||
<span className="tx">
|
||||
<span className="lb">{t("收起时预览思考开头")}</span>
|
||||
<span className="ds">{t("在思考标题下方显示一行开头文字;思考进行中也显示,展开后隐藏。")}</span>
|
||||
</span>
|
||||
<Switch data-action="transcript.thinking-preview" on={thinkingPreview} label={t("收起时预览思考开头")}
|
||||
onClick={() => setShowsThinkingPreview(!thinkingPreview)} />
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<ApplyNote id="folding" />
|
||||
@@ -85,7 +98,7 @@ export function Folding() {
|
||||
|
||||
// A schematic turn rather than real cards: it has to show a failed step and a
|
||||
// running one on demand, which no real conversation holds still for.
|
||||
function FoldPreview({ modes, hot, live, onLive }: { modes: FoldModes; hot: Fold | null; live: boolean; onLive: (v: boolean) => void }) {
|
||||
function FoldPreview({ modes, thinkingPreview, hot, live, onLive }: { modes: FoldModes; thinkingPreview: boolean; hot: Fold | null; live: boolean; onLive: (v: boolean) => void }) {
|
||||
const think = startsOpen(modes.thinking, live);
|
||||
const work = startsOpen(modes.activity, live, false, true);
|
||||
const step = (failed: boolean, changed = false) => startsOpen(modes.steps, false, failed, changed);
|
||||
@@ -104,6 +117,7 @@ function FoldPreview({ modes, hot, live, onLive }: { modes: FoldModes; hot: Fold
|
||||
<div className="fp-block" {...part("thinking")} data-open={think ? "" : undefined}>
|
||||
<span className="fp-sum">{live ? t("思考中…") : t("思考了 6 秒")}</span>
|
||||
{think && <Lines n={2} dim />}
|
||||
{!think && thinkingPreview && <span className="thinking-preview">{t("先检查配置,再决定改哪里。")}</span>}
|
||||
</div>
|
||||
{!live && <Lines n={2} />}
|
||||
<div className="fp-block fp-work" {...part("activity")} data-open={work ? "" : undefined}>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Network } from "./Network";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { NetworkSettings } from "../port/port";
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
||||
|
||||
const initial: NetworkSettings = {
|
||||
mode: "custom", type: "http", server: "proxy.example", port: 8080,
|
||||
effective: "http://proxy.example:8080", endpoint: "https://model.example/v1", hasPassword: true,
|
||||
};
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (why: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function portWith(settings: NetworkSettings = initial) {
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, "network").mockResolvedValue(settings);
|
||||
vi.spyOn(port, "saveNetwork").mockImplementation(async (draft) => ({ ...draft, effective: "direct" }));
|
||||
vi.spyOn(port, "diagnoseNetwork").mockResolvedValue([{ step: "connect", ok: true, detail: "saved route tested", durationMs: 0 }]);
|
||||
return port;
|
||||
}
|
||||
|
||||
const save = () => screen.getByRole<HTMLButtonElement>("button", { name: "保存" });
|
||||
|
||||
it("disables saving again when edits are reverted", async () => {
|
||||
const port = portWith();
|
||||
render(<Network port={port} />);
|
||||
const field = await screen.findByLabelText<HTMLInputElement>("服务器");
|
||||
await userEvent.type(field, ".draft");
|
||||
expect(save().disabled).toBe(false);
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, initial.server!);
|
||||
const clear = screen.getByRole("checkbox", { name: "删除已保存的密码" });
|
||||
await userEvent.click(clear);
|
||||
expect(save().disabled).toBe(false);
|
||||
await userEvent.click(clear);
|
||||
expect(save().disabled).toBe(true);
|
||||
expect(screen.queryByText(/未保存/)).toBeNull();
|
||||
await userEvent.click(save());
|
||||
expect(port.saveNetwork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("distinguishes loading from a failed read", async () => {
|
||||
const port = portWith();
|
||||
const pending = deferred<NetworkSettings>();
|
||||
vi.mocked(port.network).mockReturnValue(pending.promise);
|
||||
render(<Network port={port} />);
|
||||
expect(screen.getByRole("status").textContent).toBe("正在读取网络配置…");
|
||||
await act(async () => pending.reject(new Error("network settings unavailable")));
|
||||
expect(screen.getByRole("alert").textContent).toBe("network settings unavailable");
|
||||
expect(screen.queryByRole("button", { name: "保存" })).toBeNull();
|
||||
});
|
||||
|
||||
it("only saves changed fields and keeps the effective route tied to the saved state", async () => {
|
||||
const port = portWith();
|
||||
render(<Network port={port} />);
|
||||
await screen.findByRole("radio", { name: "直连" });
|
||||
expect(save().disabled).toBe(true);
|
||||
await userEvent.click(screen.getByRole("radio", { name: "直连" }));
|
||||
expect(save().disabled).toBe(false);
|
||||
expect(screen.getByText(initial.effective)).toBeTruthy();
|
||||
expect(screen.getByText(/未保存/)).toBeTruthy();
|
||||
await userEvent.click(screen.getByRole("button", { name: "测试已保存配置" }));
|
||||
expect(port.diagnoseNetwork).toHaveBeenCalledExactlyOnceWith();
|
||||
expect(port.saveNetwork).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText("saved route tested")).toBeTruthy();
|
||||
await userEvent.click(save());
|
||||
await waitFor(() => expect(save().disabled).toBe(true));
|
||||
expect(port.saveNetwork).toHaveBeenCalledWith(expect.objectContaining({ mode: "off" }), "", false);
|
||||
expect(screen.getByText("direct")).toBeTruthy();
|
||||
expect(screen.queryByText("saved route tested")).toBeNull();
|
||||
});
|
||||
|
||||
it("freezes a submitted draft and preserves it after refusal", async () => {
|
||||
const port = portWith();
|
||||
const pending = deferred<NetworkSettings>();
|
||||
vi.mocked(port.saveNetwork).mockReturnValue(pending.promise);
|
||||
render(<Network port={port} />);
|
||||
const field = await screen.findByLabelText<HTMLInputElement>("服务器");
|
||||
await userEvent.type(field, ".new");
|
||||
await userEvent.click(save());
|
||||
expect(field.matches(":disabled")).toBe(true);
|
||||
expect(screen.getByRole("radio", { name: "直连" }).matches(":disabled")).toBe(true);
|
||||
await userEvent.click(screen.getByRole("radio", { name: "直连" }));
|
||||
await userEvent.type(field, "ignored");
|
||||
await act(async () => pending.reject(new Error("disk refused")));
|
||||
expect(await screen.findByText("disk refused")).toBeTruthy();
|
||||
expect(field.value).toBe("proxy.example.new");
|
||||
expect(screen.getByRole("radio", { name: "手动设置" }).getAttribute("aria-checked")).toBe("true");
|
||||
expect(save().disabled).toBe(false);
|
||||
expect(screen.getByText(initial.effective)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lets a draft change during a test without relabelling the tested saved route", async () => {
|
||||
const port = portWith();
|
||||
const pending = deferred<Awaited<ReturnType<typeof port.diagnoseNetwork>>>();
|
||||
vi.mocked(port.diagnoseNetwork).mockReturnValue(pending.promise);
|
||||
render(<Network port={port} />);
|
||||
const field = await screen.findByLabelText<HTMLInputElement>("服务器");
|
||||
await userEvent.click(screen.getByRole("button", { name: "测试已保存配置" }));
|
||||
expect(field.matches(":disabled")).toBe(false);
|
||||
await userEvent.type(field, ".draft");
|
||||
await act(async () => pending.resolve([{ step: "connect", ok: true, detail: "original saved route", durationMs: 0 }]));
|
||||
expect(field.value).toBe("proxy.example.draft");
|
||||
expect(screen.getByText(initial.effective)).toBeTruthy();
|
||||
expect(screen.getByRole("region", { name: "已保存配置的测试结果" }).textContent).toContain("original saved route");
|
||||
expect(save().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps password replacement and explicit deletion distinct", async () => {
|
||||
const port = portWith();
|
||||
render(<Network port={port} />);
|
||||
const clear = await screen.findByRole("checkbox", { name: "删除已保存的密码" });
|
||||
await userEvent.click(clear);
|
||||
expect(save().disabled).toBe(false);
|
||||
await userEvent.click(save());
|
||||
await waitFor(() => expect(save().disabled).toBe(true));
|
||||
expect(port.saveNetwork).toHaveBeenLastCalledWith(expect.anything(), "", true);
|
||||
const password = screen.getByLabelText<HTMLInputElement>("密码(可选)");
|
||||
await userEvent.type(password, "replacement-fixture");
|
||||
await userEvent.click(save());
|
||||
await waitFor(() => expect(password.value).toBe(""));
|
||||
expect(port.saveNetwork).toHaveBeenLastCalledWith(expect.anything(), "replacement-fixture", false);
|
||||
});
|
||||
|
||||
it.each(["load", "save", "diagnose"] as const)("drops an old port's pending %s result", async (operation) => {
|
||||
const a = portWith(), b = portWith({ mode: "off", effective: "B direct" });
|
||||
const pending = deferred<NetworkSettings>();
|
||||
const probe = deferred<Awaited<ReturnType<typeof a.diagnoseNetwork>>>();
|
||||
if (operation === "load") vi.mocked(a.network).mockReturnValue(pending.promise);
|
||||
if (operation === "save") vi.mocked(a.saveNetwork).mockReturnValue(pending.promise);
|
||||
if (operation === "diagnose") vi.mocked(a.diagnoseNetwork).mockReturnValue(probe.promise);
|
||||
const view = render(<Network port={a} />);
|
||||
if (operation !== "load") {
|
||||
await screen.findByRole("radio", { name: "直连" });
|
||||
if (operation === "save") {
|
||||
await userEvent.click(screen.getByRole("radio", { name: "直连" }));
|
||||
await userEvent.click(save());
|
||||
} else await userEvent.click(screen.getByRole("button", { name: "测试已保存配置" }));
|
||||
}
|
||||
view.rerender(<Network port={b} />);
|
||||
await screen.findByText("B direct");
|
||||
await act(async () => {
|
||||
pending.resolve({ ...initial, effective: "A late" });
|
||||
probe.resolve([{ step: "connect", ok: true, detail: "A late probe", durationMs: 0 }]);
|
||||
});
|
||||
expect(screen.queryByText("A late")).toBeNull();
|
||||
expect(screen.queryByText("A late probe")).toBeNull();
|
||||
expect(screen.getByRole("radio", { name: "直连" }).getAttribute("aria-checked")).toBe("true");
|
||||
expect(save().disabled).toBe(true);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Network } from "./Network";
|
||||
import { boot, STORAGE } from "../i18n";
|
||||
import { boot, STORAGE, t } from "../i18n";
|
||||
import type { AgentPort, NetworkProbe } from "../port/port";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -28,7 +28,7 @@ async function run(lang: "zh" | "en") {
|
||||
diagnoseNetwork: async () => probes,
|
||||
} as unknown as AgentPort;
|
||||
render(<Network port={port} />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: lang === "en" ? "Test it" : "测试连接" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: t("测试已保存配置") }));
|
||||
}
|
||||
|
||||
it("words each probe step in English from its code, with the pieces filled in", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { AgentPort, NetworkProbe, NetworkSettings } from "../port/port";
|
||||
import { reason, say } from "../i18n/kernel";
|
||||
@@ -20,49 +20,76 @@ const STEP_LABEL: Record<string, string> = {
|
||||
};
|
||||
|
||||
const TYPES = ["http", "https", "socks5", "socks5h"];
|
||||
const EDITABLE: (keyof NetworkSettings)[] = ["mode", "type", "server", "port", "username", "noProxy"];
|
||||
|
||||
export function Network({ port, host = "" }: { port: AgentPort; host?: string }) {
|
||||
const [net, setNet] = useState<NetworkSettings | null>(null);
|
||||
const [connection, setConnection] = useState({ port, generation: 0 });
|
||||
if (connection.port !== port) setConnection({ port, generation: connection.generation + 1 });
|
||||
return <NetworkInput key={connection.generation} port={port} host={host} />;
|
||||
}
|
||||
|
||||
function NetworkInput({ port, host }: { port: AgentPort; host: string }) {
|
||||
const [settings, setSettings] = useState<{ saved: NetworkSettings; draft: NetworkSettings } | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [clearPassword, setClear] = useState(false);
|
||||
const [probes, setProbes] = useState<NetworkProbe[] | null>(null);
|
||||
const [busy, setBusy] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const live = useRef(false);
|
||||
const pending = useRef<"" | "save" | "test">("");
|
||||
|
||||
useEffect(() => {
|
||||
port.network().then(setNet).catch(() => setNet(null));
|
||||
let current = true;
|
||||
live.current = true;
|
||||
port.network().then((net) => current && setSettings({ saved: net, draft: net }))
|
||||
.catch((e) => current && setError(reason(e)));
|
||||
return () => { current = false; live.current = false; };
|
||||
}, [port]);
|
||||
|
||||
if (!net) return <div className="empty">{t("无法读取网络配置。")}</div>;
|
||||
if (!settings) return <div className="empty" role={error ? "alert" : "status"}>{error || t("正在读取网络配置…")}</div>;
|
||||
const { saved, draft: net } = settings;
|
||||
const changed = EDITABLE.some((key) => net[key] !== saved[key]) || password !== "" || clearPassword;
|
||||
|
||||
const patch = (p: Partial<NetworkSettings>) => setNet({ ...net, ...p });
|
||||
const patch = (p: Partial<NetworkSettings>) => {
|
||||
if (!live.current || pending.current === "save") return;
|
||||
setSettings((current) => current && { ...current, draft: { ...current.draft, ...p } });
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!live.current || pending.current || !changed) return;
|
||||
pending.current = "save";
|
||||
setBusy("save");
|
||||
setError("");
|
||||
try {
|
||||
setNet(await port.saveNetwork(net, password, clearPassword));
|
||||
const result = await port.saveNetwork(net, password, clearPassword);
|
||||
if (!live.current) return;
|
||||
setSettings({ saved: result, draft: result });
|
||||
setPassword("");
|
||||
setClear(false);
|
||||
// The old result described the old settings; keeping it on screen would
|
||||
// claim a path that has not been tested since.
|
||||
setProbes(null);
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
if (live.current) setError(reason(e));
|
||||
} finally {
|
||||
setBusy("");
|
||||
pending.current = "";
|
||||
if (live.current) setBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
const diagnose = async () => {
|
||||
if (!live.current || pending.current) return;
|
||||
pending.current = "test";
|
||||
setBusy("test");
|
||||
setError("");
|
||||
try {
|
||||
setProbes(await port.diagnoseNetwork());
|
||||
const result = await port.diagnoseNetwork();
|
||||
if (live.current) setProbes(result);
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
if (live.current) setError(reason(e));
|
||||
} finally {
|
||||
setBusy("");
|
||||
pending.current = "";
|
||||
if (live.current) setBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,7 +100,7 @@ export function Network({ port, host = "" }: { port: AgentPort; host?: string })
|
||||
)}
|
||||
<div className="seg" data-text role="radiogroup" aria-label={t("代理模式")}>
|
||||
{MODES.map(([id, label]) => (
|
||||
<button key={id} role="radio" aria-checked={net.mode === id} onClick={() => patch({ mode: id })}>
|
||||
<button key={id} role="radio" aria-checked={net.mode === id} disabled={busy === "save"} onClick={() => patch({ mode: id })}>
|
||||
{t(label)}
|
||||
</button>
|
||||
))}
|
||||
@@ -82,18 +109,18 @@ export function Network({ port, host = "" }: { port: AgentPort; host?: string })
|
||||
|
||||
<div className="kv">
|
||||
<span className="k">{t("当前生效")}</span>
|
||||
<span className="v">{net.effective || "—"}</span>
|
||||
<span className="v">{saved.effective || "—"}</span>
|
||||
</div>
|
||||
{net.endpoint && (
|
||||
{saved.endpoint && (
|
||||
<div className="kv">
|
||||
<span className="k">{t("测试目标")}</span>
|
||||
<span className="v">{net.endpoint}</span>
|
||||
<span className="v">{saved.endpoint}</span>
|
||||
</div>
|
||||
)}
|
||||
{!!net.direct?.length && (
|
||||
{!!saved.direct?.length && (
|
||||
<div className="kv">
|
||||
<span className="k">{t("始终直连")}</span>
|
||||
<span className="v">{net.direct.join(", ")}</span>
|
||||
<span className="v">{saved.direct.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -101,7 +128,7 @@ export function Network({ port, host = "" }: { port: AgentPort; host?: string })
|
||||
<div className="fields">
|
||||
<label>
|
||||
<span>{t("协议")}</span>
|
||||
<select value={net.type || "http"} onChange={(e) => patch({ type: e.target.value })}>
|
||||
<select value={net.type || "http"} disabled={busy === "save"} onChange={(e) => patch({ type: e.target.value })}>
|
||||
{TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
@@ -111,13 +138,14 @@ export function Network({ port, host = "" }: { port: AgentPort; host?: string })
|
||||
</label>
|
||||
<label className="grow">
|
||||
<span>{t("服务器")}</span>
|
||||
<input value={net.server ?? ""} placeholder="proxy.corp" onChange={(e) => patch({ server: e.target.value })} />
|
||||
<input value={net.server ?? ""} disabled={busy === "save"} placeholder="proxy.corp" onChange={(e) => patch({ server: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("端口")}</span>
|
||||
<input
|
||||
className="port"
|
||||
inputMode="numeric"
|
||||
disabled={busy === "save"}
|
||||
value={net.port ? String(net.port) : ""}
|
||||
placeholder="8080"
|
||||
onChange={(e) => patch({ port: Number(e.target.value.replace(/\D/g, "")) || 0 })}
|
||||
@@ -125,15 +153,17 @@ export function Network({ port, host = "" }: { port: AgentPort; host?: string })
|
||||
</label>
|
||||
<label className="grow">
|
||||
<span>{t("用户名(可选)")}</span>
|
||||
<input value={net.username ?? ""} onChange={(e) => patch({ username: e.target.value })} />
|
||||
<input value={net.username ?? ""} disabled={busy === "save"} onChange={(e) => patch({ username: e.target.value })} />
|
||||
</label>
|
||||
<label className="grow">
|
||||
<span>{t("密码(可选)")}</span>
|
||||
<input
|
||||
type="password"
|
||||
disabled={busy === "save"}
|
||||
value={password}
|
||||
placeholder={t(net.hasPassword ? "已保存,留空即保持不变" : "可填写 ${PROXY_PASSWORD}")}
|
||||
onChange={(e) => {
|
||||
if (!live.current || pending.current === "save") return;
|
||||
setPassword(e.target.value);
|
||||
setClear(false);
|
||||
}}
|
||||
@@ -141,7 +171,9 @@ export function Network({ port, host = "" }: { port: AgentPort; host?: string })
|
||||
</label>
|
||||
{net.hasPassword && !password && (
|
||||
<label className="chk">
|
||||
<input type="checkbox" checked={clearPassword} onChange={(e) => setClear(e.target.checked)} />
|
||||
<input type="checkbox" checked={clearPassword} disabled={busy === "save"} onChange={(e) => {
|
||||
if (live.current && pending.current !== "save") setClear(e.target.checked);
|
||||
}} />
|
||||
<span>{t("删除已保存的密码")}</span>
|
||||
</label>
|
||||
)}
|
||||
@@ -149,6 +181,7 @@ export function Network({ port, host = "" }: { port: AgentPort; host?: string })
|
||||
<span>{t("这些地址直连")}</span>
|
||||
<input
|
||||
value={net.noProxy ?? ""}
|
||||
disabled={busy === "save"}
|
||||
placeholder="localhost, 127.0.0.1, *.internal"
|
||||
onChange={(e) => patch({ noProxy: e.target.value })}
|
||||
/>
|
||||
@@ -158,15 +191,16 @@ export function Network({ port, host = "" }: { port: AgentPort; host?: string })
|
||||
|
||||
<div className="acts">
|
||||
<button className="act" data-action="network.diagnose" disabled={!!busy} onClick={() => void diagnose()}>
|
||||
{t(busy === "test" ? "测试中…" : "测试连接")}
|
||||
{t(busy === "test" ? "测试中…" : "测试已保存配置")}
|
||||
</button>
|
||||
<button className="act" data-action="network.save" data-primary disabled={!!busy} onClick={() => void save()}>
|
||||
<button className="act" data-action="network.save" data-primary disabled={!!busy || !changed} onClick={() => void save()}>
|
||||
{t(busy === "save" ? "保存中…" : "保存")}
|
||||
</button>
|
||||
</div>
|
||||
{changed && <p className="note" role="status">{t("有未保存的改动;连接测试使用已保存的配置。")}</p>}
|
||||
|
||||
{probes && (
|
||||
<div className="probes">
|
||||
<div className="probes" role="region" aria-label={t("已保存配置的测试结果")}>
|
||||
{probes.map((p, i) => (
|
||||
<div className="probe" key={i} data-ok={p.ok ? "" : undefined}>
|
||||
<i className="mk">{p.ok ? "✓" : "×"}</i>
|
||||
|
||||
@@ -49,7 +49,6 @@ import type { PaneProps, PaneReport } from "./panetypes";
|
||||
import { useRate, useTrail } from "./num";
|
||||
import { useCompactNow } from "./CompactNow";
|
||||
import { DOCK, Gutter } from "./Gutter";
|
||||
import { FlowGrips } from "./FlowResizer";
|
||||
import { Find } from "./Find";
|
||||
import { useFind } from "./usefind";
|
||||
import { RunAnalysis } from "./RunAnalysis";
|
||||
@@ -97,7 +96,7 @@ const totalsOf = (st: SessionStatus) => ({
|
||||
incompleteReason: st.sessionCostQuote?.incompleteReason,
|
||||
});
|
||||
|
||||
function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, findTogglePulse = 0, filesPulse = 0, viewRequest, sessionRef, onSettings, onFeedback, onOpenSession, needsProject, onOpenProject, onKeepHere, connect, theme, dockW, dockMax, onDockW, column, manualBrowser = false, onManualBrowser, alert }: PaneProps) {
|
||||
function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, findTogglePulse = 0, filesPulse = 0, viewRequest, sessionRef, onSettings, onFeedback, onOpenSession, needsProject, onOpenProject, onKeepHere, connect, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert }: PaneProps) {
|
||||
const [s, dispatch] = useReducer(reduce, initialState);
|
||||
const [traj, trajDispatch] = useReducer(reduceTraj, initialTraj);
|
||||
const [sessionRead, setSessionRead] = useState<SessionRead>({ kind: "pending" });
|
||||
@@ -495,7 +494,6 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
|
||||
<Find find={find} pending={historyPaging.hasMore || historyPaging.busy || historyPaging.failed} failed={historyPaging.failed} />
|
||||
|
||||
<div className="pbody rewind-notice-host" ref={body} data-dock={tab === "flow" ? "" : undefined} data-full={tab === "browser" ? "" : undefined}>
|
||||
{column && tab === "flow" && <FlowGrips column={column} />}
|
||||
<RewindNotice key={JSON.stringify([rt.id, rt.root, status?.sessionPath ?? rt.sessionPath])} readUndo={onReadUndo} onUndo={onUndoAvailable} busy={!!restoreNotice?.working || !!reloadFailure?.working} refreshKey={checkpoints} moved={s.rewindMoved} running={running} shown={visible && tab === "flow" && !rt.readOnly} />
|
||||
<div className="pviews">
|
||||
<div className="worktree-stage-host" ref={setWorktreeTarget} hidden={tab !== "flow" || !worktreePhase}/>
|
||||
|
||||
@@ -86,6 +86,37 @@ describe("a rail longer than it can show", () => {
|
||||
// that holds the thousandth rather than on the thousandth itself.
|
||||
expect(Math.abs(picked - 1000)).toBeLessThanOrEqual(40);
|
||||
});
|
||||
|
||||
it("opens a folded run instead of guessing which question it stands for", async () => {
|
||||
const { view, onJump } = await rail(2000);
|
||||
const run = view.container.querySelector<HTMLElement>(".srail-m[data-folded]");
|
||||
expect(run, "2000 questions in 600px must fold").not.toBeNull();
|
||||
|
||||
// First press: nothing jumps, because the run names a range and the reader
|
||||
// has not said which question in it yet.
|
||||
fireEvent.click(run!);
|
||||
expect(onJump).not.toHaveBeenCalled();
|
||||
const rows = view.container.querySelectorAll<HTMLElement>(".srail-subrow");
|
||||
expect(rows.length).toBeGreaterThan(1);
|
||||
|
||||
// Second press, on a row: that question, exactly.
|
||||
const label = rows[1].textContent ?? "";
|
||||
fireEvent.click(rows[1]);
|
||||
expect(onJump).toHaveBeenCalledTimes(1);
|
||||
expect((onJump.mock.calls[0][0] as RailMark).text).toBe(label);
|
||||
expect(view.container.querySelector(".srail-sub")).toBeNull();
|
||||
});
|
||||
|
||||
it("jumps on the second press when the run is already open", async () => {
|
||||
const { view, onJump } = await rail(2000);
|
||||
const run = view.container.querySelector<HTMLElement>(".srail-m[data-folded]")!;
|
||||
fireEvent.click(run);
|
||||
fireEvent.click(run);
|
||||
// Open, then the same line again: the contract the run carried before rows
|
||||
// existed, kept so a fast second press is not a dead click.
|
||||
expect(onJump).toHaveBeenCalledTimes(1);
|
||||
expect(view.container.querySelector(".srail-sub")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a star belongs to the question, not the row id", () => {
|
||||
|
||||
@@ -37,6 +37,11 @@ interface Props {
|
||||
// Only the transcript on screen answers the keys; a pane on another tab keeps
|
||||
// its rail but must not fight for them.
|
||||
bound: boolean;
|
||||
/** Raw messages still unloaded before the first mark. A paged conversation
|
||||
* mounts only its tail, so the rail would otherwise look complete while most
|
||||
* of the conversation is still on disk. */
|
||||
earlier?: number;
|
||||
onLoadEarlier?: () => void;
|
||||
}
|
||||
|
||||
// The native scrollbar owns continuous position. The semantic rail is a compact
|
||||
@@ -147,13 +152,17 @@ export function railLocalY(clientY: number, rectTop: number, rectHeight: number,
|
||||
return (clientY - rectTop) * (railHeight / rectHeight);
|
||||
}
|
||||
|
||||
export function Rail({ marks, total, scroll, flow, onJump, bound, scope }: Props) {
|
||||
export function Rail({ marks, total, scroll, flow, onJump, bound, scope, earlier = 0, onLoadEarlier }: Props) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const [tops, setTops] = useState<number[]>([]);
|
||||
// Null while every question keeps its own line; a list once they have to
|
||||
// share, because reading them apart has stopped working.
|
||||
const [groups, setGroups] = useState<RailView[] | null>(null);
|
||||
const [at, setAt] = useState(-1);
|
||||
// Which folded run has been opened into its own questions. A run is one line
|
||||
// standing for many; without this a press can only mean "the first of them",
|
||||
// which is not the question the reader pointed at.
|
||||
const [opened, setOpened] = useState<number | null>(null);
|
||||
// Which mark the keys last walked to. Kept apart from `at`, which the pointer
|
||||
// owns: hovering somewhere else must not move where the keys resume from.
|
||||
const here = useRef(-1);
|
||||
@@ -316,11 +325,24 @@ export function Rail({ marks, total, scroll, flow, onJump, bound, scope }: Props
|
||||
};
|
||||
|
||||
if (marks.length === 0) return null;
|
||||
const shown = at >= 0 && at < view.length ? view[at] : null;
|
||||
const shown = opened === null && at >= 0 && at < view.length ? view[at] : null;
|
||||
const head = shown ? marks[shown.from] : null;
|
||||
|
||||
return (
|
||||
<div className="railhost" ref={host} aria-hidden={undefined}>
|
||||
{/* A paged conversation mounts only its tail. Without this the rail ends
|
||||
where the loaded window begins and reads as a short conversation. */}
|
||||
{earlier > 0 && onLoadEarlier && (
|
||||
<button
|
||||
type="button"
|
||||
className="srail-earlier"
|
||||
style={{ top: Math.max(6, (view[0]?.top ?? PAD) - 14) }}
|
||||
aria-label={t("更早还有 {n} 条消息,点击加载", { n: earlier })}
|
||||
title={t("更早还有 {n} 条消息,点击加载", { n: earlier })}
|
||||
data-action="history.more"
|
||||
onClick={onLoadEarlier}
|
||||
/>
|
||||
)}
|
||||
<nav
|
||||
className="srail"
|
||||
aria-label={t("你说过的话")}
|
||||
@@ -362,7 +384,11 @@ export function Rail({ marks, total, scroll, flow, onJump, bound, scope }: Props
|
||||
title={m.msgIndex === undefined ? undefined : starred ? t("右键取消这处星标") : t("右键给这处加星标")}
|
||||
onFocus={() => setAt(i)}
|
||||
onBlur={() => setAt(-1)}
|
||||
onClick={() => onJump(m)}
|
||||
onClick={() => {
|
||||
if (!folded) { onJump(m); return; }
|
||||
if (opened === i) { setOpened(null); onJump(m); return; }
|
||||
setOpened(i);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
star(m.msgIndex);
|
||||
@@ -382,6 +408,32 @@ export function Rail({ marks, total, scroll, flow, onJump, bound, scope }: Props
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{/* A folded run is one line standing for many, so a press on it can only
|
||||
guess. Opening it first lets the reader pick the question they meant;
|
||||
every row is one mark, and the run closes on the way out. */}
|
||||
{opened !== null && view[opened] && !view[opened].single && (
|
||||
<div
|
||||
className="srail-sub"
|
||||
style={{ top: Math.min(Math.max(view[opened].top, 60), Math.max(60, geom.current.rail - 60)) }}
|
||||
>
|
||||
<div className="srail-subhd">{t("第 {a} 至 {b} 条", { a: view[opened].from + 1, b: view[opened].to + 1 })}</div>
|
||||
{Array.from({ length: view[opened].to - view[opened].from + 1 }, (_, k) => {
|
||||
const mk = marks[view[opened]!.from + k];
|
||||
if (!mk) return null;
|
||||
return (
|
||||
<button
|
||||
key={mk.id}
|
||||
type="button"
|
||||
className="srail-subrow"
|
||||
data-action="rail.pick"
|
||||
onClick={() => { setOpened(null); onJump(mk); }}
|
||||
>
|
||||
{mk.text.slice(0, 60)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* 贴着轨道两端的那几条,预览要收进容器里 —— 否则它会被裁掉一半 */}
|
||||
{shown && (
|
||||
<div
|
||||
|
||||
@@ -8,6 +8,7 @@ import { listenAction } from "./listen";
|
||||
import { useRuntimeReload } from "./RuntimeReload";
|
||||
import type { CapabilityScope, McpEntry, ModelEntry, PluginPackage, SkillEntry } from "../port/port";
|
||||
import { useSettingsSearch } from "./useSettingsSearch";
|
||||
import { useSettingLanding } from "./useSettingLanding";
|
||||
import { bytes, tokens as fmtTokens } from "../i18n/format";
|
||||
import { SECTION_NAME } from "./prefsnav";
|
||||
import { SettingsNav } from "./SettingsNav";
|
||||
@@ -316,32 +317,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
if (section === "ext") setExtTab(anchor === "market" ? "market" : "installed");
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!landed) return;
|
||||
const el = root.current?.querySelector<HTMLElement>(`#set-${CSS.escape(landed)}`);
|
||||
// 折叠起来的块在 DOM 里,但不在屏幕上 —— 滚过去会停在一片空白上。要开哪一
|
||||
// 个由结构回答:不需要再维护一张「哪些设置是高级」的表,也不需要把落点穿到
|
||||
// 每个页面里去。
|
||||
for (let d = el?.closest("details"); d; d = d.parentElement?.closest("details") ?? null) d.open = true;
|
||||
const behavior = matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
|
||||
const aim = () => el?.scrollIntoView({ block: "start", behavior });
|
||||
aim();
|
||||
// The page is still growing under the scroll: theme packs and the font list
|
||||
// arrive late and push the anchor below a target already locked in. A jump
|
||||
// owns its landing until it lapses, so re-aim for as long as it is owed.
|
||||
const grew = new ResizeObserver(aim);
|
||||
const col = root.current?.querySelector(".prefs-col");
|
||||
if (col) grew.observe(col);
|
||||
// 「就是这里」是一次导航反馈,不是这个设置变了 —— 所以它只是元素上短暂的
|
||||
// 一个标记,不进 React 状态,也不用往每个块穿一个纯表现的 prop。
|
||||
el?.setAttribute("data-landed", "");
|
||||
const done = setTimeout(() => setLanded(""), 900);
|
||||
return () => {
|
||||
clearTimeout(done);
|
||||
grew.disconnect();
|
||||
el?.removeAttribute("data-landed");
|
||||
};
|
||||
}, [landed, at]);
|
||||
useSettingLanding(root, landed, at, setLanded);
|
||||
|
||||
return (
|
||||
<SettingsLeaveContext.Provider value={navigation.register}><div
|
||||
@@ -420,9 +396,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(() => setAt(section)); }} />
|
||||
<SettingsNav at={at} values={nav} shown={shown} danger={danger} disabled={updatingPkg.applying} onSelect={(section) => { if (section !== at) navigation.leave(() => { setLanded(""); setAt(section); }); }} />
|
||||
|
||||
<div className="prefs-main" role="tabpanel" aria-labelledby={`prefs-${at}`} data-sec={at}>
|
||||
<div className="prefs-main" key={at} role="tabpanel" aria-labelledby={`prefs-${at}`} data-sec={at}>
|
||||
<Boundary
|
||||
fallback={
|
||||
<div className="find" data-lvl="err" role="alert">
|
||||
|
||||
@@ -471,7 +471,7 @@ export function Transcript({ items, port, historyPaging, entering, onEntered, br
|
||||
ref={scroll}
|
||||
hidden={hidden}
|
||||
>
|
||||
<Rail marks={marks} total={items.length} scroll={scroll} flow={flow} onJump={jumpTo} bound={!hidden} scope={scope} />
|
||||
<Rail marks={marks} total={items.length} scroll={scroll} flow={flow} onJump={jumpTo} bound={!hidden} scope={scope} earlier={historyPaging?.earlier ?? 0} onLoadEarlier={historyPaging?.hasMore ? loadOlder : undefined} />
|
||||
<div className="flow-edge" aria-hidden="true" />
|
||||
{/* 空转录是「窗口的空白」,壁纸该在那儿;一有内容它就是内容了。 */}
|
||||
<div className="flow" ref={flow} data-empty={items.length === 0 && !historyPaging?.hasMore && !historyPaging?.busy && !historyPaging?.failed ? "" : undefined}>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Wallpaper } from "../port/look";
|
||||
import { WallpaperScene } from "./WallpaperScene";
|
||||
|
||||
const scene: Wallpaper = {
|
||||
url: "/bg.jpg",
|
||||
kind: "scene",
|
||||
title: "花海",
|
||||
audio: "/scene.mp3",
|
||||
parallax: 0.04,
|
||||
muted: false,
|
||||
volume: 0.5,
|
||||
opacity: 0.5,
|
||||
dim: 0.55,
|
||||
focusX: 0.5,
|
||||
focusY: 0.5,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
document.documentElement.style.removeProperty("--bg-x");
|
||||
document.documentElement.style.removeProperty("--bg-y");
|
||||
});
|
||||
|
||||
describe("a Wallpaper Engine backdrop", () => {
|
||||
it("renders a looping soundtrack and starts it", async () => {
|
||||
const play = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
render(<WallpaperScene wallpaper={scene} />);
|
||||
const audio = document.querySelector("audio");
|
||||
expect(audio?.getAttribute("src")).toBe("/scene.mp3");
|
||||
expect(audio?.loop).toBe(true);
|
||||
await waitFor(() => expect(audio?.volume).toBeCloseTo(0.5));
|
||||
await waitFor(() => expect(play).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("does not start at zero volume", () => {
|
||||
const play = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
render(<WallpaperScene wallpaper={{ ...scene, volume: 0 }} />);
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves the backdrop with the pointer", () => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
render(<WallpaperScene wallpaper={scene} />);
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1000 });
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: 500 });
|
||||
window.dispatchEvent(new MouseEvent("pointermove", { clientX: 100, clientY: 100 }));
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-x")).not.toBe("50%");
|
||||
expect(document.documentElement.style.getPropertyValue("--bg-y")).not.toBe("50%");
|
||||
});
|
||||
|
||||
it("does not install the soundtrack when muted", () => {
|
||||
const play = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
render(<WallpaperScene wallpaper={{ ...scene, muted: true }} />);
|
||||
const audio = document.querySelector("audio");
|
||||
expect(audio?.muted).toBe(true);
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Wallpaper } from "../port/look";
|
||||
import { listenAction } from "./listen";
|
||||
|
||||
/** The part of a Wallpaper Engine scene this window can replay: the extracted
|
||||
* backdrop moves a little with the pointer, and its soundtracks loop behind
|
||||
* the interface. Shaders and particles stay in Wallpaper Engine. */
|
||||
export function WallpaperScene({ wallpaper }: { wallpaper?: Wallpaper }) {
|
||||
const audio = useRef<(HTMLAudioElement | null)[]>([]);
|
||||
const sources = audioSources(wallpaper);
|
||||
|
||||
useEffect(() => {
|
||||
const paper = wallpaper;
|
||||
if (!paper || paper.kind !== "scene" || !paper.parallax) return;
|
||||
const root = document.documentElement;
|
||||
const baseX = clampPct(paper.focusX * 100);
|
||||
const baseY = clampPct(paper.focusY * 100);
|
||||
const shift = Math.min(0.15, Math.max(0, paper.parallax)) * 100;
|
||||
const reduced = () =>
|
||||
root.dataset.effects === "reduced" ||
|
||||
(typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches);
|
||||
const move = (event: PointerEvent) => {
|
||||
if (reduced()) return;
|
||||
const dx = (event.clientX / Math.max(innerWidth, 1) - 0.5) * shift;
|
||||
const dy = (event.clientY / Math.max(innerHeight, 1) - 0.5) * shift;
|
||||
root.style.setProperty("--bg-x", `${clampPct(baseX - dx)}%`);
|
||||
root.style.setProperty("--bg-y", `${clampPct(baseY - dy)}%`);
|
||||
};
|
||||
const off = listenAction(window, "pointermove", { action: "wallpaper.parallax", listener: move as EventListener }, { passive: true });
|
||||
return () => {
|
||||
off();
|
||||
root.style.setProperty("--bg-x", `${baseX}%`);
|
||||
root.style.setProperty("--bg-y", `${baseY}%`);
|
||||
};
|
||||
}, [wallpaper?.kind, wallpaper?.parallax, wallpaper?.focusX, wallpaper?.focusY]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const element of audio.current) {
|
||||
if (element) element.volume = clampLevel(wallpaper?.volume ?? 1);
|
||||
}
|
||||
}, [wallpaper?.volume, sources.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sources.length || wallpaper?.kind !== "scene" || wallpaper.muted || (wallpaper.volume ?? 1) <= 0) return;
|
||||
const start = () => {
|
||||
for (const element of audio.current) {
|
||||
if (!element) continue;
|
||||
try {
|
||||
void Promise.resolve(element.play()).catch(() => {});
|
||||
} catch {
|
||||
// Autoplay is refused until a gesture; the listeners below retry.
|
||||
}
|
||||
}
|
||||
};
|
||||
start();
|
||||
const offPointer = listenAction(window, "pointerdown", { action: "wallpaper.sound", listener: start as EventListener }, { once: true });
|
||||
const offKey = listenAction(window, "keydown", { action: "wallpaper.sound", listener: start as EventListener }, { once: true });
|
||||
return () => {
|
||||
offPointer();
|
||||
offKey();
|
||||
for (const element of audio.current) element?.pause();
|
||||
};
|
||||
}, [sources.join("\u0000"), wallpaper?.muted, wallpaper?.kind, wallpaper?.volume]);
|
||||
|
||||
if (!sources.length) return null;
|
||||
return createPortal(
|
||||
<>
|
||||
{sources.map((src, i) => (
|
||||
<audio
|
||||
key={`${i}:${src}`}
|
||||
ref={(element) => {
|
||||
audio.current[i] = element;
|
||||
}}
|
||||
src={src}
|
||||
loop
|
||||
muted={wallpaper?.muted}
|
||||
preload="auto"
|
||||
hidden
|
||||
/>
|
||||
))}
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function audioSources(wallpaper?: Wallpaper): string[] {
|
||||
if (wallpaper?.kind !== "scene") return [];
|
||||
if (wallpaper.audios?.length) return wallpaper.audios;
|
||||
return wallpaper.audio ? [wallpaper.audio] : [];
|
||||
}
|
||||
|
||||
function clampPct(value: number): number {
|
||||
return Math.min(100, Math.max(0, value));
|
||||
}
|
||||
|
||||
function clampLevel(value: number): number {
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { t } from "../i18n";
|
||||
import type { AgentPort, Appearance } from "../port/port";
|
||||
import type { SceneFolder } from "../port/look";
|
||||
import { WallpaperSceneImport } from "./WallpaperSceneImport";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const sceneLook: Appearance = {
|
||||
wallpaper: {
|
||||
url: "/bg.jpg",
|
||||
kind: "scene",
|
||||
title: "花海",
|
||||
audio: "/scene.mp3",
|
||||
parallax: 0.02,
|
||||
muted: false,
|
||||
volume: 0.5,
|
||||
opacity: 0.5,
|
||||
dim: 0.55,
|
||||
focusX: 0.5,
|
||||
focusY: 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
function files() {
|
||||
return [
|
||||
new File(['{"title":"花海"}'], "project.json", { type: "application/json" }),
|
||||
new File([new Uint8Array([1, 2, 3])], "scene.pkg"),
|
||||
new File([new Uint8Array([4])], "preview.jpg", { type: "image/jpeg" }),
|
||||
];
|
||||
}
|
||||
|
||||
describe("importing a Wallpaper Engine folder", () => {
|
||||
it("sends project.json, scene.pkg and the preview together", async () => {
|
||||
const uploadScene = vi.fn(async (_folder: SceneFolder) => sceneLook);
|
||||
const onLook = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const { container } = render(
|
||||
<WallpaperSceneImport
|
||||
port={{ uploadScene } as unknown as AgentPort}
|
||||
look={{}}
|
||||
onLook={onLook}
|
||||
setPaper={() => {}}
|
||||
onError={onError}
|
||||
/>,
|
||||
);
|
||||
const input = container.querySelector('input[data-action="wallpaper.scene"]') as HTMLInputElement;
|
||||
const [project, scene, preview] = files();
|
||||
fireEvent.change(input, { target: { files: [project, scene, preview] } });
|
||||
|
||||
await waitFor(() => expect(uploadScene).toHaveBeenCalledTimes(1));
|
||||
expect(uploadScene.mock.calls[0][0]).toMatchObject({ project, scene, preview });
|
||||
expect(onLook).toHaveBeenCalledWith(sceneLook);
|
||||
expect(onError).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("says what is missing instead of uploading half a folder", async () => {
|
||||
const uploadScene = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const { container } = render(
|
||||
<WallpaperSceneImport
|
||||
port={{ uploadScene } as unknown as AgentPort}
|
||||
look={{}}
|
||||
onLook={() => {}}
|
||||
setPaper={() => {}}
|
||||
onError={onError}
|
||||
/>,
|
||||
);
|
||||
const input = container.querySelector('input[data-action="wallpaper.scene"]') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files: [new File([new Uint8Array([1])], "scene.pkg")] } });
|
||||
|
||||
expect(uploadScene).not.toHaveBeenCalled();
|
||||
expect(onError).toHaveBeenCalledWith(t("文件夹里需要 project.json 和 scene.pkg"));
|
||||
});
|
||||
|
||||
it("mutes a scene's soundtrack", () => {
|
||||
const setPaper = vi.fn();
|
||||
render(
|
||||
<WallpaperSceneImport
|
||||
port={{ uploadScene: vi.fn() } as unknown as AgentPort}
|
||||
look={sceneLook}
|
||||
onLook={() => {}}
|
||||
setPaper={setPaper}
|
||||
onError={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: t("关闭声音") }));
|
||||
expect(setPaper).toHaveBeenCalledWith({ muted: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { AgentPort, Appearance } from "../port/port";
|
||||
import type { Wallpaper } from "../port/look";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
|
||||
interface Props {
|
||||
port: AgentPort;
|
||||
look: Appearance;
|
||||
onLook: (look: Appearance) => void;
|
||||
setPaper: (patch: Partial<Wallpaper>) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
/** Picks a Wallpaper Engine folder and shows what was imported. Kept out of
|
||||
* Appearance so that panel can stay about the picture it already knows. */
|
||||
export function WallpaperSceneImport({ port, look, onLook, setPaper, onError }: Props) {
|
||||
const folder = useRef<HTMLInputElement>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const input = folder.current;
|
||||
if (!input) return;
|
||||
input.setAttribute("webkitdirectory", "");
|
||||
input.setAttribute("directory", "");
|
||||
}, []);
|
||||
|
||||
const pick = useCallback(
|
||||
async (chosen: FileList | null) => {
|
||||
const files = Array.from(chosen ?? []);
|
||||
const project = files.find((f) => f.name.toLowerCase() === "project.json");
|
||||
const scene = files.find((f) => f.name.toLowerCase() === "scene.pkg");
|
||||
if (!project || !scene) {
|
||||
onError(t("文件夹里需要 project.json 和 scene.pkg"));
|
||||
return;
|
||||
}
|
||||
const preview = files.find((f) => /^preview\.(jpe?g|png|webp|avif)$/i.test(f.name));
|
||||
setBusy(true);
|
||||
onError("");
|
||||
try {
|
||||
onLook(await port.uploadScene({ project, scene, preview }));
|
||||
} catch (e) {
|
||||
onError(reason(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[port, onLook, onError],
|
||||
);
|
||||
|
||||
const paper = look.wallpaper;
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={folder}
|
||||
data-action="wallpaper.scene"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
void pick(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button className="paperpick" data-action="wallpaper.scene" data-busy={busy ? "" : undefined} onClick={() => folder.current?.click()}>
|
||||
<span className="plus" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16">
|
||||
<path d="M8 3.7v8.6M3.7 8h8.6" />
|
||||
</svg>
|
||||
</span>
|
||||
{t("导入 Wallpaper Engine…")}
|
||||
</button>
|
||||
{paper?.kind === "scene" && (
|
||||
<>
|
||||
<div className="prow">
|
||||
<span className="tx">{t("Wallpaper Engine 场景")}</span>
|
||||
<span className="now">{paper.title || t("未命名场景")}</span>
|
||||
{(paper.audios?.length || paper.audio) && (
|
||||
<button className="now nowbtn" data-action="wallpaper.sound" onClick={() => setPaper({ muted: !paper.muted })}>
|
||||
{paper.muted ? t("打开声音") : t("关闭声音")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="note">{t("场景以主图和声音播放;粒子和着色器效果暂未再现。")}</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Appearance } from "../port/port";
|
||||
import { Backdrop } from "./Backdrop";
|
||||
import { WallpaperScene } from "./WallpaperScene";
|
||||
import { WallpaperVolume } from "./WallpaperVolume";
|
||||
|
||||
/** Everything behind the window: the pack's sky, a picture or video
|
||||
* wallpaper, and a Wallpaper Engine scene's sound and controls. */
|
||||
export function WallpaperStage({ sky, look, onLook }: { sky: boolean; look: Appearance; onLook: (look: Appearance) => void }) {
|
||||
return (
|
||||
<>
|
||||
<Backdrop sky={sky} wallpaper={look.wallpaper} />
|
||||
<WallpaperScene wallpaper={look.wallpaper} />
|
||||
<WallpaperVolume look={look} onLook={onLook} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { t } from "../i18n";
|
||||
import type { Appearance } from "../port/port";
|
||||
import type { Wallpaper } from "../port/look";
|
||||
import { WallpaperVolume } from "./WallpaperVolume";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function unit(over: Partial<Wallpaper> = {}): Wallpaper {
|
||||
return {
|
||||
url: "/bg.jpg",
|
||||
kind: "scene",
|
||||
title: "花海",
|
||||
audio: "/scene.mp3",
|
||||
parallax: 0.02,
|
||||
muted: false,
|
||||
volume: 0.6,
|
||||
opacity: 0.5,
|
||||
dim: 0.55,
|
||||
focusX: 0.5,
|
||||
focusY: 0.5,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function look(paper: Wallpaper): Appearance {
|
||||
return { wallpaper: paper };
|
||||
}
|
||||
|
||||
describe("the scene's main-window volume control", () => {
|
||||
it("lives outside the app layout", () => {
|
||||
const { container } = render(<WallpaperVolume look={look(unit())} onLook={() => {}} />);
|
||||
expect(container.querySelector(".wvol")).toBeNull();
|
||||
expect(document.querySelector(".wvol")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("mutes from the icon without losing the level", () => {
|
||||
const onLook = vi.fn((_next: Appearance) => {});
|
||||
render(<WallpaperVolume look={look(unit())} onLook={onLook} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: t("关闭声音") }));
|
||||
expect(onLook.mock.calls[0][0].wallpaper).toMatchObject({ muted: true, volume: 0.6 });
|
||||
});
|
||||
|
||||
it("restores a level when unmuting from silence", () => {
|
||||
const onLook = vi.fn((_next: Appearance) => {});
|
||||
render(<WallpaperVolume look={look(unit({ muted: true, volume: 0 }))} onLook={onLook} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: t("打开声音") }));
|
||||
expect(onLook.mock.calls[0][0].wallpaper).toMatchObject({ muted: false, volume: 1 });
|
||||
});
|
||||
|
||||
it("sets the level from the vertical slider", () => {
|
||||
const onLook = vi.fn((_next: Appearance) => {});
|
||||
render(<WallpaperVolume look={look(unit())} onLook={onLook} />);
|
||||
fireEvent.change(screen.getByRole("slider", { name: t("音量") }), { target: { value: "0.25" } });
|
||||
expect(onLook.mock.calls[0][0].wallpaper).toMatchObject({ volume: 0.25, muted: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import type { Appearance } from "../port/port";
|
||||
import type { Wallpaper } from "../port/look";
|
||||
import { t } from "../i18n";
|
||||
|
||||
interface Props {
|
||||
look: Appearance;
|
||||
onLook: (look: Appearance) => void;
|
||||
}
|
||||
|
||||
/** A scene's soundtrack control: the icon is the mute switch, and the vertical
|
||||
* level appears while the pointer or keyboard focus is on it. */
|
||||
export function WallpaperVolume({ look, onLook }: Props) {
|
||||
const paper = look.wallpaper;
|
||||
if (!paper || paper.kind !== "scene" || !hasAudio(paper)) return null;
|
||||
const volume = clamp(paper.volume ?? 1);
|
||||
const silent = paper.muted || volume <= 0;
|
||||
const update = (patch: Partial<Wallpaper>) => onLook({ ...look, wallpaper: { ...paper, ...patch } });
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="wvol"
|
||||
data-silent={silent ? "" : undefined}
|
||||
style={{ position: "fixed", right: 8, bottom: 8, zIndex: 25, display: "flex", flexDirection: "column", alignItems: "center", gap: 6 }}
|
||||
>
|
||||
<div className="wvol-pop">
|
||||
<input
|
||||
className="wvol-range"
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={volume}
|
||||
aria-label={t("音量")}
|
||||
data-action="wallpaper.volume"
|
||||
style={{ writingMode: "vertical-lr", direction: "rtl", width: 18, height: 96, margin: "4px 0", accentColor: "var(--accent)", cursor: "pointer" }}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value);
|
||||
update({ volume: next, muted: next <= 0 });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="wvol-btn"
|
||||
data-action="wallpaper.sound"
|
||||
aria-label={silent ? t("打开声音") : t("关闭声音")}
|
||||
title={silent ? t("打开声音") : t("关闭声音")}
|
||||
style={{ width: 34, height: 34, display: "grid", placeItems: "center", padding: 0, border: "1px solid var(--hair)", borderRadius: "50%", background: "color-mix(in srgb, var(--raised) 84%, transparent)", color: "var(--text)", cursor: "pointer", backdropFilter: "blur(12px)" }}
|
||||
onClick={() => {
|
||||
if (silent) update({ muted: false, volume: volume > 0 ? volume : 1 });
|
||||
else update({ muted: true });
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M4 9h4l5-4v14l-5-4H4z" />
|
||||
{silent ? (
|
||||
<path d="M17 9l5 6M22 9l-5 6" />
|
||||
) : (
|
||||
<>
|
||||
<path d="M16 8a5 5 0 0 1 0 8" />
|
||||
<path d="M18.5 5.5a9 9 0 0 1 0 13" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function hasAudio(paper: Wallpaper): boolean {
|
||||
return Boolean(paper.audios?.length || paper.audio);
|
||||
}
|
||||
|
||||
function clamp(value: number): number {
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { WallpaperVolumeSlider } from "./WallpaperVolumeSlider";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("the scene volume slider", () => {
|
||||
it("uses the same filled track as the focus sliders", () => {
|
||||
const { container } = render(<WallpaperVolumeSlider volume={0.6} onChange={() => {}} />);
|
||||
const slider = screen.getByRole("slider", { name: /音量/ }) as HTMLInputElement;
|
||||
expect(slider.style.getPropertyValue("--at")).toBe("60%");
|
||||
expect(container.textContent).toContain("60%");
|
||||
});
|
||||
|
||||
it("writes the level and treats zero as silent", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<WallpaperVolumeSlider volume={0.6} onChange={onChange} />);
|
||||
const slider = screen.getByRole("slider", { name: /音量/ });
|
||||
fireEvent.change(slider, { target: { value: "0.25" } });
|
||||
expect(onChange).toHaveBeenCalledWith({ volume: 0.25, muted: false });
|
||||
fireEvent.change(slider, { target: { value: "0" } });
|
||||
expect(onChange).toHaveBeenLastCalledWith({ volume: 0, muted: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { Wallpaper } from "../port/look";
|
||||
import { t } from "../i18n";
|
||||
import { pct } from "../i18n/format";
|
||||
|
||||
/** The scene's soundtrack level, drawn with the same slider as the focus
|
||||
* controls beside it. */
|
||||
export function WallpaperVolumeSlider({ volume, onChange }: { volume?: number; onChange: (patch: Partial<Wallpaper>) => void }) {
|
||||
const value = Math.min(1, Math.max(0, volume ?? 1));
|
||||
return (
|
||||
<label className="prow">
|
||||
<span className="tx">{t("音量")}</span>
|
||||
<input
|
||||
className="slider"
|
||||
style={{ "--at": `${Math.round(value * 100)}%` } as CSSProperties}
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={value}
|
||||
data-action="wallpaper.volume"
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value);
|
||||
onChange({ volume: next, muted: next <= 0 });
|
||||
}}
|
||||
/>
|
||||
<span className="now">{pct(value)}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +1,102 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { act, cleanup, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SayCard } from "./SayCard";
|
||||
import { FOLD_CHOICES, FOLD_DEFAULTS, setFoldModes } from "../../state/prefs";
|
||||
import { firstSentence } from "../thoughtPeek";
|
||||
import { Folding } from "../Folding";
|
||||
import { FOLD_DEFAULTS, setFoldModes, setShowsThinkingPreview } from "../../state/prefs";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
setFoldModes(FOLD_DEFAULTS);
|
||||
setShowsThinkingPreview(false);
|
||||
});
|
||||
|
||||
const item = { t: "say" as const, id: "s", text: "answer", reasoning: "先检查配置文件。然后再看日志。", done: true };
|
||||
const peek = (el: HTMLElement) => el.querySelector(".peek");
|
||||
const peek = (el: HTMLElement) => el.querySelector(".thinking-preview");
|
||||
|
||||
describe("firstSentence", () => {
|
||||
it("takes the first sentence of Chinese and Latin text", () => {
|
||||
expect(firstSentence("先检查配置文件。然后再看日志。", 80)).toBe("先检查配置文件。");
|
||||
expect(firstSentence("Check the config first. Then the logs.", 80)).toBe("Check the config first.");
|
||||
});
|
||||
it("cuts a long first sentence at the limit with an ellipsis", () => {
|
||||
const out = firstSentence("a".repeat(200), 20);
|
||||
expect([...out]).toHaveLength(21);
|
||||
expect(out.endsWith("…")).toBe(true);
|
||||
});
|
||||
it("ignores leading blank lines and whitespace", () => {
|
||||
expect(firstSentence("\n\n hello there. More", 80)).toBe("hello there.");
|
||||
});
|
||||
it("is empty for blank reasoning", () => {
|
||||
expect(firstSentence(" \n ", 80)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the thinking peek", () => {
|
||||
it("is a choice for thinking only, and not the default", () => {
|
||||
expect(FOLD_CHOICES.thinking).toContain("peek");
|
||||
expect(FOLD_CHOICES.activity).not.toContain("peek");
|
||||
expect(FOLD_DEFAULTS.thinking).not.toBe("peek");
|
||||
});
|
||||
|
||||
it("shows nothing beside a folded block under the default", () => {
|
||||
expect(peek(render(<SayCard item={item} />).container)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the first sentence beside a folded block and hides it once opened", async () => {
|
||||
setFoldModes({ thinking: "peek" });
|
||||
describe("collapsed thinking previews", () => {
|
||||
it("shows the opening text only when enabled and collapsed", async () => {
|
||||
const { container } = render(<SayCard item={item} />);
|
||||
expect(peek(container)?.textContent).toBe("先检查配置文件。");
|
||||
expect((container.querySelector("details") as HTMLDetailsElement).open).toBe(false);
|
||||
await userEvent.click(container.querySelector("summary") as HTMLElement);
|
||||
expect(peek(container)).toBeNull();
|
||||
act(() => setShowsThinkingPreview(true));
|
||||
expect(peek(container)?.textContent).toBe(item.reasoning);
|
||||
await userEvent.click(container.querySelector("summary")!);
|
||||
expect(peek(container)).toBeNull();
|
||||
await userEvent.click(container.querySelector("summary")!);
|
||||
expect(peek(container)?.textContent).toBe(item.reasoning);
|
||||
act(() => setShowsThinkingPreview(false));
|
||||
expect(peek(container)).toBeNull();
|
||||
});
|
||||
|
||||
it("follows the setting for a block already on screen", () => {
|
||||
const { container } = render(<SayCard item={item} />);
|
||||
expect(peek(container)).toBeNull();
|
||||
act(() => setFoldModes({ thinking: "peek" }));
|
||||
expect(peek(container)).not.toBeNull();
|
||||
it("retains the opening as more reasoning streams, then survives completion", () => {
|
||||
setShowsThinkingPreview(true);
|
||||
const { container, rerender } = render(<SayCard item={{ ...item, done: false, text: "", reasoning: " First step." }} />);
|
||||
expect(peek(container)?.textContent).toBe("First step.");
|
||||
const reasoning = ` First step.\n${"More reasoning. ".repeat(30)}`;
|
||||
rerender(<SayCard item={{ ...item, done: false, text: "", reasoning }} />);
|
||||
expect(peek(container)?.textContent).toBe(reasoning.trimStart());
|
||||
rerender(<SayCard item={{ ...item, reasoning }} />);
|
||||
expect(peek(container)?.textContent).toBe(reasoning.trimStart());
|
||||
});
|
||||
|
||||
it("shows no peek while the thought is still streaming", () => {
|
||||
setFoldModes({ thinking: "peek" });
|
||||
expect(peek(render(<SayCard item={{ ...item, done: false, text: "" }} />).container)).toBeNull();
|
||||
it("opens while thinking, then previews when the answer starts, unless manually overridden", async () => {
|
||||
setFoldModes({ thinking: "live" });
|
||||
setShowsThinkingPreview(true);
|
||||
const { container, rerender } = render(<SayCard item={{ ...item, done: false, text: "" }} />);
|
||||
const details = () => container.querySelector("details")!;
|
||||
expect(details().open).toBe(true);
|
||||
expect(peek(container)).toBeNull();
|
||||
rerender(<SayCard item={{ ...item, done: false }} />);
|
||||
expect(details().open).toBe(false);
|
||||
expect(peek(container)?.textContent).toBe(item.reasoning);
|
||||
await userEvent.click(container.querySelector("summary")!);
|
||||
rerender(<SayCard item={item} />);
|
||||
expect(details().open).toBe(true);
|
||||
expect(peek(container)).toBeNull();
|
||||
act(() => setFoldModes({ thinking: "folded" }));
|
||||
expect(details().open).toBe(true);
|
||||
});
|
||||
|
||||
it("lets a manually collapsed block preview even when the default is expanded", async () => {
|
||||
setFoldModes({ thinking: "open" });
|
||||
setShowsThinkingPreview(true);
|
||||
const { container } = render(<SayCard item={item} />);
|
||||
expect(peek(container)).toBeNull();
|
||||
await userEvent.click(container.querySelector("summary")!);
|
||||
expect(peek(container)?.textContent).toBe(item.reasoning);
|
||||
act(() => setFoldModes({ thinking: "live" }));
|
||||
expect(peek(container)?.textContent).toBe(item.reasoning);
|
||||
});
|
||||
|
||||
it("does not render a disclosure or preview for blank reasoning", () => {
|
||||
setShowsThinkingPreview(true);
|
||||
const { container } = render(<SayCard item={{ ...item, reasoning: " \n " }} />);
|
||||
expect(container.querySelector("details")).toBeNull();
|
||||
expect(peek(container)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the independent preview setting", () => {
|
||||
it("changes the transcript and demonstration without presets changing the preview preference", async () => {
|
||||
const settings = render(<Folding />);
|
||||
const card = render(<SayCard item={item} />);
|
||||
const sw = screen.getByRole("switch");
|
||||
expect(sw.getAttribute("aria-checked")).toBe("false");
|
||||
await userEvent.click(sw);
|
||||
expect(peek(card.container)?.textContent).toBe(item.reasoning);
|
||||
expect(settings.container.querySelector('.fp-block[data-part="thinking"] .thinking-preview')).not.toBeNull();
|
||||
const choices = settings.container.querySelector('[role="radiogroup"][aria-label]')!;
|
||||
const presets = within(choices as HTMLElement).getAllByRole("radio");
|
||||
await userEvent.click(presets[3]);
|
||||
expect(sw.getAttribute("aria-checked")).toBe("true");
|
||||
expect(card.container.querySelector("details")!.open).toBe(true);
|
||||
expect(peek(card.container)).toBeNull();
|
||||
await userEvent.click(presets[1]);
|
||||
expect(sw.getAttribute("aria-checked")).toBe("true");
|
||||
expect(peek(card.container)?.textContent).toBe(item.reasoning);
|
||||
await userEvent.click(sw);
|
||||
expect(peek(card.container)).toBeNull();
|
||||
expect(settings.container.querySelector(".thinking-preview")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useFoldModes, useStartsOpen } from "../../state/foldpref";
|
||||
import { firstSentence } from "../thoughtPeek";
|
||||
import { useThinkingPreview, useStartsOpen } from "../../state/foldpref";
|
||||
import { StudioIcon } from "../StudioIcon";
|
||||
import { t } from "../../i18n";
|
||||
import { count, decimals } from "../../i18n/format";
|
||||
@@ -31,7 +30,6 @@ function ThoughtLabel({ item }: { item: Extract<Item, { t: "say" }> }) {
|
||||
}
|
||||
|
||||
const LIVE_TICK_MS = 100;
|
||||
const PEEK_CHARS = 80;
|
||||
|
||||
// While the model is still thinking the reader sees the clock run, not a bare
|
||||
// ellipsis; a card this window did not watch begin has no start to count from.
|
||||
@@ -107,8 +105,8 @@ export function SayCard({ item, afterAnswer, settled = true, reply, quiet }: { i
|
||||
const start = useStartsOpen("thinking", !item.done && !item.text);
|
||||
const [touched, setOpen] = useState<boolean | null>(null);
|
||||
const open = touched ?? start;
|
||||
const peeks = useFoldModes().thinking === "peek" && !open && item.done;
|
||||
const peek = useMemo(() => (peeks ? firstSentence(item.reasoning ?? "", PEEK_CHARS) : ""), [peeks, item.reasoning]);
|
||||
const peeks = useThinkingPreview() && !open;
|
||||
const peek = peeks ? item.reasoning?.trimStart() : "";
|
||||
const [menu, setMenu] = useState<"" | "retry" | "more">("");
|
||||
const shut = useCallback(() => setMenu(""), []);
|
||||
const retry = useRef<HTMLButtonElement>(null);
|
||||
@@ -155,7 +153,7 @@ export function SayCard({ item, afterAnswer, settled = true, reply, quiet }: { i
|
||||
<details className="think" open={open} onToggle={(e) => e.currentTarget.open !== open && setOpen(e.currentTarget.open)}>
|
||||
<summary>
|
||||
<span className="fold">{item.done || item.thoughtMs !== undefined ? <ThoughtLabel item={item} /> : <LiveThought id={item.id} />}</span>
|
||||
{peek && <span className="peek">{peek}</span>}
|
||||
{peek && <span className="thinking-preview">{peek}</span>}
|
||||
</summary>
|
||||
<div className="tk">
|
||||
{thought}
|
||||
|
||||
@@ -67,7 +67,7 @@ it("gathers the controls under one button below the bar fold, keeps them in the
|
||||
expect(document.documentElement.dataset.fold).toContain("bar");
|
||||
const button = more()!;
|
||||
expect(button.getAttribute("aria-expanded")).toBe("false");
|
||||
for (const sel of [".find-action", ".theme-toggle", ".top-action"]) {
|
||||
for (const sel of [".find-action", ".theme-toggle", ".view-action"]) {
|
||||
expect(document.querySelector(`.chrome .r-fold ${sel}`), sel).not.toBeNull();
|
||||
}
|
||||
await userEvent.click(button);
|
||||
|
||||
@@ -28,7 +28,6 @@ async function draw(shell: Record<string, unknown>, host?: string) {
|
||||
status={{ preset: "balanced", toolApprovalMode: "ask", workspaceRoot: ROOT, sessionPath: ROOT + "/s.jsonl" } as never}
|
||||
title="会话"
|
||||
steer={0}
|
||||
onSettings={vi.fn()}
|
||||
account={null}
|
||||
theme="dark"
|
||||
onTheme={vi.fn()}
|
||||
|
||||
@@ -103,12 +103,15 @@ function carriesFiles(e: DragEvent): boolean {
|
||||
return types.includes(PATH_TYPE) || types.includes("Files") || types.includes("text/uri-list") || types.includes("text/plain");
|
||||
}
|
||||
|
||||
// The innermost zone holding the target: a wide zone must not shadow a narrow
|
||||
// one inside it, whichever mounted first.
|
||||
function zoneAt(target: EventTarget | null): Zone | null {
|
||||
if (!(target instanceof Node)) return null;
|
||||
let found: Zone | null = null;
|
||||
for (const zone of zones) {
|
||||
if (zone.el.contains(target)) return zone;
|
||||
if (zone.el.contains(target) && (!found || found.el.contains(zone.el))) found = zone;
|
||||
}
|
||||
return null;
|
||||
return found;
|
||||
}
|
||||
|
||||
function light(next: Zone | null, dt: DataTransfer | null = null) {
|
||||
|
||||
@@ -27,7 +27,7 @@ function fixture(contents = false) {
|
||||
const generation = { current: 0 };
|
||||
const release = vi.fn();
|
||||
const land = vi.fn();
|
||||
const paging: HistoryPaging = { snapshot: "one", revision: 1, hasMore: true, busy: false, failed: false, more: vi.fn(async () => {}), reload: vi.fn(async () => {}) };
|
||||
const paging: HistoryPaging = { snapshot: "one", revision: 1, hasMore: true, busy: false, failed: false, earlier: 0, more: vi.fn(async () => {}), reload: vi.fn(async () => {}) };
|
||||
const hook = renderHook(({ revision, snapshot = "one", hidden = false }) => useHistoryAnchor({ ...paging, revision, snapshot }, scroll, [[item]], hidden, generation, release, land),
|
||||
{ initialProps: { revision: 1, snapshot: "one", hidden: false } });
|
||||
return { ...hook, generation, land, paging };
|
||||
|
||||
@@ -79,9 +79,9 @@ export function apply(look: Appearance | null, busy = false) {
|
||||
if (look?.textSize && look.textSize !== 1) style.setProperty("--fs-scale", String(look.textSize));
|
||||
else style.removeProperty("--fs-scale");
|
||||
|
||||
// Reading size moves the transcript's prose alone, so the frame around it
|
||||
// stays where the layout put it.
|
||||
style.setProperty("--read", `${readSizeOf(look?.readSize)}px`);
|
||||
const readSize = readSizeOf(look?.readSize);
|
||||
style.setProperty("--read", `${readSize}px`);
|
||||
style.setProperty("--read-scale", String(readSize / readDefault()));
|
||||
|
||||
// How far that prose runs is the other half of the same question, and the
|
||||
// only one with two honest answers: a line short enough to read without
|
||||
|
||||
@@ -59,13 +59,19 @@ const app = () => document.querySelector<HTMLElement>(".app")!;
|
||||
const toggleSidebar = () => userEvent.click(document.querySelector<HTMLElement>(".gutter-l .grip")!);
|
||||
|
||||
async function openAppearance() {
|
||||
await userEvent.click(screen.getByRole("button", { name: "沙盒" }));
|
||||
await userEvent.click(document.querySelector<HTMLElement>('[data-action="chrome.settings"]')!);
|
||||
await userEvent.click(await screen.findByRole("tab", { name: /外观/ }));
|
||||
await screen.findByRole("group", { name: "图标栏" });
|
||||
}
|
||||
const choice = (name: string) => screen.getByRole("button", { name });
|
||||
const pressed = (name: string) => choice(name).getAttribute("aria-pressed");
|
||||
|
||||
it("offers the tools destination only through the navigation entries, not a second button in the top bar", async () => {
|
||||
await open();
|
||||
expect(screen.queryByRole("button", { name: "沙盒" })).toBeNull();
|
||||
expect(within(screen.getByRole("navigation", { name: "主导航" })).getByRole("button", { name: "工具与权限" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("always draws the rail by default, with every entry in it", async () => {
|
||||
await open();
|
||||
expect(nav()).not.toBeNull();
|
||||
|
||||
@@ -47,7 +47,7 @@ async function mount(running = false, pathless = false) {
|
||||
const [report, setReport] = useState<PaneReport | null>(null);
|
||||
const onReport = useCallback((_: string, next: PaneReport) => setReport(next), []);
|
||||
return <>
|
||||
<Chrome port={port} status={report?.status ?? null} title="active" steer={0} onSettings={() => {}} account={null} theme="light" onTheme={() => {}} onFind={() => {}} view="flow" rows={0} surfaces={0} onView={() => {}} />
|
||||
<Chrome port={port} status={report?.status ?? null} title="active" steer={0} account={null} theme="light" onTheme={() => {}} onFind={() => {}} view="flow" rows={0} surfaces={0} onView={() => {}} />
|
||||
<Pane port={port} rt={{ id: "r1", base: "/rt/r1", root: "/workspace", name: "workspace", sessionPath }}
|
||||
title="active" active visible sideHost={null} side={false} onFocus={() => {}} onReport={onReport}
|
||||
onSessionChanged={() => {}} pulse={0} findPulse={0} onSettings={() => {}} needsProject={false}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment jsdom
|
||||
import { useState } from "react";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, expect, it, vi } from "vitest";
|
||||
import "./testkit";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { Composer } from "./Composer";
|
||||
import { install, useFileDrop } from "./filedrop";
|
||||
import { PATH_TYPE } from "./filedrag";
|
||||
|
||||
beforeAll(install);
|
||||
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
|
||||
|
||||
function transfer(path: string) {
|
||||
const data = new Map<string, string>([[PATH_TYPE, path]]);
|
||||
return {
|
||||
files: [], items: [], effectAllowed: "copy", dropEffect: "none",
|
||||
get types() { return [...data.keys()]; },
|
||||
setData: (type: string, value: string) => data.set(type, value),
|
||||
getData: (type: string) => data.get(type) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function Pane({ port }: { port: MockPort }) {
|
||||
return (
|
||||
<section className="pane">
|
||||
<div data-testid="transcript">earlier answers</div>
|
||||
<Composer port={port} status={null} running={false} onSubmit={vi.fn(async () => true)} onChanged={vi.fn()} onError={vi.fn()} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
it("takes a file dropped anywhere on the pane, not only on the composer", async () => {
|
||||
const port = new MockPort();
|
||||
const dropRefs = vi.spyOn(port, "dropRefs").mockImplementation(async (paths) => paths.map((path) => ({ path: "README.md", ref: `@README.md`, image: path.endsWith(".png") })));
|
||||
render(<Pane port={port} />);
|
||||
const transcript = screen.getByTestId("transcript");
|
||||
const dataTransfer = transfer("/workspace/README.md");
|
||||
fireEvent.dragOver(transcript, { dataTransfer });
|
||||
expect(screen.getByText("松手 → 引用 1 个文件")).toBeTruthy();
|
||||
fireEvent.drop(transcript, { dataTransfer });
|
||||
await screen.findByRole("button", { name: "移除 README.md" });
|
||||
expect(dropRefs).toHaveBeenCalledWith(["/workspace/README.md"]);
|
||||
});
|
||||
|
||||
it("leaves a drop on a nested zone to that zone even when a wider one mounted first", async () => {
|
||||
const outer = vi.fn();
|
||||
const inner = vi.fn();
|
||||
function Nest() {
|
||||
const [show, setShow] = useState(false);
|
||||
const wide = useFileDrop(outer, () => {});
|
||||
const narrow = useFileDrop(inner, () => {});
|
||||
return (
|
||||
<div data-testid="wide" ref={wide}>
|
||||
<button onClick={() => setShow(true)}>open</button>
|
||||
{show && <div data-testid="narrow" ref={narrow} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
render(<Nest />);
|
||||
fireEvent.click(screen.getByText("open"));
|
||||
const dataTransfer = transfer("/workspace/a.txt");
|
||||
act(() => { fireEvent.drop(screen.getByTestId("narrow"), { dataTransfer }); });
|
||||
await waitFor(() => expect(inner).toHaveBeenCalledTimes(1));
|
||||
expect(outer).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Column } from "./FlowResizer";
|
||||
import type { AgentPort, ContextBreakdown, McpEntry, SessionStatus } from "../port/port";
|
||||
import type { HubPort, RuntimeView } from "../port/hub";
|
||||
import type { ConnectOffer } from "./ConnectNote";
|
||||
@@ -75,10 +74,6 @@ export interface PaneProps {
|
||||
dockW: number;
|
||||
dockMax: number;
|
||||
onDockW: (w: number) => void;
|
||||
// The reading column's width, the two ends it is held between, and what the
|
||||
// sidebars are drawn at once it asks. Absent where nothing can drag it: a pane
|
||||
// rendered on its own keeps the width its stylesheet gives it.
|
||||
column?: Column;
|
||||
manualBrowser?: boolean;
|
||||
onManualBrowser?: (on: boolean) => void;
|
||||
// The window's failed-request notice, handed only to the pane in front.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import "./testkit";
|
||||
import { App } from "./App";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
import { boot, STORAGE } from "../i18n";
|
||||
import { refresh } from "./viewport";
|
||||
|
||||
beforeEach(async () => {
|
||||
await import("./Settings");
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
localStorage.setItem(STORAGE, "zh");
|
||||
boot();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
vi.spyOn(document.body, "clientWidth", "get").mockReturnValue(1600);
|
||||
vi.spyOn(document.body, "clientHeight", "get").mockReturnValue(900);
|
||||
refresh();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function open() {
|
||||
const hub = new MockHub();
|
||||
const build = hub.portFor.bind(hub);
|
||||
const prepared = new WeakSet();
|
||||
hub.portFor = (rt) => {
|
||||
const port = build(rt);
|
||||
if (!prepared.has(port)) {
|
||||
prepared.add(port);
|
||||
port.providerSetup = async () => null;
|
||||
port.welcomeSeen = async () => true;
|
||||
}
|
||||
return port;
|
||||
};
|
||||
render(<App hub={hub} />);
|
||||
await screen.findByRole("combobox", { name: "任务输入" });
|
||||
}
|
||||
|
||||
it("draws no handles on the reading column and ignores a width an older build remembered", async () => {
|
||||
localStorage.setItem("rx-flow-w", "900");
|
||||
await open();
|
||||
expect(document.querySelector(".flowgrip")).toBeNull();
|
||||
expect(document.querySelector<HTMLElement>(".app")!.style.getPropertyValue("--flow-w")).toBe("");
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Settings } from "./Settings";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.useRealTimers(); });
|
||||
|
||||
function draw(at: string, port = new MockPort()) {
|
||||
render(<Settings
|
||||
hub={new MockHub()} port={port} status={null}
|
||||
theme="light" onTheme={() => {}} contrast="" onContrast={() => {}}
|
||||
weight="" onWeight={() => {}} look={{}} onLook={() => {}}
|
||||
reloadThemes={() => {}} onClose={() => {}} onChanged={() => {}}
|
||||
onError={() => {}} at={at} account={null} accountUnread="" reloadAccount={() => {}}
|
||||
/>);
|
||||
}
|
||||
|
||||
it.each([
|
||||
["appearance", "版本"],
|
||||
["tools", "会话"],
|
||||
])("starts the next category at the top after scrolling %s", async (from, to) => {
|
||||
draw(from);
|
||||
const panel = screen.getByRole("tabpanel");
|
||||
panel.scrollTop = 1800;
|
||||
panel.scrollLeft = 120;
|
||||
await userEvent.click(screen.getByRole("tab", { name: new RegExp(`^${to}(?!间消息)`) }));
|
||||
expect(screen.getByRole("heading", { name: to, level: 2 })).toBeTruthy();
|
||||
expect(screen.getByRole("tabpanel").scrollTop).toBe(0);
|
||||
expect(screen.getByRole("tabpanel").scrollLeft).toBe(0);
|
||||
});
|
||||
|
||||
it("starts a keyboard-selected category at the top and keeps focus on its tab", async () => {
|
||||
draw("tools");
|
||||
const current = screen.getByRole("tab", { name: /^工具与权限/ });
|
||||
current.focus();
|
||||
screen.getByRole("tabpanel").scrollTop = 1600;
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
const next = screen.getByRole("tab", { name: "会话间消息" });
|
||||
expect(next.getAttribute("aria-selected")).toBe("true");
|
||||
expect(document.activeElement).toBe(next);
|
||||
expect(screen.getByRole("tabpanel").scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the current category's scroll and unsaved input when it is selected again", async () => {
|
||||
draw("persona:persona");
|
||||
const prefix = await screen.findByRole<HTMLTextAreaElement>("textbox", { name: "系统提示词开头" });
|
||||
await waitFor(() => expect(prefix.disabled).toBe(false));
|
||||
await userEvent.type(prefix, "Unsaved draft");
|
||||
const panel = screen.getByRole("tabpanel");
|
||||
panel.scrollTop = 650;
|
||||
await userEvent.click(screen.getByRole("tab", { name: "人设" }));
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "搜索设置" }), "字体");
|
||||
expect(screen.getByRole("tabpanel").scrollTop).toBe(650);
|
||||
expect(screen.getByRole<HTMLTextAreaElement>("textbox", { name: "系统提示词开头" }).value).toBe("Unsaved draft");
|
||||
});
|
||||
|
||||
function anchorScrolling() {
|
||||
return vi.spyOn(Element.prototype, "scrollIntoView").mockImplementation(function (this: Element) {
|
||||
const panel = this.closest<HTMLElement>('[role="tabpanel"]');
|
||||
if (panel) panel.scrollTop = 420;
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps a search result's anchor after switching away from a scrolled category", async () => {
|
||||
const scroll = anchorScrolling();
|
||||
draw("session");
|
||||
screen.getByRole("tabpanel").scrollTop = 1500;
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "搜索设置" }), "柔和");
|
||||
await userEvent.click(await screen.findByRole("option", { name: /文字对比度/ }));
|
||||
const target = document.querySelector<HTMLElement>('[data-setting="contrast"]')!;
|
||||
expect(target.closest<HTMLDetailsElement>("details")?.open).toBe(true);
|
||||
expect(scroll.mock.contexts).toContain(target);
|
||||
expect(screen.getByRole("tabpanel").scrollTop).toBe(420);
|
||||
});
|
||||
|
||||
it("opens an initial anchor inside its disclosure", async () => {
|
||||
const scroll = anchorScrolling();
|
||||
draw("appearance:contrast");
|
||||
await screen.findByRole("heading", { name: "外观", level: 2 });
|
||||
const target = document.querySelector<HTMLElement>('[data-setting="contrast"]')!;
|
||||
expect(target.closest<HTMLDetailsElement>("details")?.open).toBe(true);
|
||||
expect(scroll.mock.contexts).toContain(target);
|
||||
expect(screen.getByRole("tabpanel").scrollTop).toBe(420);
|
||||
});
|
||||
|
||||
it("waits for a delayed setting before starting the landing feedback lifetime", async () => {
|
||||
const port = new MockPort();
|
||||
const saved = await port.observabilitySettings();
|
||||
let finish!: (value: typeof saved) => void;
|
||||
vi.spyOn(port, "observabilitySettings").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
|
||||
vi.useFakeTimers();
|
||||
const scroll = anchorScrolling();
|
||||
draw("network:trace-export", port);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1200); });
|
||||
expect(document.querySelector('[data-setting="trace-export"]')).toBeNull();
|
||||
await act(async () => { finish(saved); });
|
||||
const target = document.querySelector<HTMLElement>('[data-setting="trace-export"]')!;
|
||||
expect(target.getAttribute("data-landed")).toBe("");
|
||||
expect(scroll.mock.contexts).toContain(target);
|
||||
});
|
||||
|
||||
it("lands on a searched setting that arrives after navigation", async () => {
|
||||
const port = new MockPort();
|
||||
const saved = await port.observabilitySettings();
|
||||
let finish!: (value: typeof saved) => void;
|
||||
vi.spyOn(port, "observabilitySettings").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
|
||||
const scroll = anchorScrolling();
|
||||
draw("session", port);
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "搜索设置" }), "追踪导出");
|
||||
await userEvent.click(await screen.findByRole("option", { name: /追踪导出/ }));
|
||||
await act(async () => { finish(saved); });
|
||||
const target = document.querySelector<HTMLElement>('[data-setting="trace-export"]')!;
|
||||
expect(target.getAttribute("data-landed")).toBe("");
|
||||
expect(scroll.mock.contexts).toContain(target);
|
||||
});
|
||||
|
||||
it("cancels a pending landing when the user chooses another category", async () => {
|
||||
const port = new MockPort();
|
||||
const saved = await port.observabilitySettings();
|
||||
let finish!: (value: typeof saved) => void;
|
||||
vi.spyOn(port, "observabilitySettings").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
|
||||
draw("network:trace-export", port);
|
||||
await userEvent.click(screen.getByRole("tab", { name: /^版本/ }));
|
||||
await act(async () => { finish(saved); });
|
||||
await userEvent.click(screen.getByRole("tab", { name: /^网络/ }));
|
||||
const target = await waitFor(() => {
|
||||
const found = document.querySelector<HTMLElement>('[data-setting="trace-export"]');
|
||||
expect(found).toBeTruthy();
|
||||
return found!;
|
||||
});
|
||||
expect(target.hasAttribute("data-landed")).toBe(false);
|
||||
});
|
||||
@@ -40,7 +40,7 @@ it.each(["completed", "pending"])("keeps a %s machine theme import when the comm
|
||||
render(<App hub={hub} />);
|
||||
await screen.findByRole("combobox", { name: "任务输入" });
|
||||
await waitFor(() => expect(sessionStorage.getItem("rx-active-pane")).toBe(remote.id));
|
||||
await userEvent.click(screen.getByRole("button", { name: "沙盒" }));
|
||||
await userEvent.click(document.querySelector<HTMLElement>('[data-action="chrome.settings"]')!);
|
||||
await userEvent.click(await screen.findByRole("tab", { name: /外观/ }));
|
||||
const sheet = document.querySelector(".prefs")!;
|
||||
fireEvent.change(sheet.querySelector('input[data-action="theme.import"]')!, {
|
||||
|
||||
@@ -41,7 +41,7 @@ it.each(["completed", "pending"])("keeps the %s machine theme inventory when the
|
||||
render(<App hub={hub} />);
|
||||
await screen.findByRole("combobox", { name: "任务输入" });
|
||||
await waitFor(() => expect(sessionStorage.getItem("rx-active-pane")).toBe(remote.id));
|
||||
await userEvent.click(screen.getByRole("button", { name: "沙盒" }));
|
||||
await userEvent.click(document.querySelector<HTMLElement>('[data-action="chrome.settings"]')!);
|
||||
await userEvent.click(await screen.findByRole("tab", { name: /外观/ }));
|
||||
const sheet = document.querySelector(".prefs")!;
|
||||
if (state === "completed") await act(async () => finishLocal(packs));
|
||||
|
||||
@@ -176,6 +176,24 @@ describe("decorative roles are themeable and the status hues are not", () => {
|
||||
expect(read("--link")).toBe("#eeeeee");
|
||||
});
|
||||
|
||||
const SYNTAX = {
|
||||
synKeyword: "--syn-key", synString: "--syn-str", synNumber: "--syn-num", synFunction: "--syn-fn",
|
||||
synComment: "--syn-comment", synType: "--syn-type", synOperator: "--syn-op", synPunctuation: "--syn-punc", synVariable: "--syn-var",
|
||||
};
|
||||
|
||||
it("maps each of the nine syntax tokens onto its own variable", () => {
|
||||
const light = Object.fromEntries(Object.keys(SYNTAX).map((name, i) => [name, `#${String(i + 1).repeat(6)}`]));
|
||||
apply({ ...pack, tokens: { ...pack.tokens, light: { ...pack.tokens.light, ...light } } }, "light");
|
||||
Object.entries(SYNTAX).forEach(([name, variable]) => expect(read(variable), name).toBe(light[name]));
|
||||
expect(new Set(Object.values(light)).size).toBe(9);
|
||||
});
|
||||
|
||||
it("writes no variable for a syntax token the pack does not name, so each surface keeps its own default", () => {
|
||||
apply({ ...pack, tokens: { ...pack.tokens, light: { ...pack.tokens.light, synKeyword: "#123456" } } }, "light");
|
||||
expect(read("--syn-key")).toBe("#123456");
|
||||
["--syn-comment", "--syn-type", "--syn-op", "--syn-punc", "--syn-var"].forEach((variable) => expect(read(variable), variable).toBe(""));
|
||||
});
|
||||
|
||||
it("carries a pack's own shadow onto the one variable menus, popovers and dialogs cast", () => {
|
||||
const shadow = "0 0 0 1px #00000040, 0 8px 24px -8px #00000033";
|
||||
apply({ ...pack, tokens: { light: { ...pack.tokens.light, shadowFloat: shadow }, dark: pack.tokens.dark } }, "light");
|
||||
|
||||
@@ -33,6 +33,11 @@ const SURFACE: Record<string, string[]> = {
|
||||
synString: ["--syn-str"],
|
||||
synNumber: ["--syn-num"],
|
||||
synFunction: ["--syn-fn"],
|
||||
synComment: ["--syn-comment"],
|
||||
synType: ["--syn-type"],
|
||||
synOperator: ["--syn-op"],
|
||||
synPunctuation: ["--syn-punc"],
|
||||
synVariable: ["--syn-var"],
|
||||
// Shape and type. --r-pill is absent because a pill is a shape rather than a
|
||||
// size: a pack that could set it would round a button into something else.
|
||||
radiusXs: ["--r-xs"],
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: "sentence" });
|
||||
|
||||
/** The first sentence of a thought, cut to `max` characters with an ellipsis. */
|
||||
export function firstSentence(reasoning: string, max: number): string {
|
||||
const text = reasoning.trim();
|
||||
if (!text) return "";
|
||||
const first = segmenter.segment(text)[Symbol.iterator]().next().value?.segment.trim() ?? "";
|
||||
const chars = [...first.replace(/\s+/g, " ")];
|
||||
return chars.length > max ? `${chars.slice(0, max).join("")}…` : chars.join("");
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { toolBlurb } from "./toolBlurbs";
|
||||
|
||||
const catalogue = new URL("../../../../internal/runtime/usecap/testdata/capability_list.json", import.meta.url);
|
||||
const peerTools = ["dispatch_to_project", "list_peers", "send_peer_message"];
|
||||
const configTools = ["advise", "best_of_n", "run_script", "read_mcp_resource", "present_files"];
|
||||
const mediaTools = ["web_search", "read_video", "import_web_image", "computer_act", "computer_read"];
|
||||
|
||||
describe("the tool blurbs", () => {
|
||||
@@ -14,7 +15,7 @@ describe("the tool blurbs", () => {
|
||||
expect(tools.filter((name) => toolBlurb(name) === undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("cover the peer, media and web tools that assembly adds outside the catalogue", () => {
|
||||
expect([...peerTools, ...mediaTools].filter((name) => toolBlurb(name) === undefined)).toEqual([]);
|
||||
it("cover the peer, media, web and config-gated tools that assembly adds outside the catalogue", () => {
|
||||
expect([...peerTools, ...mediaTools, ...configTools].filter((name) => toolBlurb(name) === undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import { t } from "../i18n";
|
||||
// this build has no line for, such as one an MCP server declares.
|
||||
export function toolBlurb(name: string): string | undefined {
|
||||
switch (name) {
|
||||
case "advise":
|
||||
return t("向更强的模型求助:它读完到目前为止的对话后,用方案、诊断或审查回答你的问题;它自己不执行任何东西。");
|
||||
case "ask":
|
||||
return t("向你提问:遇到只有你能决定或提供的事时,模型用它来问你。");
|
||||
case "await_user":
|
||||
@@ -13,6 +15,8 @@ export function toolBlurb(name: string): string | undefined {
|
||||
return t("在命令行里执行命令并返回输出,用于构建、测试、git、包管理等。");
|
||||
case "bash_output":
|
||||
return t("读取后台任务新产生的输出。");
|
||||
case "best_of_n":
|
||||
return t("把一个明确的编码任务并行做 2–4 遍,每遍在独立的 git 工作树里;再由评审比较,只把最好的一份写回工作区。");
|
||||
case "browser_act":
|
||||
return t("在浏览器页面上执行真实操作,比如点击和输入。");
|
||||
case "browser_open":
|
||||
@@ -89,8 +93,12 @@ export function toolBlurb(name: string): string | undefined {
|
||||
return t("修改 Jupyter 笔记本(.ipynb)里的一个单元格,可替换或插入。");
|
||||
case "parallel_tasks":
|
||||
return t("同时派出多个只读子代理任务,全部完成后返回摘要。");
|
||||
case "present_files":
|
||||
return t("把已有的最终文件以可点击的卡片展示给你,包括命令行生成的文档;路径须在工作区或已授权的文件夹内,打开的是当前文件而不是快照,也不保证内容正确。");
|
||||
case "read_file":
|
||||
return t("读取文本文件,可指定起始行和行数,输出带行号。");
|
||||
case "read_mcp_resource":
|
||||
return t("读取已连接的 MCP 服务器自己列出的一份资源(文档、记录、文件);服务器没列出的地址会被拒绝,返回的是服务器的数据,不是你的指令。");
|
||||
case "read_only_skill":
|
||||
return t("以只读方式调用一个技能;子代理类技能在只读的隔离会话里运行。");
|
||||
case "read_only_task":
|
||||
@@ -111,6 +119,8 @@ export function toolBlurb(name: string): string | undefined {
|
||||
return t("在隔离的子代理里结合网页内容和本地代码做调研,适合既要外部资料又要本地验证的问题。");
|
||||
case "review":
|
||||
return t("在隔离的子代理里审查待提交的改动,指出正确性、安全、缺测试和隐藏的行为变化;只读。");
|
||||
case "run_script":
|
||||
return t("运行一小段 Starlark(类 Python)脚本来调用多个工具,几步相互依赖的操作只需一次往返,只返回脚本打印的内容;每次调用都和模型直接调用一样受检查和审批。");
|
||||
case "run_skill":
|
||||
return t("调用系统提示里技能索引中的一份技能手册。");
|
||||
case "security_review":
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEffect, useState } from "react";
|
||||
* cannot move a pixel however far its slider is dragged. It measures the
|
||||
* preview, which is drawn at the window's shape, rather than assuming one.
|
||||
* Both axes count as free until the image decodes: never disable on a guess. */
|
||||
export function useCrop(url: string | undefined, kind: "image" | "video" | undefined, box: HTMLElement | null) {
|
||||
export function useCrop(url: string | undefined, kind: "image" | "video" | "scene" | undefined, box: HTMLElement | null) {
|
||||
const [image, setImage] = useState(0);
|
||||
const [frame, setFrame] = useState(0);
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ it("continues searching when fast page responses batch busy state transitions",
|
||||
|
||||
it("searches the next page when a committed revision is the only changed read state", () => {
|
||||
const more = vi.fn(async () => {});
|
||||
const paging = { snapshot: "one", revision: 1, hasMore: true, busy: false, failed: false, more, reload: vi.fn(async () => {}) };
|
||||
const paging = { snapshot: "one", revision: 1, hasMore: true, busy: false, failed: false, earlier: 0, more, reload: vi.fn(async () => {}) };
|
||||
const { rerender } = renderHook(({ revision }) => useHistorySearch({ ...paging, revision }, true, "needle"), { initialProps: { revision: 1 } });
|
||||
expect(more).toHaveBeenCalledTimes(1);
|
||||
rerender({ revision: 2 });
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface HistoryPaging {
|
||||
hasMore: boolean;
|
||||
busy: boolean;
|
||||
failed: boolean;
|
||||
/** How many raw messages sit before the loaded window. The rail draws only
|
||||
* what is mounted, so without this a paged conversation looks shorter than
|
||||
* it is until someone scrolls up. */
|
||||
earlier: number;
|
||||
more: () => Promise<void>;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
@@ -41,7 +45,7 @@ export function useHistoryPages(port: Pick<AgentPort, "history" | "historyPage">
|
||||
const owner = useMemo(() => ({ epoch: 0, cursor: "", snapshot: "", before: 0, total: 0, paging: !!port.historyPage, more: null as Promise<void> | null }), [port]);
|
||||
const current = useRef(owner);
|
||||
current.current = owner;
|
||||
const [state, setState] = useState({ snapshot: "", revision: 0, hasMore: false, busy: false, failed: false });
|
||||
const [state, setState] = useState({ snapshot: "", revision: 0, hasMore: false, busy: false, failed: false, earlier: 0 });
|
||||
useEffect(() => () => { owner.epoch++; }, [owner]);
|
||||
|
||||
const load = useCallback(async (applies: () => boolean = () => true) => {
|
||||
@@ -67,7 +71,7 @@ export function useHistoryPages(port: Pick<AgentPort, "history" | "historyPage">
|
||||
owner.before = page?.start ?? 0;
|
||||
owner.total = page?.total ?? 0;
|
||||
dispatch({ kind: "__restore", ...restored });
|
||||
setState((old) => ({ snapshot: owner.snapshot, revision: old.revision + 1, hasMore: !!owner.cursor, busy: false, failed: false }));
|
||||
setState((old) => ({ snapshot: owner.snapshot, revision: old.revision + 1, hasMore: !!owner.cursor, busy: false, failed: false, earlier: owner.before }));
|
||||
} catch (error) {
|
||||
if (valid()) setState((old) => ({ ...old, busy: false, failed: true }));
|
||||
throw error;
|
||||
@@ -90,7 +94,7 @@ export function useHistoryPages(port: Pick<AgentPort, "history" | "historyPage">
|
||||
owner.before = page.start;
|
||||
owner.more = null;
|
||||
dispatch({ kind: "__history_prepend", ...projection(page) });
|
||||
setState((old) => ({ ...old, revision: old.revision + 1, hasMore: !!owner.cursor, busy: false, failed: false }));
|
||||
setState((old) => ({ ...old, revision: old.revision + 1, hasMore: !!owner.cursor, busy: false, failed: false, earlier: owner.before }));
|
||||
}).catch(async (error) => {
|
||||
if (!valid()) return;
|
||||
if (error instanceof HttpError && error.reason?.code === "history.snapshot_changed") { await load(); return; }
|
||||
|
||||
@@ -39,7 +39,9 @@ export function useIntake({ root, onReceive }: Options): {
|
||||
const ref = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
pane.current = node?.closest<HTMLElement>(".pane") ?? null;
|
||||
const off = zone(node);
|
||||
// The whole pane takes the drop: aiming at the composer is not something
|
||||
// a reader should have to do with a file in hand.
|
||||
const off = zone(pane.current ?? node);
|
||||
return () => {
|
||||
pane.current?.removeAttribute("data-intake");
|
||||
pane.current = null;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, type Dispatch, type RefObject, type SetStateAction } from "react";
|
||||
|
||||
export function useSettingLanding(root: RefObject<HTMLDivElement | null>, anchor: string, section: string, setAnchor: Dispatch<SetStateAction<string>>) {
|
||||
useEffect(() => {
|
||||
if (!anchor) return;
|
||||
const col = root.current?.querySelector(".prefs-col");
|
||||
if (!col) return;
|
||||
let target: HTMLElement | null = null;
|
||||
let done: ReturnType<typeof setTimeout> | undefined;
|
||||
const behavior = matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
|
||||
const aim = () => {
|
||||
const found = col.querySelector<HTMLElement>(`#set-${CSS.escape(anchor)}`);
|
||||
if (!found) return;
|
||||
for (let parent = found.closest("details"); parent; parent = parent.parentElement?.closest("details") ?? null)
|
||||
parent.open = true;
|
||||
if (target !== found) {
|
||||
target?.removeAttribute("data-landed");
|
||||
target = found;
|
||||
target.setAttribute("data-landed", "");
|
||||
clearTimeout(done);
|
||||
done = setTimeout(() => setAnchor(""), 900);
|
||||
}
|
||||
found.scrollIntoView({ block: "start", behavior });
|
||||
};
|
||||
const grew = new ResizeObserver(aim);
|
||||
const arrived = new MutationObserver(aim);
|
||||
grew.observe(col);
|
||||
arrived.observe(col, { childList: true, subtree: true });
|
||||
aim();
|
||||
return () => {
|
||||
clearTimeout(done);
|
||||
grew.disconnect();
|
||||
arrived.disconnect();
|
||||
target?.removeAttribute("data-landed");
|
||||
};
|
||||
}, [root, anchor, section, setAnchor]);
|
||||
}
|
||||
@@ -258,7 +258,7 @@ it("supports reader keyboard invocation, navigation and dismissal without submit
|
||||
select(read, "restart");
|
||||
expect(fireEvent.keyDown(read, { key: "F10", shiftKey: true })).toBe(false);
|
||||
const copy = await screen.findByRole("menuitem", { name: "复制" });
|
||||
expect(document.activeElement).toBe(copy);
|
||||
await waitFor(() => expect(document.activeElement).toBe(copy));
|
||||
await user.keyboard("{End}");
|
||||
expect(document.activeElement).toBe(screen.getByRole("menuitem", { name: "加入对话" }));
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
@@ -53,7 +53,7 @@ it.each([[false, "原目录"], [true, "隔离目录"]])("identifies the conversa
|
||||
const current = isolated ? copy : root;
|
||||
vi.spyOn(port, "workspaces").mockResolvedValue({ current, isolated, canSwitch: true, canIsolate: true, recents: [] });
|
||||
render(<Chrome port={port} status={{ workspaceRoot: current, sessionPath: path } as never}
|
||||
title="工作" steer={0} onSettings={vi.fn()} account={null} theme="dark" onTheme={vi.fn()}
|
||||
title="工作" steer={0} account={null} theme="dark" onTheme={vi.fn()}
|
||||
onFind={vi.fn()} view="flow" rows={0} surfaces={0} onView={vi.fn()} />);
|
||||
expect(await screen.findByText(label)).toBeTruthy();
|
||||
expect(screen.getByText(label).getAttribute("title")).toBe(current);
|
||||
@@ -65,7 +65,7 @@ it("ignores old checkout metadata when switching conversations before it arrives
|
||||
let finish!: (value: Awaited<ReturnType<MockPort["workspaces"]>>) => void;
|
||||
vi.spyOn(old, "workspaces").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
|
||||
vi.spyOn(next, "workspaces").mockResolvedValue({ current: root, isolated: false, canSwitch: true, canIsolate: true, recents: [] });
|
||||
const props = { title: "工作", steer: 0, onSettings: vi.fn(), account: null, theme: "dark" as const,
|
||||
const props = { title: "工作", steer: 0, account: null, theme: "dark" as const,
|
||||
onTheme: vi.fn(), onFind: vi.fn(), view: "flow" as const, rows: 0, surfaces: 0, onView: vi.fn() };
|
||||
const { rerender } = render(<Chrome {...props} port={old} status={{ workspaceRoot: copy } as never} />);
|
||||
rerender(<Chrome {...props} port={next} status={{ workspaceRoot: root } as never} />);
|
||||
|
||||
+4
-4
@@ -22,22 +22,22 @@ require (
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.3 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.4 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.1.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
|
||||
github.com/zalando/go-keyring v0.2.8 // indirect
|
||||
golang.org/x/crypto v0.58.0 // indirect
|
||||
golang.org/x/net v0.61.0 // indirect
|
||||
golang.org/x/text v0.43.0 // indirect
|
||||
modernc.org/libc v1.77.1 // indirect
|
||||
modernc.org/libc v1.79.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
modernc.org/sqlite v1.60.1 // indirect
|
||||
modernc.org/sqlite v1.61.0 // indirect
|
||||
mvdan.cc/sh/v3 v3.14.1 // indirect
|
||||
)
|
||||
|
||||
|
||||
+18
-18
@@ -18,16 +18,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
|
||||
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
||||
github.com/godbus/dbus/v5 v5.2.4 h1:F2gUCy++NMIxGmo9J+z5le/+ZC8W1OgeR51P4i9ZB/U=
|
||||
github.com/godbus/dbus/v5 v5.2.4/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
|
||||
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -44,8 +44,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
|
||||
github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
|
||||
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
@@ -74,24 +74,24 @@ golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
|
||||
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
|
||||
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
|
||||
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
|
||||
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
|
||||
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
|
||||
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
|
||||
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
|
||||
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
|
||||
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
|
||||
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
@@ -100,8 +100,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
|
||||
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
|
||||
modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
|
||||
modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
owner: @SivanCola
|
||||
backup: @esengine
|
||||
status: active
|
||||
reviewed: 2026-10-25
|
||||
---
|
||||
|
||||
# Checkpoint cancellation / 检查点准入取消
|
||||
|
||||
## Cause and owner
|
||||
|
||||
The checkpoint barrier waited on a condition variable without the invocation's
|
||||
context. Cancellation could leave a call waiting behind an exclusive checkpoint
|
||||
operation, then allow it to enter tool-use hooks or execution after that owner
|
||||
released. The Agent could not finish its deferred admission cleanup until then.
|
||||
|
||||
The shared barrier now accepts a context for writer and exclusive admission.
|
||||
Cancellation wakes condition waiters under the same mutex used for admission;
|
||||
the loop checks cancellation before changing ownership. A cancelled waiter does
|
||||
not decrement active writers, release exclusive access or advance generation.
|
||||
|
||||
Existing context-free methods retain their background-context behavior.
|
||||
|
||||
`MutationObserver.BeginWriteContext` uses this admission, retains the existing
|
||||
durable undo retirement, and checks cancellation again after preparation. A
|
||||
successful permit has an idempotent release closure: repeated cleanup cannot
|
||||
release a second writer.
|
||||
|
||||
The Agent passes its current invocation context after
|
||||
real target resolution and permission, before synchronous hooks or execution.
|
||||
|
||||
## Authority and data flow
|
||||
|
||||
| Boundary | Input and decision |
|
||||
| --- | --- |
|
||||
| Target and permission | Existing tool/proxy resolution and permission gates; unchanged grants |
|
||||
| Admission | Invocation context plus current barrier ownership; cancellation only abandons this request |
|
||||
| Undo preparation | Existing checkpoint Store and transaction persistence; completed retirement stays retired |
|
||||
| User code | Starts only after preparation returns a live permit; tool `Executed` is not forged |
|
||||
| Cleanup | Existing invocation defer and one permit's idempotent release |
|
||||
|
||||
The context callback only broadcasts the condition variable. It cannot grant
|
||||
admission, change another caller's ownership or interrupt an active writer.
|
||||
|
||||
No file path, command, shell launcher, approval bypass or arbitrary callback is
|
||||
introduced into the barrier. No persisted format, cross-process claim, wire
|
||||
field, tool schema, provider prefix, permission setting or default changes.
|
||||
|
||||
## Compatibility and limits
|
||||
|
||||
The barrier coordinates a Store's writers against rewind. Concurrent writers
|
||||
remain supported; this is not file-level exclusion or a cross-process lease.
|
||||
|
||||
Existing nonblocking `TryEnter*` calls keep their behavior. Production rewind
|
||||
uses these nonblocking methods; the new exclusive context method does not turn
|
||||
rewind into a queued operation or change its busy refusal.
|
||||
|
||||
Cancellation governs admission, not a revocable execution permit. Once user
|
||||
code starts, its own cancellation and physical completion rules still apply.
|
||||
|
||||
Undo persistence and ordinary mutex acquisition are not interrupted. If
|
||||
cancellation occurs during durable preparation, the completed retirement is
|
||||
preserved, this permit is released, and the call stops before user code.
|
||||
|
||||
Old undo is not restored after another action may have invalidated it.
|
||||
|
||||
Background task/fleet callers use identified, cancellable registration outside
|
||||
the registry mutex; see [background writer admission](CHECKPOINT_WRITER_ADMISSION_SECURITY.md).
|
||||
The legacy context-free method remains available to existing callers.
|
||||
|
||||
An asynchronous hook
|
||||
can outlive its synchronous callback; this slice does not register or reconcile
|
||||
that process.
|
||||
|
||||
No FIFO, bounded wait, model-capacity separation, complete effect
|
||||
receipt, safe isolated integration or whole-series completion is claimed.
|
||||
|
||||
## Reachability and validation
|
||||
|
||||
Deterministic Agent tests hold an exclusive barrier, park direct and deferred
|
||||
writer/reader-hook calls, cancel, then require completion while the owner still
|
||||
holds access. They verify no callback/tool entry or changed user bytes, and no
|
||||
leaked writer. Original owners compile and fail these behavioral assertions.
|
||||
|
||||
A live actor's aborted checkpoint wait must also release its provisional
|
||||
workspace paths so a second Owner can acquire them before the actor ends.
|
||||
|
||||
Real `boot.Build` tests retain built-in tools, capability resolution and shell
|
||||
hooks. The fixture injects a Store-bound observer through the public Agent
|
||||
binding and cancels at the permission boundary.
|
||||
|
||||
Original reader-hook cases
|
||||
remain blocked and attempt hook entry after cleanup releases exclusive access;
|
||||
the shell launcher itself refuses that cancelled command.
|
||||
|
||||
Built-in writer
|
||||
cases are controls: the existing workspace lease already refuses them first.
|
||||
|
||||
This is an assembly regression with an explicit collaborator, not evidence
|
||||
that production rewind deliberately holds the barrier indefinitely.
|
||||
|
||||
Barrier tests cover cancelled/free admission, queued writer/exclusive waits,
|
||||
deadline expiry, shutdown, successful legacy/context waiters and preservation
|
||||
of another owner and generation. Fake time and `synctest` establish ordering;
|
||||
the boot watchdog only detects a stuck call, not a performance threshold.
|
||||
|
||||
Public checkpoint APIs create a committed undo offer. Cancelling before
|
||||
admission preserves it across reload. Cancelling through the Store's undo-change
|
||||
notification after retirement keeps it retired across reload. Repeated permit
|
||||
cleanup cannot admit rewind while a second writer remains.
|
||||
|
||||
These fixtures
|
||||
exercise owner contracts rather than asserting an observed production incident.
|
||||
|
||||
Local validation and authored security analysis do not replace exact-head
|
||||
hosted platform checks or independent review.
|
||||
|
||||
## 中文说明
|
||||
|
||||
旧检查点屏障不接收调用 context,取消无法唤醒等待者;独占方稍后退出后,旧调用
|
||||
仍可能进入 hook 或工具。现在等待、取消和准入由同一屏障所有者协调,取消只撤回
|
||||
本次请求,不会释放别人的许可或推进版本。Agent 在实际目标解析和权限检查之后,
|
||||
将调用 context 传给检查点准入,成功许可仍由调用退出路径释放。
|
||||
|
||||
准入前取消保留既有 undo;持久化退役期间发生取消,则保留已经完成的退役并退出,
|
||||
不恢复可能已失效的旧 undo。重复释放同一许可不会减少其他写入者的计数。
|
||||
|
||||
普通上下文无关 API、非阻塞 rewind、并行写入、权限、持久化格式和工具定义保持
|
||||
既有约定。本次不强制打断进行中的文件 I/O,不解决后台登记锁、异步 hook 进程、
|
||||
公平队列、跨进程文件事务、验证证据或整个隔离恢复生命周期。
|
||||
|
||||
真实装配测试明确注入检查点 Store;旧读取 hook 路径会阻塞并尝试进入回调,但实际
|
||||
shell 启动器会拒绝已取消的命令,不能把它描述成已运行 shell 写入。写工具对照由
|
||||
现有工作区租约先行拒绝。确定性交错、磁盘重载、本地 race 和后续 CI 分开记账。
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
owner: @SivanCola
|
||||
backup: @esengine
|
||||
status: active
|
||||
reviewed: 2026-10-26
|
||||
---
|
||||
|
||||
# Background writer admission / 后台写入者准入
|
||||
|
||||
## Cause and repair
|
||||
|
||||
`MutationObserver.RegisterWriter` held the shared parent/child registry mutex
|
||||
while waiting for checkpoint admission, retiring undo on disk and notifying the
|
||||
undo owner. Unregistration also persisted the checkpoint under that mutex.
|
||||
Registry queries and unrelated writer retirement could block behind these steps.
|
||||
|
||||
Task and fleet background registration used the context-free method. A cancelled
|
||||
call could remain waiting behind an exclusive checkpoint operation. Changing only
|
||||
the barrier did not pass cancellation through these three registration sites.
|
||||
|
||||
The registry now reserves an identified pending registration under its mutex and
|
||||
prepares outside it. Pending entries do not count as active writers.
|
||||
|
||||
Publication
|
||||
uses a separate serialization mutex, takes the latest registry snapshot briefly,
|
||||
then releases the registry mutex before calling the existing checkpoint Store.
|
||||
This prevents an older snapshot from overtaking a newer removal.
|
||||
|
||||
The context method returns cleanup bound to that registration's identity. Task
|
||||
and fleet transfer this cleanup to the background job. Cancellation bounds
|
||||
admission; it does not revoke a successfully handed-off job's writer permit.
|
||||
Repeated or old cleanup cannot remove a replacement with the same string ID.
|
||||
|
||||
## Source, sink and authority
|
||||
|
||||
| Boundary | Decision and guarantee |
|
||||
| --- | --- |
|
||||
| Source | Existing host execution ID, writer kind, turn and caller context |
|
||||
| Pending reservation | One token per ID; duplicate callers await that token |
|
||||
| Preparation | Existing barrier and durable undo retirement, outside registry mutex |
|
||||
| Publication | Existing Store active-writer projection; latest snapshots serialized |
|
||||
| Handoff | Task/fleet job owns the returned identified cleanup |
|
||||
| Retirement | Remove only the expected token, cancel its preparation, release its permit |
|
||||
|
||||
Cancelled followers do not cancel their leader. A retired pending registration
|
||||
cannot resurrect after preparation returns. Panic cleanup releases the acquired
|
||||
barrier permit, closes duplicate callers' rendezvous and rethrows the panic.
|
||||
Undo already retired durably remains retired after cancellation or panic.
|
||||
|
||||
The undo notification callback is an existing host callback, not new untrusted
|
||||
executable input. A callback that queries the writer registry is a public-API
|
||||
regression fixture; no production callback deadlock is asserted from that fixture.
|
||||
The production controller callback emits `RewindChanged`.
|
||||
|
||||
## Compatibility and limits
|
||||
|
||||
Legacy registration remains context-free. Legacy `UnregisterWriter(id)` retains
|
||||
its current-ID meaning; new production callers use identified cleanup instead.
|
||||
Duplicates share a registration rather than owning reference-counted permits,
|
||||
as before.
|
||||
|
||||
This change introduces no persisted format, cross-process protocol,
|
||||
path authority, permission grant, tool schema, provider prefix or default policy.
|
||||
|
||||
Synchronous Store I/O and ordinary mutex acquisition are not context-abortable.
|
||||
Cancellation is checked around preparation; queries of the registry stay available
|
||||
while Store I/O is blocked. Publication can still serialize behind slow disk I/O.
|
||||
Store persistence remains best effort under its existing contract.
|
||||
|
||||
The registry and checkpoint barrier are in-process rewind coordination. This is
|
||||
not file-level mutual exclusion, process-scope completion, automatic isolation,
|
||||
result integration, recovery journaling or root-budget enforcement. Asynchronous
|
||||
hooks and processes require their own physical-lifetime integration.
|
||||
|
||||
The accepted [job finalization owner](https://github.com/esengine/DeepSeek-Reasonix/pull/14469)
|
||||
may prove that a closure will not be invoked.
|
||||
|
||||
Task/fleet register returned writer-token cleanup with that owner alongside
|
||||
transcript/progress cleanup, instead of a closure-only defer or ID unregistration.
|
||||
|
||||
Cleanup runs on actual closure return or proven no-launch, before completion
|
||||
publication. This is not proof of descendant process-scope completion.
|
||||
|
||||
## Verification boundaries
|
||||
|
||||
Deterministic tests cover parked exclusive admission, cancellation, duplicate
|
||||
followers, pending retirement, replacement generations, panic and durable undo.
|
||||
A Store-mutex fixture parks publication and checks that registry access continues;
|
||||
its watchdog detects a stuck fixture, not a product latency requirement.
|
||||
|
||||
Real task/fleet calls exercise cancelled background admission. Real `boot.Build`
|
||||
tests cover direct and dynamically resolved task/fleet handoff, a completed parent
|
||||
turn while children remain live, and final job cleanup.
|
||||
|
||||
The fixture installs the
|
||||
shared observer through the public Agent setter; it does not claim a concurrent
|
||||
default Controller rewind incident.
|
||||
|
||||
Compiling preceding-owner overlays reproduce four observer failures; preceding
|
||||
task/fleet callers reproduce two cancellation failures plus their parent test.
|
||||
|
||||
The observer overlay adds only a context-shaped adapter to legacy registration
|
||||
and ID-only cleanup; it does not assert that the old API offered identified cleanup.
|
||||
|
||||
The combined job-owner regression checks both ordinary writer release and a
|
||||
same-ID replacement admitted before the old job finishes. Its finalizer must
|
||||
release only its original token.
|
||||
|
||||
Accepted no-launch tests and checkpoint
|
||||
admission tests remain part of the combined validation, including real boot
|
||||
entry points. These do not establish unknown process-scope termination.
|
||||
|
||||
Local race and root results, hosted CI, native platforms and independent review
|
||||
are reported separately in the PR.
|
||||
|
||||
## 中文说明
|
||||
|
||||
原登记流程持有父子代理共享的登记表锁等待检查点、持久化撤销状态并调用通知;
|
||||
移除登记时也在该锁内持久化,导致查询及其他写入者的清理一起等待。
|
||||
task/fleet 三个生产入口没有把调用上下文交给后台写入者准入。
|
||||
|
||||
新流程先在锁内创建带身份的待准入记录,再在锁外等待及准备;待准入记录不计入
|
||||
活动写入者。单独的发布锁串行提交最新快照,登记表锁不覆盖 Store I/O。
|
||||
成功后返回绑定本次身份的清理回调,并转交后台任务;旧回调不能释放同 ID 的新登记。
|
||||
|
||||
取消只终止准入等待,不撤销已经交接的后台执行。取消跟随者不影响首个申请,
|
||||
待准入记录被移除后不能复活;panic 关闭等待出口并释放本次许可,继续传播原 panic。
|
||||
已经持久化的撤销退休状态不会恢复成可撤销。
|
||||
|
||||
旧接口保留原语义,没有变更权限、持久格式、工具定义、稳定提示前缀或默认策略。
|
||||
磁盘 I/O 和普通互斥锁本身仍不可取消;这是进程内的 rewind 协调,不能替代跨进程文件
|
||||
事务、进程物理完成、自动隔离或恢复日志。原生平台、CI 与独立审查以各自实际证据为准。
|
||||
|
||||
已接受的 #14469 统一实际闭包返回与已证明不启动时的资源终结。本分支把登记 token 的
|
||||
清理回调交给这个所有者,与进度和 transcript 资源一起在完成发布前终结,不再只靠闭包
|
||||
defer,也不退回按字符串 ID 移除。组合回归覆盖普通释放及旧任务结束前同 ID 新登记;
|
||||
旧 finalizer 不能释放新 token。
|
||||
|
||||
这仍不证明后代进程 scope 完成或所有后台生命周期闭合。
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
owner: @SivanCola
|
||||
backup: @esengine
|
||||
status: active
|
||||
reviewed: 2026-10-24
|
||||
---
|
||||
|
||||
# Concurrent mutation review series / 并发写入分批审查
|
||||
|
||||
## Purpose / 目的
|
||||
|
||||
The complete implementation remains pinned at
|
||||
`467a61668a0781cab171536158536283d931e4d9` in #14133 as a source reference.
|
||||
It is being split following the maintainer's review-size request.
|
||||
The source PR's validation is historical evidence, not qualification of a slice.
|
||||
|
||||
完整实现保留在 #14133 的上述提交。本系列按维护者要求拆分;原实现的测试记录
|
||||
保留,但每一批都必须独立构建、检查及测试,不能继承整体分支的通过结论。
|
||||
|
||||
## Sequence and review boundaries / 顺序与边界
|
||||
|
||||
| Order / 顺序 | Review unit / 审查单元 | Prerequisite and activation / 前置条件及接入 |
|
||||
| --- | --- | --- |
|
||||
| 1 | Mutation admission primitive, content versions, legacy claim compatibility | Current slice; library only. No tool/controller/settings activation. |
|
||||
| 2 | Tool effect descriptions and per-actor read-version provenance | Depends on 1. Review path grants, approval ordering, overlays and same-file stale-view regressions. |
|
||||
| 3 | Shared tool/agent transaction assembly | Depends on 2. Review parent/child/fleet/dynamic paths, refused preparation and physical completion. |
|
||||
| 4 | Source snapshots and private worktree preparation | Independent platform layer where possible. Review Git common-directory operations, modes, symlinks, untracked and ignored inputs. |
|
||||
| 5 | Journaled per-file application and conditional recovery | Depends on 1 and 4. Review manifest containment, preimages, identity, failure injection, conditional rollback and idempotence. |
|
||||
| 6 | Frozen check inputs and verification receipts | Review input observation, environment identity, stale evidence and unavailable backend behavior before integration activation. |
|
||||
| 7 | Durable isolated task records and lifecycle | Depends on 4 and 6. Split preparation/execution from recovery/retention if either diff becomes too broad. |
|
||||
| 8 | Task/fleet/background and nested ownership | Depends on 3 and 7. Review budgets, continuation, direct-parent identity, cancellation, restart and cross-version fences. |
|
||||
| 9 | Root conversations and source integration | Depends on 5–8. Review current grants, source drift, controlled checks and recovery blocking together. |
|
||||
| 10 | Controlled portable verifier and confined shell/hooks | Separate written command/authority security review; no unsupported-platform enforcement claim. |
|
||||
| 11 | Controller/wire/frontends and migration/default selection | Activate only after required lower-layer review and platform evidence; preserve legacy settings and active runtime protocols. |
|
||||
| 12 | Product/process performance and final acceptance | Reconcile every source delta, tests and compatibility evidence on the combined accepted tree. |
|
||||
|
||||
表中是依赖关系及审查边界,不是承诺将每行所有文件一次性提交。若一行仍然
|
||||
包含多个独立机制,应继续拆分。前置接口经审查确定后,再提交对应的接入批次。
|
||||
优先使用直接面向 `studio` 的独立 PR;需要堆叠时明确实际基分支及依赖。
|
||||
|
||||
## Extraction and security rules / 拆分与安全规则
|
||||
|
||||
1. Each slice MUST build and run its own tests without unsubmitted sibling
|
||||
patches. No lint baseline, safety gate or coverage floor is relaxed to make
|
||||
an extraction pass.
|
||||
2. Every PR MUST state Cause, Blast radius, Neighbouring behaviours tested and
|
||||
Why this layer, plus its actual base/head and local/hosted evidence.
|
||||
3. Sensitive slices MUST document input provenance, authority, filesystem or
|
||||
command sinks, validation, failure recovery and explicit unenforced bounds.
|
||||
4. Original CodeQL findings remain unresolved until assessed at their actual
|
||||
sources and sinks. Moving code between PRs is not security remediation.
|
||||
5. The source branch MUST remain recoverable. Each extracted file or hunk is
|
||||
accounted for as kept, adapted, deferred or intentionally dropped; the
|
||||
combined final tree is compared with the pinned source plus upstream changes.
|
||||
6. Primitive API simplifications in slice 1 are intentional: use `Options`,
|
||||
lease methods and explicit-context version reads. Runtime-only context
|
||||
conveniences and legacy recovery admission hooks are deferred to their users.
|
||||
|
||||
每批必须独立验证,不降低门禁;安全说明不能用“旧分支测过”替代。拆分时保留
|
||||
完整来源,并记录适配或延期的差异;最后对合并后的代码树做完整性核对。第一批
|
||||
去掉尚无调用者的便利函数,后续接入时按已审查接口适配,不复制旧 API。
|
||||
|
||||
## First-slice review / 第一批审查入口
|
||||
|
||||
Read [the coordinator security review](MUTATION_COORDINATOR_SECURITY.md), then
|
||||
`internal/state/mutation/doc.go`, `coordinator.go`, `registry.go`, `resources.go`,
|
||||
`capacity.go` and `completion.go`. The compatibility change is limited to
|
||||
`workspacelease/claim_attempt.go` and its existing `paths.go` publication owner.
|
||||
|
||||
先阅读安全审查及包说明,再审查资源申请、登记、身份、容量及释放。旧协议的
|
||||
调整限于独立短申请及其发布清理,不接入运行时恢复屏障或修改旧锁语义。
|
||||
@@ -1662,6 +1662,51 @@ sources are merged; on a name collision `reasonix.toml` wins.
|
||||
keep working — move them into `reasonix.toml`'s `[[plugins]]` or a `.mcp.json` when
|
||||
convenient.
|
||||
|
||||
## Custom instructions / 提示词怎么注入
|
||||
|
||||
Choose the scope before adding a prompt.
|
||||
|
||||
### One task / 单次任务
|
||||
|
||||
Type the instructions in chat. For a reusable prompt you invoke explicitly,
|
||||
use a [custom slash command](#slash-commands).
|
||||
|
||||
直接在聊天框输入要求。需要反复手动调用的提示词可写成[自定义斜杠命令](#slash-commands)。
|
||||
|
||||
### Repository rules / 仓库规则
|
||||
|
||||
- Create `AGENTS.md`, or use the repository's existing `REASONIX.md` / `CLAUDE.md`,
|
||||
at the workspace root. For example: `- Run the project tests before reporting completion.`
|
||||
- Use `AGENTS.local.md` for personal rules in that directory, or an instruction
|
||||
file in your Reasonix home for user-global rules.
|
||||
- See [standing-instruction resolution](./SESSION_MEMORY_RETRIEVAL.md#instruction-resolution)
|
||||
for hierarchy, imports, and `/memory instructions` to inspect what loaded.
|
||||
|
||||
- 在工作区根目录创建 `AGENTS.md`,或沿用仓库已有的 `REASONIX.md` / `CLAUDE.md`。
|
||||
例如添加 `- 完成前运行项目测试。`。
|
||||
- 当前目录的个人规则可放在 `AGENTS.local.md`;跨项目的用户规则放在 Reasonix 主目录中的指令文件。
|
||||
- 层级、导入及查看已加载内容的 `/memory instructions` 命令见
|
||||
[常驻指令文档](./SESSION_MEMORY_RETRIEVAL.md#instruction-resolution)。
|
||||
|
||||
### User-wide persona / 用户级人设
|
||||
|
||||
1. In Studio, open **Settings → Persona → System persona**.
|
||||
2. Fill **Start of the system prompt** and/or **Additional instructions**, then **Save**.
|
||||
3. Start a new session, or wait for the existing session's next rebuild
|
||||
(for example, a model switch). Saving does **not** change the running session's prompt.
|
||||
|
||||
These settings supplement built-in guidance; they do not replace it. They are
|
||||
stored in your user configuration, not a project's `reasonix.toml`.
|
||||
See [System persona](./PERSONA.md) for configuration fields and limits.
|
||||
|
||||
1. 在 Studio 打开**设置 → 人设 → 系统人设**。
|
||||
2. 填写**系统提示词开头**和/或**补充要求**,点击**保存**。
|
||||
3. 新建会话,或等现有会话下一次重建(例如切换模型)后生效。
|
||||
**保存不会改变正在运行的会话提示词**。
|
||||
|
||||
这些设置补充而非替换内置指令,写入用户配置,不由项目 `reasonix.toml` 设置。
|
||||
配置字段及限制见[系统人设](./PERSONA.md)。
|
||||
|
||||
## Slash commands
|
||||
|
||||
In an interactive `reasonix` session, built-in commands (`/compact`, `/context`, `/new`, `/clear`, `/rewind`,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
owner: @SivanCola
|
||||
backup: @esengine
|
||||
status: active
|
||||
reviewed: 2026-10-25
|
||||
---
|
||||
|
||||
# Hook checkpoint admission / Hook 检查点写入准入
|
||||
|
||||
## Root cause and repair
|
||||
|
||||
A reader's tool-use hooks can write files. Checkpoint admission previously used
|
||||
only the concrete tool's read-only classification, so these callbacks bypassed
|
||||
the rewind barrier and omitted the explicit hook coverage gap.
|
||||
|
||||
When an observer is installed, the invocation requires checkpoint write
|
||||
admission if either the tool or its hook set may write. Admission stays before
|
||||
callbacks. The permit spans synchronous callbacks, execution and failure cleanup.
|
||||
|
||||
An admitted hook set records `GapHookWrite` even around a reader. This reports
|
||||
unknown coverage; it does not invent file preimages or make an incomplete
|
||||
checkpoint complete. Tool execution and read-only classification are unchanged.
|
||||
|
||||
## Authority and data flow
|
||||
|
||||
| Boundary | Contract |
|
||||
| --- | --- |
|
||||
| Target access | Existing permission and target resolution finish before checkpoint admission |
|
||||
| Hook capability | Existing optional capability report; legacy custom hooks remain conservatively write-capable |
|
||||
| Checkpoint owner | `MutationObserver.BeginWriteContext`: enter rewind barrier, durably retire undo, then return permit |
|
||||
| Failure | Closed barrier or failed undo retirement refuses the call before hook/tool entry |
|
||||
| Cleanup | Existing invocation defer releases the permit on success, refusal or failure |
|
||||
| Coverage | Existing `GapHookWrite`, never a fabricated path or captured preimage |
|
||||
|
||||
No new hook launch path, permission grant, shell program, dependency, persisted
|
||||
field, wire schema, tool definition or configuration default is introduced.
|
||||
The repaired caller uses the same checkpoint owner as an ordinary writer.
|
||||
|
||||
Empty and non-tool hook runners do not acquire a reader's write permit or
|
||||
retire undo. An enabled tool-hook set can conservatively require admission
|
||||
even if no individual command matches this invocation.
|
||||
|
||||
## Concurrency and compatibility bounds
|
||||
|
||||
The checkpoint barrier excludes rewind; it does not serialize all writers or
|
||||
replace file-level, cross-process mutation coordination. Readers with possible
|
||||
hook writes now follow the existing writer wait/shutdown contract.
|
||||
|
||||
Invocation admission accepts its context; cancellation can abandon a wait
|
||||
without releasing the exclusive owner. See [cancellation bounds](CHECKPOINT_CANCELLATION_SECURITY.md).
|
||||
This does not add a fair queue, global execution mutex or filesystem scan.
|
||||
|
||||
Imported asynchronous hooks can outlive their callback. Their process lifetime
|
||||
is not covered by this permit; registering and reconciling those background
|
||||
writers remains a separate lifecycle requirement.
|
||||
|
||||
Old records and formats are unchanged. `GapHookWrite` already has supported
|
||||
readers; its presence now accurately includes reader-side hooks. A checkpoint
|
||||
can therefore advertise incomplete coverage where it previously omitted it.
|
||||
|
||||
This slice is independent of #14397's provisional-lease repair and #14398's
|
||||
optional host invalidation callback. It does not finish delivery-receipt or
|
||||
verification-evidence accounting, capture arbitrary hook writes, or sandbox
|
||||
the approved program.
|
||||
|
||||
## Validation and reachability
|
||||
|
||||
Tests construct an Agent with the same hooks/observer wiring used by boot.
|
||||
The callback writes real bytes and attempts a nonblocking exclusive entry:
|
||||
it must hold a writer permit, and rewind must be refused until it exits.
|
||||
|
||||
Pre-success, pre-refusal, post-success and post-failure paths cover direct and
|
||||
deferred reader targets. Negative cases cover closed admission and a deliberate
|
||||
durable-retirement I/O fault, plus empty/non-tool hook runners.
|
||||
|
||||
Public checkpoint APIs create a real committed rewind/undo offer. The callback
|
||||
must see it retired before entry, and reloading must not revive it. Fault fixtures
|
||||
model unavailable storage; they do not claim an observed production disk fault.
|
||||
|
||||
The undo fixture enters the Agent invocation directly to test that contract.
|
||||
Ordinary visible Controller turns already retire undo when opening a checkpoint;
|
||||
the fixture is not evidence that such a turn normally retains an undo offer.
|
||||
|
||||
Real `boot.Build` tests run a helper process that writes another file around a
|
||||
read, and inspect the actual controller checkpoint's coverage. A compiling
|
||||
original-owner overlay must fail these assertions. Author tests do not replace
|
||||
independent review or exact-head native CI.
|
||||
|
||||
## 中文说明
|
||||
|
||||
只读工具不代表整个调用只读。原接线按工具分类跳过 checkpoint 写屏障,导致周围
|
||||
可能写文件的 hook 未参与 rewind 互斥,也未记入覆盖缺口。
|
||||
|
||||
现在工具或 hook 任一可能写入,都先通过已有写入准入:进入屏障并持久化退役旧的
|
||||
undo,再允许回调执行。准入失败则 hook 和工具都不执行;调用退出后释放许可。
|
||||
|
||||
hook 的未知写入只记录既有覆盖缺口,不伪造预映像或修改工具 `Executed`。无工具
|
||||
hook 的读取保持原行为。权限、工具定义、配置和持久化格式不变。
|
||||
|
||||
该屏障只保护 checkpoint 与 rewind,不是完整文件事务或跨进程锁;调用等待支持
|
||||
取消,但后台登记、验证证据、自动隔离与恢复仍需后续实现。测试的存储故障是明确故障注入,
|
||||
不冒称已在线上发生。
|
||||
|
||||
导入的异步 hook 进程可能晚于回调结束,当前许可不覆盖它的完整运行期;这类后台
|
||||
写入的登记和恢复仍是后续生命周期工作。
|
||||
Loaded 100 of 287 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user