diff --git a/.github/workflows/studio.yml b/.github/workflows/studio.yml index 6b9bd0bc20..b5b82c3fe0 100644 --- a/.github/workflows/studio.yml +++ b/.github/workflows/studio.yml @@ -163,6 +163,7 @@ jobs: node perf/settings-rows.mjs node perf/narrow-steps.mjs node perf/job-names.mjs + node perf/composer-feel.mjs node perf/side-panel.mjs node perf/card-shell.mjs node perf/line-cards.mjs @@ -177,6 +178,70 @@ jobs: node perf/narrow-rail.mjs node perf/selfcheck.mjs branch-tooltip + # What a hosted page does over the relay, end to end: the production bundle, + # the real Studio host and kernel, a real Chromium; only the accounts API and + # the relay are stand-ins (tools/relayfake). Event-transport changes are + # judged here, because a unit test of one side cannot see the other. + relay-harness: + name: relay harness + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Transport changed? + id: scope + env: + BASE_REF: ${{ github.base_ref }} + run: | + paths="desktop/frontend-next internal/frontend/serve internal/platform/remotecloud internal/platform/account tools/relayfake cmd/reasonix-studio-host" + if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && + git diff --quiet "origin/$BASE_REF...HEAD" -- $paths; then + echo "run=false" >> "$GITHUB_OUTPUT" + else + echo "run=true" >> "$GITHUB_OUTPUT" + fi + + - if: steps.scope.outputs.run == 'true' + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: go.mod + cache: true + + - if: steps.scope.outputs.run == 'true' + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 10 + run_install: false + + - if: steps.scope.outputs.run == 'true' + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml + + - if: steps.scope.outputs.run == 'true' + name: Build the host, the stand-ins and the page + run: | + go build -o desktop/frontend-next/relay/bin/studio-host ./cmd/reasonix-studio-host + go build -o desktop/frontend-next/relay/bin/relayfake ./tools/relayfake + cd desktop/frontend-next + pnpm install --frozen-lockfile + pnpm exec vite build --outDir dist-relay --emptyOutDir + pnpm exec playwright install --with-deps chromium + + - if: steps.scope.outputs.run == 'true' + name: Scenarios + working-directory: desktop/frontend-next + env: + RELAY_HOST_BIN: ${{ github.workspace }}/desktop/frontend-next/relay/bin/studio-host + RELAY_FAKE_BIN: ${{ github.workspace }}/desktop/frontend-next/relay/bin/relayfake + RELAY_DIST: ${{ github.workspace }}/desktop/frontend-next/dist-relay + run: node relay/live.mjs + # A pull request with no platform-relevant change (scripts/ci-platform-scope.sh) # moves the Windows and macOS legs onto an ubuntu runner with every step # skipped, so the check names still report without a scarce runner. A push to diff --git a/.gitignore b/.gitignore index e5cc91e956..bce610b82f 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,4 @@ scripts/official-theme-art/__pycache__/ # running, which on Windows is every rebuild while Studio is open. *.exe *.exe~ +/relayfake diff --git a/desktop/frontend-next/.gitignore b/desktop/frontend-next/.gitignore index c581aecf3a..b7de7f20d4 100644 --- a/desktop/frontend-next/.gitignore +++ b/desktop/frontend-next/.gitignore @@ -4,3 +4,5 @@ dist-perf* *.png !public/icons/*.png tsconfig.tsbuildinfo +dist-relay* +relay/bin diff --git a/desktop/frontend-next/perf/composer-feel.mjs b/desktop/frontend-next/perf/composer-feel.mjs new file mode 100644 index 0000000000..4eceae2b2e --- /dev/null +++ b/desktop/frontend-next/perf/composer-feel.mjs @@ -0,0 +1,79 @@ +// The composer keeps the 1.x feel: a ring of light sweeps its border once when +// it is hovered or focused, nothing sweeps while a turn runs or when motion is +// reduced, and the run state is announced rather than printed inside the box. +import { chromium } from "playwright"; + +const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2"; +const fails = []; +const check = (name, ok, detail = "") => { + console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`); + if (!ok) fails.push(name); +}; +const shotDir = process.env.SHOTS; +const sweep = (page) => page.evaluate(() => getComputedStyle(document.querySelector(".compose"), "::before").animationName); + +const browser = await chromium.launch(); +for (const scheme of ["light", "dark"]) { + for (const running of [false, true]) { + const label = `${scheme} ${running ? "running" : "idle"}`; + const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme }); + page.on("pageerror", (e) => fails.push("页面异常: " + e.message)); + await page.goto(BASE + (running ? "&running=1" : ""), { waitUntil: "networkidle" }); + await page.waitForSelector(".compose"); + if (running) { + await page.waitForFunction(() => typeof window.__feed === "function"); + await page.waitForTimeout(400); + await page.evaluate(() => window.__feed({ kind: "turn_started" })); + } + await page.waitForSelector(running ? ".studio-runstate[data-running]" : ".studio-runstate[data-idle]"); + await page.mouse.move(5, 5); + await page.waitForTimeout(150); + + const state = await page.evaluate(() => { + const status = document.querySelector(".compose .studio-runstate"); + const word = status?.querySelector(".studio-runlabel > span.sr-only"); + const r = word?.getBoundingClientRect(); + const clock = status?.querySelector(".studio-runclock"); + return { + role: status?.getAttribute("role"), + live: status?.getAttribute("aria-live"), + said: word?.textContent ?? "", + wordBox: r ? [Math.round(r.width), Math.round(r.height)] : null, + clockShown: !!clock && clock.getBoundingClientRect().width > 0, + mark: !!status?.querySelector(".rmark"), + }; + }); + check(`${label}: the state is announced`, state.role === "status" && state.live === "polite" && state.said.length > 0, state.said); + check(`${label}: the word is not printed in the box`, state.wordBox && state.wordBox[0] <= 2 && state.wordBox[1] <= 2, JSON.stringify(state.wordBox)); + check(`${label}: the mark stays`, state.mark); + if (running) check(`${label}: the clock stays`, state.clockShown); + + check(`${label}: nothing sweeps at rest`, (await sweep(page)) === "none"); + await page.hover(".compose textarea"); + await page.waitForTimeout(300); + const hovered = await sweep(page); + check(`${label}: ${running ? "no sweep on hover while running" : "a sweep on hover"}`, running ? hovered === "none" : hovered === "compose-sweep", hovered); + if (shotDir && !running) await page.locator(".compose").screenshot({ path: `${shotDir}/${scheme}-hover.png` }); + await page.mouse.move(5, 5); + await page.waitForTimeout(100); + await page.focus(".compose textarea"); + await page.waitForTimeout(300); + const focused = await sweep(page); + check(`${label}: ${running ? "no sweep on focus while running" : "a sweep on focus"}`, running ? focused === "none" : focused === "compose-sweep", focused); + if (shotDir) await page.locator(".compose").screenshot({ path: `${shotDir}/${scheme}-${running ? "running" : "idle"}.png` }); + await page.close(); + } + const calm = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" }); + await calm.goto(BASE, { waitUntil: "networkidle" }); + await calm.waitForSelector(".compose"); + await calm.hover(".compose textarea"); + await calm.focus(".compose textarea"); + await calm.waitForTimeout(300); + check(`${scheme}: no sweep when motion is reduced`, (await sweep(calm)) === "none"); + await calm.close(); +} +await browser.close(); +if (fails.length) { + console.log(`\n${fails.length} failed`); + process.exit(1); +} diff --git a/desktop/frontend-next/perf/crumb.mjs b/desktop/frontend-next/perf/crumb.mjs index 8f6cc3621c..571139fee3 100644 --- a/desktop/frontend-next/perf/crumb.mjs +++ b/desktop/frontend-next/perf/crumb.mjs @@ -1,4 +1,4 @@ -// The session title sits right beside the project name in the title bar, at every width, and the +// The session title sits right beside the project and checkout in the title bar, at every width, and the // project name still gives way (ellipsis) before the title is pushed off. Layout is a real-browser question. import { chromium } from "playwright"; @@ -22,9 +22,11 @@ try { const range = document.createRange(); range.selectNodeContents(projEl); const text = range.getBoundingClientRect(); - return { gap: Math.round(sep.left - text.right), titleRight: Math.round(title.right) }; + const tags = [...document.querySelectorAll(".chrome .crumb .isolab")].filter((el) => !el.hidden); + const edge = tags.at(-1)?.getBoundingClientRect().right ?? text.right; + return { gap: Math.round(sep.left - edge), titleRight: Math.round(title.right) }; }); - check(`${width}px: the title follows the project name`, at.gap >= 0 && at.gap <= 12, `gap ${at.gap}px`); + check(`${width}px: the title follows the project and checkout`, at.gap >= 0 && at.gap <= 12, `gap ${at.gap}px`); check(`${width}px: the title stays on screen`, at.titleRight <= width, `right edge ${at.titleRight}px`); const long = await page.evaluate(() => { document.querySelector(".chrome .crumb-proj").textContent = "a-very-long-project-folder-name-".repeat(8); diff --git a/desktop/frontend-next/perf/side-panel.mjs b/desktop/frontend-next/perf/side-panel.mjs index c5cc18dd8c..28547ceb6b 100644 --- a/desktop/frontend-next/perf/side-panel.mjs +++ b/desktop/frontend-next/perf/side-panel.mjs @@ -2,6 +2,7 @@ // and inside the conversation's own column — never over what sits beside or // above it, whether the file panel is open or not and at any width. import { chromium } from "playwright"; +import { mkdir } from "node:fs/promises"; const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=3"; const fails = []; @@ -12,13 +13,14 @@ const check = (name, ok, detail = "") => { const shotDir = process.env.SHOTS; const box = (el) => (el ? (({ left, top, right, bottom }) => ({ left, top, right, bottom }))(el.getBoundingClientRect()) : null); -const browser = await chromium.launch(); +if (shotDir) await mkdir(shotDir, { recursive: true }); +const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL }); for (const scheme of ["light", "dark"]) { - for (const [width, files] of [[1400, false], [1400, true], [760, false], [760, true]]) { - const label = `${scheme} ${width}px${files ? " with the file panel" : ""}`; - const page = await browser.newPage({ viewport: { width, height: 800 }, locale: "zh-CN", colorScheme: scheme }); + for (const [width, files, height = 800, zoom = 1] of [[1400, false], [1400, true], [760, false], [760, true], [390, false, 700], [900, false, 480], [760, false, 800, 1.3]]) { + const label = `${scheme} ${width}x${height} zoom ${zoom}${files ? " with the file panel" : ""}`; + const page = await browser.newPage({ viewport: { width, height }, locale: "zh-CN", colorScheme: scheme }); page.on("pageerror", (e) => fails.push("页面异常: " + e.message)); - await page.goto(PAGE, { waitUntil: "networkidle" }); + await page.goto(`${PAGE}&zoom=${zoom}`, { waitUntil: "networkidle" }); await page.waitForSelector(".compose"); if (files) { await page.locator('.navbtn[aria-label="文件"]').first().click(); @@ -41,7 +43,34 @@ for (const scheme of ["light", "dark"]) { check(`${label}: it stays inside the window`, p.left >= 0 && p.right <= r.vw && p.top >= 0 && p.bottom <= r.vh); check(`${label}: it does not cover the file panel`, r.beside.every((b) => p.right <= b.left || p.left >= b.right || p.bottom <= b.top || p.top >= b.bottom), files ? `${r.beside.length} file panel parts` : "no file panel"); check(`${label}: it opens above the button that asked`, p.bottom <= r.button.top && p.left <= r.button.left + 1, `button ${Math.round(r.button.left)}`); - if (shotDir && !(width === 760 && files)) await page.screenshot({ path: `${shotDir}/${scheme}-${width}${files ? "-files" : ""}.png` }); + const draft = page.locator('[data-action-change="side.draft"]'); + await draft.fill("解释这个函数如何处理错误。".repeat(50)); + await page.locator('[data-action="side.send"]').click(); + await page.locator('.sideturn[data-role="side"]').waitFor(); + await draft.fill("keep this draft"); + const collapse = page.locator('[data-action="side.collapse"]'); + await collapse.click(); + const hidden = await draft.isHidden(); + const compact = await page.locator(".sidepanel").evaluate((el) => { + const b = el.getBoundingClientRect(); + const button = el.querySelector('[data-action="side.collapse"]'); + const c = button.getBoundingClientRect(); + const hit = document.elementFromPoint(c.x + c.width / 2, c.y + c.height / 2); + const flow = el.closest(".pane").querySelector('[data-pane="flow"]').getBoundingClientRect(); + return { height: b.height, hit: hit === button || button.contains(hit), readable: b.top - flow.top, flowHeight: flow.height }; + }); + check(`${label}: collapse leaves a reachable header and hides inputs`, hidden && compact.hit && compact.height < 72, JSON.stringify(compact)); + check(`${label}: collapse exposes the transcript`, compact.readable > 100 && compact.readable > compact.flowHeight / 2, JSON.stringify(compact)); + await collapse.focus(); + await page.keyboard.press("Tab"); + check(`${label}: Tab skips the collapsed controls`, await page.locator('[data-action="side.close"]').evaluate((el) => el === document.activeElement)); + if (shotDir && [1400, 390].includes(width) && !files) await page.screenshot({ path: `${shotDir}/${scheme}-${width}.png` }); + await collapse.click(); + await draft.waitFor({ state: "visible" }); + check(`${label}: expansion keeps the draft`, await draft.inputValue() === "keep this draft"); + await page.keyboard.press("Escape"); + await page.locator(".sidepanel").waitFor({ state: "detached" }); + check(`${label}: Escape returns focus`, await page.locator('[data-action="side.toggle"]').evaluate((el) => el === document.activeElement)); await page.close(); } } diff --git a/desktop/frontend-next/relay/README.md b/desktop/frontend-next/relay/README.md new file mode 100644 index 0000000000..09eadd1bbd --- /dev/null +++ b/desktop/frontend-next/relay/README.md @@ -0,0 +1,29 @@ +--- +owner: @esengine +backup: @SivanCola +status: active +reviewed: 2026-10-17 +--- + +# Relay harness + +A hosted page driving a real Studio through a stand-in relay. + +- Real: the production page bundle, its encrypted channel, `reasonix-studio-host` with its kernel, and Chromium. +- Replaced: the accounts API and the message relay (`tools/relayfake`), and the model provider (a scripted stream in `stack.mjs`). + +The page still dials `id.reasonix.io` and `remote.reasonix.io`; the browser +context answers those two hosts with the stand-ins. + +``` +go build -o relay/bin/studio-host ../../cmd/reasonix-studio-host +go build -o relay/bin/relayfake ../../tools/relayfake +pnpm exec vite build --outDir dist-relay --emptyOutDir +RELAY_HOST_BIN=$PWD/relay/bin/studio-host RELAY_FAKE_BIN=$PWD/relay/bin/relayfake \ + RELAY_DIST=$PWD/dist-relay node relay/live.mjs +``` + +`CHROMIUM_PATH` selects a browser other than the one Playwright installed. +A change to how events travel between a page and a kernel is judged by this +run, not only by a unit test of one side. Add a scenario to `live.mjs` rather +than a second harness. diff --git a/desktop/frontend-next/relay/live.mjs b/desktop/frontend-next/relay/live.mjs new file mode 100644 index 0000000000..7bad9f575e --- /dev/null +++ b/desktop/frontend-next/relay/live.mjs @@ -0,0 +1,61 @@ +// A hosted page driving a real Studio through a stand-in relay. What a shipped +// page does over that link is the claim: the production bundle, the encrypted +// channel, the Studio's own host and a real kernel are all real; only the two +// Reasonix services between them are replaced (tools/relayfake). +import { chromium } from "playwright"; +import { startStack, routeServices, until } from "./stack.mjs"; + +const need = (name) => { + if (!process.env[name]) throw new Error(`${name} is not set; see relay/README.md`); + return process.env[name]; +}; + +const stack = await startStack({ hostBin: need("RELAY_HOST_BIN"), fakeBin: need("RELAY_FAKE_BIN"), dist: need("RELAY_DIST") }); +const browser = await chromium.launch(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}); +const failures = []; +const check = (name, ok, detail = "") => { + console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` - ${detail}` : ""}`); + if (!ok) failures.push(name); +}; + +try { + const context = await browser.newContext({ viewport: { width: 1300, height: 850 } }); + await routeServices(context, stack); + const page = await context.newPage(); + const pageErrors = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + await page.goto(stack.pageUrl()); + await page.evaluate(() => { window.__survivor = true; }); + await page.keyboard.press("Escape"); + const pane = await stack.api("/runtimes", "POST", { root: stack.workspace }); + check("a pane opens on the Studio", pane.status === 200, `status ${pane.status}`); + + const composer = page.getByLabel("Task input").first(); + await composer.waitFor({ timeout: 30_000 }); + const text = () => page.evaluate(() => document.body.innerText); + const count = async (word) => ((await text()).match(new RegExp(word, "g")) ?? []).length; + const send = async (message) => { + await composer.fill(message); + await composer.press("Enter"); + }; + + await send("first"); + const live = await until("the reply to stream onto the page", async () => (await count("word11")) >= 1, 20_000).then(() => true, () => false); + check("a reply streams onto the page over the relay", live, `provider saw ${stack.provider.requests.length} request(s)`); + + const dropped = await stack.ctl("drop-controllers", "POST"); + check("the relay drops the page's socket", dropped.dropped === 1, JSON.stringify(dropped)); + await until("the page to reconnect", async () => (await stack.ctl("state")).controllers === 1, 30_000).catch(() => {}); + await send("second"); + const after = await until("a reply after the reconnect", async () => (await count("word11")) >= 2, 40_000).then(() => true, () => false); + check("a reply streams onto the page after the relay drops the socket", after); + + check("the page was never reloaded", await page.evaluate(() => window.__survivor === true)); + check("the page threw no error", pageErrors.length === 0, pageErrors.join(" | ")); +} catch (error) { + check("the scenario ran to the end", false, String(error)); +} finally { + await browser.close(); + await stack.stop(); +} +process.exit(failures.length ? 1 : 0); diff --git a/desktop/frontend-next/relay/stack.mjs b/desktop/frontend-next/relay/stack.mjs new file mode 100644 index 0000000000..0e9b9dca1c --- /dev/null +++ b/desktop/frontend-next/relay/stack.mjs @@ -0,0 +1,172 @@ +import http from "node:http"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export async function until(what, probe, ms = 20_000) { + const end = Date.now() + ms; + for (;;) { + const value = await probe(); + if (value) return value; + if (Date.now() > end) throw new Error(`timed out waiting for ${what}`); + await wait(100); + } +} + +function firstLine(child, what) { + return new Promise((resolve, reject) => { + let buffer = ""; + child.stdout.on("data", (chunk) => { + buffer += chunk; + const at = buffer.indexOf("\n"); + if (at >= 0) resolve(buffer.slice(0, at)); + }); + child.once("exit", (code) => reject(new Error(`${what} exited (${code}) before it was ready`))); + }); +} + +function scriptedProvider() { + const requests = []; + const server = http.createServer((req, res) => { + let body = ""; + req.on("data", (chunk) => { body += chunk; }); + req.on("end", () => { + if (!req.url.includes("chat/completions")) { + res.writeHead(404); + res.end("{}"); + return; + } + requests.push({ at: Date.now(), body }); + res.writeHead(200, { "content-type": "text/event-stream" }); + const chunk = (delta) => + `data: ${JSON.stringify({ id: "x", object: "chat.completion.chunk", model: "fake-m", choices: [{ index: 0, delta }] })}\n\n`; + let n = 0; + const timer = setInterval(() => { + if (n < 12) { + res.write(chunk({ content: `word${n++} ` })); + return; + } + clearInterval(timer); + res.write(`data: ${JSON.stringify({ id: "x", object: "chat.completion.chunk", model: "fake-m", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 12, total_tokens: 22 } })}\n\n`); + res.write("data: [DONE]\n\n"); + res.end(); + }, 120); + res.on("close", () => clearInterval(timer)); + }); + }); + return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, requests, port: server.address().port }))); +} + +function pageServer(dist) { + const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".json": "application/json", ".webmanifest": "application/manifest+json", ".png": "image/png" }; + const server = http.createServer((req, res) => { + const target = path.join(dist, decodeURIComponent(new URL(req.url, "http://x").pathname)); + const file = target.startsWith(dist) && fs.existsSync(target) && fs.statSync(target).isFile() ? target : path.join(dist, "index.html"); + res.writeHead(200, { "content-type": types[path.extname(file)] ?? "application/octet-stream" }); + fs.createReadStream(file).pipe(res); + }); + return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, origin: `http://127.0.0.1:${server.address().port}` }))); +} + +/** + * Starts everything between a hosted page and a Studio except the page's browser: + * the real studio-host binary on a fresh data home, a stand-in accounts API and + * relay, a scripted model provider, and a static server for the built page. + */ +export async function startStack({ hostBin, fakeBin, dist }) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "relay-stack-")); + const home = path.join(root, "home"); + const workspace = path.join(root, "workspace"); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(workspace, { recursive: true }); + const provider = await scriptedProvider(); + fs.writeFileSync(path.join(home, "config.toml"), [ + 'language = "en"', + 'default_model = "fake"', + `providers = [{ name = "fake", kind = "openai", base_url = "http://127.0.0.1:${provider.port}/v1", model = "fake-m", api_key_env = "FAKE_KEY" }]`, + "", + ].join("\n")); + fs.writeFileSync(path.join(home, ".env"), "FAKE_KEY=fixture\n"); + + const fake = spawn(fakeBin, [], { stdio: ["ignore", "pipe", "inherit"] }); + const urls = JSON.parse(await firstLine(fake, "relayfake")); + const ctl = async (route, method = "GET") => (await fetch(`${urls.accounts}/ctl/${route}`, { method })).json(); + + const host = spawn(hostBin, [], { + stdio: ["pipe", "pipe", "inherit"], + env: { + ...process.env, HOME: root, USERPROFILE: root, REASONIX_HOME: home, REASONIX_STATE_HOME: home, + REASONIX_ACCOUNTS_URL: urls.accounts, REASONIX_REMOTE_URL: urls.relay, REASONIX_ACCOUNT_TOKEN: "fixture-token", + FAKE_KEY: "fixture", + }, + }); + const handshake = JSON.parse(await firstLine(host, "studio-host")); + const state = await until("the Studio to reach the relay", async () => { + const s = await ctl("state"); + return s.deviceId && s.deviceOnline ? s : null; + }); + const page = await pageServer(dist); + + const api = async (route, method = "GET", body) => { + const response = await fetch(handshake.origin + route, { + method, + headers: { cookie: `reasonix_token=${handshake.token}`, origin: handshake.origin, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { status: response.status, json: await response.json().catch(() => null) }; + }; + + return { + urls, deviceId: state.deviceId, pageOrigin: page.origin, workspace, provider, ctl, api, + pageUrl: () => `${page.origin}/?device=${state.deviceId}`, + async stop() { + const gone = new Promise((resolve) => host.once("exit", resolve)); + host.stdin.end(); + host.kill(); + await gone; + fake.kill(); + page.server.close(); + provider.server.close(); + fs.rmSync(root, { recursive: true, force: true }); + }, + }; +} + +/** + * Points a browser context's two Reasonix services at the stand-ins. The page is + * the production build, so it still dials id.reasonix.io and remote.reasonix.io; + * the requests and the socket are answered here. The socket is a plain pipe: + * the page and the Studio speak their own encrypted protocol across it. + */ +export async function routeServices(context, stack) { + await context.route("https://id.reasonix.io/**", async (route) => { + const request = route.request(); + const cors = { + "access-control-allow-origin": request.headers().origin ?? "*", + "access-control-allow-credentials": "true", + "access-control-allow-headers": "content-type", + }; + if (request.method() === "OPTIONS") { + await route.fulfill({ status: 204, headers: cors }); + return; + } + const answer = await fetch(stack.urls.accounts + new URL(request.url()).pathname, { + method: request.method(), + headers: { "content-type": "application/json" }, + body: request.postData() ?? undefined, + }); + await route.fulfill({ status: answer.status, headers: { ...cors, "content-type": "application/json" }, body: await answer.text() }); + }); + await context.routeWebSocket(/remote\.reasonix\.io/, (page) => { + const upstream = new WebSocket(`${stack.urls.relay}/v1/sessions/connect?fixture=1`, "reasonix.remote.v1"); + const queued = []; + upstream.onopen = () => queued.splice(0).forEach((message) => upstream.send(message)); + upstream.onmessage = (event) => page.send(String(event.data)); + upstream.onclose = (event) => page.close({ code: event.code >= 1000 && event.code < 5000 ? event.code : 1011, reason: event.reason }); + page.onMessage((message) => (upstream.readyState === 1 ? upstream.send(message) : queued.push(message))); + page.onClose(() => upstream.close()); + }); +} diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index c19f3094f6..22f1b168a4 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -237,6 +237,7 @@ export const ACTIONS: UIAction[] = [ { 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: "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" }, { id: "side.unquote", kind: "view", target: "none", proof: "interaction" }, diff --git a/desktop/frontend-next/src/i18n/en_settings.ts b/desktop/frontend-next/src/i18n/en_settings.ts index ecdb55af0b..7fb367ed17 100644 --- a/desktop/frontend-next/src/i18n/en_settings.ts +++ b/desktop/frontend-next/src/i18n/en_settings.ts @@ -21,6 +21,8 @@ export const EN_SETTINGS: Record = { "浏览器标签": "Browser tabs", "辅助会话": "Side question", "只读 · 不写入主会话 · 关闭即丢": "Read-only · not in the main session · gone when closed", + "收起辅助会话": "Collapse the side question", + "展开辅助会话": "Expand the side question", "关闭辅助会话": "Close the side question", "移除引用 {n}": "Remove quote {n}", "向辅助会话提问": "Ask the side question", diff --git a/desktop/frontend-next/src/styles/LAYOUT.md b/desktop/frontend-next/src/styles/LAYOUT.md index 949f4e220e..9d1e8eefb7 100644 --- a/desktop/frontend-next/src/styles/LAYOUT.md +++ b/desktop/frontend-next/src/styles/LAYOUT.md @@ -1581,3 +1581,8 @@ what exists. - The mark at the top of the rail is one row: the wordmark and a compact filled uppercase badge ("STUDIO" by CSS; the text and the aria label are unchanged), vertically centred on each other, gap 8px. The badge is `--text` on `--page` inverted, so it is dark on light and light on dark and never moves with the theme. - The badge is hidden at 760px and below. The wordmark box is cropped to its drawn width so the badge sits next to the letters, not next to the asset's padding. + +## Side question + +- 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. diff --git a/desktop/frontend-next/src/styles/sidepanel.css b/desktop/frontend-next/src/styles/sidepanel.css index 216776fdf1..8eb3b9335f 100644 --- a/desktop/frontend-next/src/styles/sidepanel.css +++ b/desktop/frontend-next/src/styles/sidepanel.css @@ -4,13 +4,13 @@ display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 12px; transform-origin: bottom left; background: var(--raised); color: var(--text); box-shadow: 0 12px 32px -12px rgba(0, 0, 0, .45); } -.sidepanel > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border); } +.sidepanel > header { flex: none; display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border); } .sidepanel > header span { display: grid; gap: 2px; } .sidepanel > header b { font: 600 12px/1.3 var(--ui); } .sidepanel > header small { color: var(--ghost); font: 500 10px/1.3 var(--ui); } .sidepanel > header button { padding: 0 6px; border: 0; background: none; color: var(--muted); font-size: 16px; line-height: 1; cursor: pointer; } .sidepanel > header button:hover { color: var(--text); } -.sidebody { flex: 1; min-height: 80px; overflow-y: auto; display: grid; align-content: start; gap: 10px; padding: 10px 12px; } +.sidebody { flex: 1; min-height: 0; overflow-y: auto; display: grid; align-content: start; gap: 10px; padding: 10px 12px; } .sidehint { margin: 0; color: var(--faint); font: 500 11px/1.6 var(--ui); } .sideturn { display: grid; gap: 4px; } .sideturn p { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 500 12px/1.6 var(--ui); } @@ -21,10 +21,18 @@ .sidequotes li { display: flex; gap: 6px; align-items: flex-start; padding: 3px 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--overlay); } .sidequotes li span { flex: 1; min-width: 0; max-height: 36px; overflow: hidden; font: 500 11px/1.5 var(--mono); overflow-wrap: anywhere; } .sidequotes li button { border: 0; background: none; color: var(--muted); cursor: pointer; } -.sidepanel > footer { display: grid; grid-template-columns: 1fr auto; gap: 6px; padding: 8px 12px 12px; border-top: 1px solid var(--border); } -.sidepanel > footer .sidequote { grid-column: 1 / -1; justify-self: start; padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--muted); font: var(--w-med) var(--fs-meta)/1.5 var(--ui); cursor: pointer; } -.sidepanel > footer .sidequote:hover { color: var(--text); background: var(--overlay); } +.sidecontent > footer { flex: none; display: grid; grid-template-columns: 1fr auto; gap: 6px; padding: 8px 12px 12px; border-top: 1px solid var(--border); } +.sidecontent > footer .sidequote { grid-column: 1 / -1; justify-self: start; padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--muted); font: var(--w-med) var(--fs-meta)/1.5 var(--ui); cursor: pointer; } +.sidecontent > footer .sidequote:hover { color: var(--text); background: var(--overlay); } .sidepanel textarea { resize: none; min-width: 0; padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font: 500 12px/1.5 var(--ui); } .sidepanel textarea:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } .sidesend { padding: 0 12px; border: 1px solid var(--text); border-radius: 8px; background: var(--text); color: var(--surface); font: 600 12px/1 var(--ui); cursor: pointer; } .sidesend:disabled { opacity: .45; cursor: default; } + +.sidecontent { min-height: 0; display: flex; flex-direction: column; overflow: hidden; } +.sidecontent[hidden] { display: none; } +.sideactions { display: flex; align-items: center; gap: 8px; } +.sidepanel > header .sidecollapse { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; } +.sidecollapse svg { width: 16px; height: 16px; } +.sidecollapse[aria-expanded="false"] svg { transform: rotate(180deg); } +.sidepanel[data-collapsed] > header { border-bottom: 0; } diff --git a/desktop/frontend-next/src/styles/studio.css b/desktop/frontend-next/src/styles/studio.css index 82c994b2b2..a8b334aef5 100644 --- a/desktop/frontend-next/src/styles/studio.css +++ b/desktop/frontend-next/src/styles/studio.css @@ -232,6 +232,23 @@ body { background: var(--page); } box-shadow: 0 1px 2px rgb(0 0 0 / .06), 0 0 0 3px color-mix(in srgb, var(--focus) 10%, transparent), 0 14px 34px -24px rgb(0 0 0 / .4); } .compose:focus-within::after { border-color: color-mix(in srgb, var(--focus) 58%, var(--border)); } +@property --compose-sweep { syntax: ""; inherits: false; initial-value: 0deg; } +.compose::before { + content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1px; pointer-events: none; opacity: 0; + background: conic-gradient(from var(--compose-sweep), transparent 0 62%, color-mix(in srgb, var(--focus) 70%, transparent) 82%, transparent 100%); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); +} +@media (prefers-reduced-motion: no-preference) { + .pane:not([data-run="running"]) .compose:is(:hover, :focus-within)::before { animation: compose-sweep 1.9s cubic-bezier(.22, .8, .18, 1) both; } +} +@keyframes compose-sweep { + 0% { --compose-sweep: 190deg; opacity: 0; } + 22% { opacity: .22; } + 48% { opacity: .58; } + 78% { --compose-sweep: 520deg; opacity: .42; } + 100% { --compose-sweep: 560deg; opacity: 0; } +} .compose .row { background: transparent; border-top-color: var(--hair); } .compose .row > * { animation: none; } .composeguide { display: none; } @@ -3129,7 +3146,6 @@ body { font-size: 14px; line-height: 1.75; } font: 650 11px/1.2 var(--ui); } .studio-runclock { flex: none; font-variant-numeric: tabular-nums; font-weight: 500; opacity: .78; } -.studio-runclock::before { content: "·"; margin-right: 7px; opacity: .6; } .studio-runstate .studio-runtokens { flex: none; } .studio-runstate .rmark { width: 15px; diff --git a/desktop/frontend-next/src/ui/RunLine.interaction.test.tsx b/desktop/frontend-next/src/ui/RunLine.interaction.test.tsx index b0f2af19c7..a60280bd2f 100644 --- a/desktop/frontend-next/src/ui/RunLine.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/RunLine.interaction.test.tsx @@ -1,46 +1,37 @@ // @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; import "./testkit"; -import { act, cleanup, render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { PaneShown } from "./shown"; import { RunLine } from "./RunLine"; -const base = { label: "运行中", blocked: false, sent: 0, received: 0, estimated: false }; +afterEach(cleanup); -beforeEach(() => vi.useFakeTimers({ now: 1_000_000 })); -afterEach(() => { cleanup(); vi.useRealTimers(); }); +const draw = (over: Partial[0]> = {}) => + render(); -describe("the running chip above the composer", () => { - it("says how long the turn has been running and keeps counting", () => { - render(); - expect(screen.getByText("1m 5s")).toBeTruthy(); - act(() => { vi.advanceTimersByTime(3_000); }); - expect(screen.getByText("1m 8s")).toBeTruthy(); +describe("the run state beside the composer", () => { + it("is announced to a screen reader and not printed as a word", () => { + draw(); + const status = screen.getByRole("status"); + expect(status.getAttribute("aria-live")).toBe("polite"); + const word = screen.getByText("运行中"); + expect(status.contains(word)).toBe(true); + expect(word.classList.contains("sr-only")).toBe(true); }); - it("shows no time for a turn this window did not see start", () => { - const { container } = render(); - expect(container.querySelector(".studio-runclock")).toBeNull(); + it("keeps the clock and the mark where the word used to be", () => { + draw(); + expect(document.querySelector(".studio-runclock")).not.toBeNull(); + expect(document.querySelector(".rmark")).not.toBeNull(); }); - it("shows no time and runs no timer when nothing is running", () => { - const { container } = render(); - expect(container.querySelector(".studio-runclock")).toBeNull(); - expect(vi.getTimerCount()).toBe(0); - }); - - it("keeps the time out of what a screen reader announces", () => { - const { container } = render(); - expect(container.querySelector(".studio-runclock")?.getAttribute("aria-hidden")).toBe("true"); - }); - - it("does not tick in a pane nobody is looking at", () => { - render(); - expect(vi.getTimerCount()).toBe(0); - }); - - it("keeps the time while the turn waits on a decision", () => { - render(); - expect(screen.getByText("12s")).toBeTruthy(); + it("says idle and waiting the same way, and the state attributes still follow the run", () => { + const idle = draw({ label: "空闲", running: false, since: undefined }); + expect(screen.getByText("空闲").classList.contains("sr-only")).toBe(true); + expect(document.querySelector(".studio-runstate[data-idle]")).not.toBeNull(); + idle.unmount(); + draw({ label: "等待你的决定", running: true, blocked: true }); + expect(screen.getByText("等待你的决定").classList.contains("sr-only")).toBe(true); + expect(document.querySelector(".studio-runstate[data-waiting]")).not.toBeNull(); }); }); diff --git a/desktop/frontend-next/src/ui/RunLine.tsx b/desktop/frontend-next/src/ui/RunLine.tsx index 30b4fd1118..d474deff7d 100644 --- a/desktop/frontend-next/src/ui/RunLine.tsx +++ b/desktop/frontend-next/src/ui/RunLine.tsx @@ -21,7 +21,7 @@ export function RunLine({ label, running, blocked, since, sent, received, estima data-waiting={blocked ? "" : undefined} data-idle={running || blocked ? undefined : ""} > - {t(label)}{running && since !== undefined && } + {t(label)}{running && since !== undefined && } ); diff --git a/desktop/frontend-next/src/ui/SidePanel.interaction.test.tsx b/desktop/frontend-next/src/ui/SidePanel.interaction.test.tsx index bdecae11c0..0f47210737 100644 --- a/desktop/frontend-next/src/ui/SidePanel.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/SidePanel.interaction.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "./testkit"; import { SidePanel } from "./SidePanel"; @@ -118,3 +118,54 @@ describe("the side conversation panel", () => { await screen.findByText("done"); }); }); + + +describe("collapsing a side question without discarding it", () => { + it("keeps draft and quotes while hiding the panel controls", async () => { + const { port } = draw(); + const close = vi.spyOn(port, "closeSide"); + const ask = vi.spyOn(port, "askSide"); + const quoted = select("keep this quote"); + await userEvent.click(screen.getByRole("button", { name: "引用选中内容" })); + quoted.remove(); + await userEvent.type(screen.getByRole("textbox", { name: "向辅助会话提问" }), "draft"); + const collapse = screen.getByRole("button", { name: "收起辅助会话" }); + await userEvent.click(collapse); + expect(collapse.getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(collapse); + expect(screen.queryByRole("textbox", { name: "向辅助会话提问" })).toBeNull(); + expect(screen.queryByRole("button", { name: "发送" })).toBeNull(); + expect(screen.queryByRole("button", { name: "移除引用 1" })).toBeNull(); + expect(close).not.toHaveBeenCalled(); + await userEvent.tab(); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "关闭辅助会话" })); + await userEvent.click(screen.getByRole("button", { name: "展开辅助会话" })); + expect((screen.getByRole("textbox", { name: "向辅助会话提问" }) as HTMLTextAreaElement).value).toBe("draft"); + expect(screen.getByText("keep this quote")).toBeTruthy(); + await userEvent.click(screen.getByRole("button", { name: "发送" })); + await waitFor(() => expect(ask).toHaveBeenCalledWith(expect.any(String), "draft", ["keep this quote"])); + }); + + it.each([false, true])("finishes the pending answer while collapsed (failure=%s)", async (failure) => { + const { port } = draw(); + let finish!: (text: string) => void; + let fail!: (reason: Error) => void; + vi.spyOn(port, "askSide").mockImplementation(() => new Promise((resolve, reject) => { finish = resolve; fail = reject; })); + const open = vi.spyOn(port, "openSide"); + const close = vi.spyOn(port, "closeSide"); + await userEvent.type(screen.getByRole("textbox", { name: "向辅助会话提问" }), "question"); + await userEvent.click(screen.getByRole("button", { name: "发送" })); + await waitFor(() => expect(open).toHaveBeenCalledOnce()); + await userEvent.click(screen.getByRole("button", { name: "收起辅助会话" })); + if (failure) fail(new Error("answer failed")); else finish("answer finished"); + await waitFor(() => expect(document.querySelector(failure ? ".sideerr" : '.sideturn[data-role="side"]')?.textContent).toContain(failure ? "answer failed" : "answer finished")); + expect(close).not.toHaveBeenCalled(); + expect(screen.queryByRole("textbox", { name: "向辅助会话提问" })).toBeNull(); + await userEvent.click(screen.getByRole("button", { name: "展开辅助会话" })); + expect((screen.getByRole("textbox", { name: "向辅助会话提问" }) as HTMLTextAreaElement).value).toBe(failure ? "question" : ""); + expect(open).toHaveBeenCalledOnce(); + await userEvent.click(screen.getByRole("button", { name: "收起辅助会话" })); + fireEvent.keyDown(screen.getByRole("button", { name: "展开辅助会话" }), { key: "Escape" }); + await waitFor(() => expect(close).toHaveBeenCalledOnce()); + }); +}); diff --git a/desktop/frontend-next/src/ui/SidePanel.tsx b/desktop/frontend-next/src/ui/SidePanel.tsx index 34f58392ca..fa3f8c2001 100644 --- a/desktop/frontend-next/src/ui/SidePanel.tsx +++ b/desktop/frontend-next/src/ui/SidePanel.tsx @@ -1,7 +1,8 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { t } from "../i18n"; import { reason } from "../i18n/kernel"; import type { AgentPort } from "../port/port"; +import { StudioIcon } from "./StudioIcon"; import { listenAction } from "./listen"; interface Turn { @@ -19,6 +20,8 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v const [turns, setTurns] = useState([]); const [quotes, setQuotes] = useState([]); const [draft, setDraft] = useState(""); + const [collapsed, setCollapsed] = useState(false); + const contentId = useId(); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const panel = useRef(null); @@ -30,16 +33,18 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v if (open) void open.then((held) => port.closeSide(held)).catch(() => {}); }, [port]); - // The room above the composer inside its pane is what the panel may fill: the - // pane's own top is the ceiling, so the panel never rides over what is above. + // Rectangles include interface zoom; the CSS height uses local pixels. + // The pane bounds the room above the composer in either coordinate space. useEffect(() => { const el = panel.current; const host = el?.offsetParent; const pane = host?.closest(".pane"); - if (!el || !host || !pane) return; + if (!el || !(host instanceof HTMLElement) || !pane) return; const fit = () => { - const room = host.getBoundingClientRect().top - pane.getBoundingClientRect().top - 18; - el.style.setProperty("--side-room", `${Math.max(240, Math.min(520, Math.floor(room)))}px`); + const bounds = host.getBoundingClientRect(); + const scale = bounds.width / host.offsetWidth || 1; + const room = (bounds.top - pane.getBoundingClientRect().top) / scale - 18; + el.style.setProperty("--side-room", `${Math.max(0, Math.min(520, Math.floor(room)))}px`); }; fit(); const watch = new ResizeObserver(fit); @@ -104,6 +109,7 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v return (