对话页换成组件库,桌面旁边可以打开本命盘(#106)。
Independent Staging Quality Gate / validate (push) Failing after 9m13s
Independent Staging Quality Gate / publish (push) Skipped

输入框里左边选模型、右边发送。回答下的赞、踩、复制、重新生成和追问改用组件库按钮。不满意原因和兑换窗改用对话框,句子不变。桌面顶栏可以打开当前人物的本命盘,能拖宽,刷新后还在;手机没有这个入口。当前会话和流式状态从 Home() 移进已有会话 store,useState 从 33 减到 25。流式、停止、排队、撤回、退点和寒暄免费没有改。

自测:tsc 退出码 0;lint 0 error、122 条警告;定向测试 275 通过、失败 0、取消 0;npm run build 退出码 0,/ 仍是 Static。首屏 gzip-9 639127,相对基线 634528 多 0.72%。隐私标记测试退出码 0。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-25 03:15:13 +08:00
co-authored by Grok (agent loop)
parent e4e240bf86
commit d25dfdbe1a
31 changed files with 921 additions and 249 deletions
@@ -66,7 +66,11 @@ test("the account dialog and onboarding paywall portal to document.body, outside
assert.match(overlay, /<AlertDialog open/);
assert.match(read("../src/components/ui/alert-dialog.tsx"), /AlertDialogPrimitive\.Portal/);
assert.match(read("../src/components/ui/dialog.tsx"), /DialogPrimitive\.Portal/);
assert.match(paywall, /createPortal\(overlay, document\.body\)/);
// 原值:兑换窗 `createPortal(overlay, document.body)`
// 新值:兑换窗用 Dialog,传送由 DialogPrimitive.Portal 完成
// 原因:K2 兑换窗改用组件库 Dialog,仍挂到 body,不留在会被 inert 的页面里
assert.match(paywall, /<Dialog open/);
assert.doesNotMatch(paywall, /createPortal\(/);
// 原值:renderToString 得到 role="dialog",且没有 inert
// 新值:注销走 AlertDialog,组件库根把角色写成 alert-dialog;页面源码不写 inert
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
CHAT_CHART_OPEN_STORAGE_KEY,
CHAT_CHART_WIDTH_DEFAULT,
CHAT_CHART_WIDTH_MAX,
CHAT_CHART_WIDTH_MIN,
CHAT_CHART_WIDTH_STORAGE_KEY,
clampChatChartWidth,
readStoredChatChartOpen,
readStoredChatChartWidth,
writeStoredChatChartOpen,
writeStoredChatChartWidth,
type ChatChartStorage,
} from "../src/lib/chat-chart-panel-state.ts";
function memoryStorage(seed: Record<string, string> = {}): ChatChartStorage & { values: Map<string, string> } {
const values = new Map(Object.entries(seed));
return {
values,
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => { values.set(key, String(value)); },
};
}
test("chat natal panel remembers open and width on this machine", () => {
const storage = memoryStorage();
assert.equal(readStoredChatChartOpen(storage), false);
assert.equal(readStoredChatChartWidth(storage), CHAT_CHART_WIDTH_DEFAULT);
writeStoredChatChartOpen(true, storage);
writeStoredChatChartWidth(480, storage);
assert.equal(storage.values.get(CHAT_CHART_OPEN_STORAGE_KEY), "1");
assert.equal(readStoredChatChartOpen(storage), true);
assert.equal(readStoredChatChartWidth(storage), 480);
writeStoredChatChartOpen(false, storage);
assert.equal(readStoredChatChartOpen(storage), false);
assert.equal(readStoredChatChartWidth(storage), 480);
});
test("chat natal panel width stays between 280 and 560", () => {
assert.equal(clampChatChartWidth(10), CHAT_CHART_WIDTH_MIN);
assert.equal(clampChatChartWidth(900), CHAT_CHART_WIDTH_MAX);
assert.equal(clampChatChartWidth(Number.NaN), CHAT_CHART_WIDTH_DEFAULT);
const storage = memoryStorage();
writeStoredChatChartWidth(12, storage);
assert.equal(storage.values.get(CHAT_CHART_WIDTH_STORAGE_KEY), String(CHAT_CHART_WIDTH_MIN));
assert.equal(readStoredChatChartWidth(memoryStorage({ [CHAT_CHART_WIDTH_STORAGE_KEY]: "nope" })), CHAT_CHART_WIDTH_DEFAULT);
});
test("the chat page loads the natal split only when the panel opens, and phones have no entry", () => {
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const frame = readFileSync(new URL("../src/components/chat-chart-frame.tsx", import.meta.url), "utf8");
const split = readFileSync(new URL("../src/components/chat-chart-split.tsx", import.meta.url), "utf8");
assert.match(page, /<ChatChartToggle \/>/);
assert.match(page, /<ChatChartFrame>/);
assert.doesNotMatch(page, /react-resizable-panels|vedic-chart-svg|chat-chart-split/);
assert.match(frame, /打开本命盘/);
assert.match(frame, /收起本命盘/);
assert.match(frame, /if \(!ready \|\| isMobile\) return null/);
assert.match(frame, /import\("@\/components\/chat-chart-split"\)/);
assert.match(split, /ResizablePanelGroup/);
assert.match(split, /collapsible/);
assert.doesNotMatch(split, /data-onboarding-draw/);
});
@@ -285,13 +285,16 @@ test("the jump-to-latest control stays keyboard reachable and labelled", () => {
assert.match(jumpControl, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
const jumpSource = readFileSync(new URL("../src/components/jump-to-latest-button.tsx", import.meta.url), "utf8");
const buttonTag = sourceBetween(jumpSource, "<button", ">");
// 原值:`<button`
// 新值:`<Button`,仍是 type="button"
// 原因:K2「跳到最新」改用组件库 Button,类名、焦点环和 44px 目标不变
const buttonTag = sourceBetween(jumpSource, "<Button", ">");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const buttonRule = styles.match(/\.jump-to-latest__button \{[^}]*\}/)?.[0] ?? "";
const wrapRule = styles.match(/\.jump-to-latest \{[^}]*\}/)?.[0] ?? "";
// Given: a real button in document order, never removed from the tab sequence.
assert.match(jumpSource, /<button\b/);
assert.match(jumpSource, /<Button\b/);
assert.match(buttonTag, /type="button"/);
assert.doesNotMatch(buttonTag, /tabIndex/);
assert.doesNotMatch(buttonTag, /aria-hidden/);
@@ -64,7 +64,10 @@ test("the textarea lives in an isolated composer that owns the draft subscriptio
assert.doesNotMatch(rectificationSource, /composer-draft|setComposerDraft|useComposerDraft/);
// Then: only the composer subtree reads the value that changes on every keystroke.
assert.match(composerSource, /<Textarea[\s\S]*?value=\{draft\}/);
// 原值:`<Textarea` 直接放在表单里
// 新值:`<InputGroupTextarea`,仍绑定同一份 draft
// 原因:K2 输入框改用 InputGroup,草稿订阅不变
assert.match(composerSource, /<InputGroupTextarea[\s\S]*?value=\{draft\}/);
assert.match(composerSource, /disabled=\{!draft\.trim\(\) \|\| submitBlocked\}/);
});
@@ -78,7 +81,10 @@ test("the composer keeps its Chinese input, focus and accessibility contract", (
// When: the page still focuses the same textarea element through its own ref.
assert.match(pageSource, /const composerInput = useRef<HTMLTextAreaElement>\(null\)/);
assert.match(pageSource, /inputRef=\{composerInput\}/);
assert.match(composerSource, /<Textarea\n\s*ref=\{inputRef\}/);
// 原值:`<Textarea ref={inputRef}`
// 新值:`<InputGroupTextarea ref={inputRef}`
// 原因:K2 输入框改用 InputGroup,页面仍用同一个 textarea ref 聚焦
assert.match(composerSource, /<InputGroupTextarea\n\s*ref=\{inputRef\}/);
// 原值 `composerInput.current?.focus()` / 新值 `focus({ preventScroll: true })`
// / 原因:裸 focus 会让浏览器滚动所有可滚动祖先,包括 overflow 的 `.chat-panel`——
// 线上实测把 46px 顶栏顶到了 y=-88 且无法滚回。焦点行为本身没变,只是不再连带滚动。
+4 -1
View File
@@ -64,10 +64,13 @@ test("the redeem paywall opens on the first send refused for credits, not when t
});
test("the onboarding paywall reuses the shared account dialog header", () => {
// 原值:自绘遮罩 `account-modal-overlay`
// 新值:`DialogContent` 使用 `account-modal paywall-modal`,标题仍在 `account-modal-header`
// 原因:K2 兑换窗改用组件库 Dialog,文案和这些类名留下
assert.match(onboardingPaywallSource, /<header className="account-modal-header">/);
assert.match(onboardingPaywallSource, /<\/header>/);
assert.doesNotMatch(onboardingPaywallSource, /className="dialog-header"/);
assert.match(onboardingPaywallSource, /className="account-modal-overlay"/);
assert.doesNotMatch(onboardingPaywallSource, /className="account-modal-overlay"/);
assert.match(onboardingPaywallSource, /className="account-modal paywall-modal"/);
assert.match(onboardingPaywallSource, /className="dialog-close"/);
});
@@ -9,7 +9,11 @@ class HostElement {
readonly nodeType = 1;
readonly namespaceURI = "http://www.w3.org/1999/xhtml";
readonly childNodes: HostElement[] = [];
readonly style = {};
readonly style = {
setProperty() {},
getPropertyValue() { return ""; },
removeProperty() { return ""; },
};
open = false;
get disabled() { return this.hasAttribute("disabled"); }
isConnected = true;
@@ -22,6 +26,8 @@ class HostElement {
scrollTop = 0;
scrollHeight = 0;
clientHeight = 0;
clientWidth = 1440;
offsetWidth = 1440;
readonly scrollCalls: { top?: number }[] = [];
scrollTo(options: { top?: number }) { this.scrollCalls.push(options); }
/** No layout here: every box is the origin. Components that position from rects still run. */
@@ -33,10 +39,17 @@ class HostElement {
if (wanted.every(name => classes.includes(name))) return this;
return this.parentNode?.closest(selector) ?? null;
}
get parentElement() { return this.parentNode; }
get children() { return this.childNodes.filter((child) => child.nodeType === 1); }
matches() { return false; }
querySelector() { return null; }
querySelectorAll() { return []; }
getRootNode() { return this.ownerDocument; }
contains(node: HostElement | null): boolean { return node === this || this.childNodes.some(child => child.contains(node)); }
get text(): string { return this.textContent + this.childNodes.map(child => child.text).join(""); }
get props(): Record<string, unknown> { return (this as unknown as Record<string, Record<string, unknown>>)[Object.keys(this).find(key => key.startsWith("__reactProps$")) ?? ""] ?? {}; }
readonly attributes = new Map<string, string>();
readonly dataset: Record<string, string> = {};
parentNode: HostElement | null = null;
textContent = "";
get nodeValue() { return this.textContent; }
@@ -68,9 +81,12 @@ class HostElement {
class HostDocument {
readonly nodeType = 9;
readonly body: HostElement;
readonly documentElement: HostElement;
activeElement: HostElement | null = null;
constructor() {
this.documentElement = new HostElement("HTML", this);
this.body = new HostElement("BODY", this);
this.documentElement.appendChild(this.body);
}
createTextNode(text: string) { const node = new HostElement("#text", this); node.textContent = text; return node; }
getElementById() { return null; }
@@ -85,6 +101,12 @@ export function createClientLifecycleHarness() {
const document = new HostDocument();
const frames = new Map<number, ReturnType<typeof setTimeout>>();
let frameId = 0;
const requestAnimationFrame = (callback: () => void) => {
const id = ++frameId;
frames.set(id, setTimeout(() => { frames.delete(id); callback(); }, 0));
return id;
};
const cancelAnimationFrame = (id: number) => { clearTimeout(frames.get(id)); frames.delete(id); };
const window = {
document,
innerWidth: 1440,
@@ -92,21 +114,19 @@ export function createClientLifecycleHarness() {
clearTimeout,
getComputedStyle: () => ({ direction: "ltr" }),
HTMLElement: HostElement,
Element: HostElement,
Node: HostElement,
HTMLIFrameElement: class {},
addEventListener() {},
removeEventListener() {},
requestAnimationFrame(callback: () => void) {
const id = ++frameId;
frames.set(id, setTimeout(() => { frames.delete(id); callback(); }, 0));
return id;
},
cancelAnimationFrame(id: number) { clearTimeout(frames.get(id)); frames.delete(id); },
requestAnimationFrame,
cancelAnimationFrame,
};
document.defaultView = window;
// tsx's ESM parser binds its runtime to global `self` when that binding
// exists. A partial window makes `cn` fail to parse. Next still calls
// `self.setTimeout`, so `self` is the real global, which already has it.
const globals = { window, self: globalThis, document, HTMLElement: HostElement, Element: HostElement, IS_REACT_ACT_ENVIRONMENT: true };
const globals = { window, self: globalThis, document, HTMLElement: HostElement, Element: HostElement, Node: HostElement, requestAnimationFrame, cancelAnimationFrame, IS_REACT_ACT_ENVIRONMENT: true };
const originals = Object.fromEntries(Object.keys(globals).map((key) => [
key, Object.getOwnPropertyDescriptor(globalThis, key),
]));
+12 -5
View File
@@ -4,7 +4,6 @@ import test from "node:test";
import React from "react";
import { toast } from "sonner";
import { ReplyDownReason } from "../src/components/reply-down-reason";
import { PRIVACY } from "../src/lib/legal-documents";
import { REPLY_QUALITY_RETENTION_DAYS, parseReplyQualityFilter } from "../src/lib/reply-quality-labels";
import { runReplyQualityRetentionTick } from "../src/lib/reply-quality-retention-core";
@@ -141,7 +140,13 @@ test("the privacy draft says what a 👎 keeps, why, for how long, and that 注
});
type Harness = ReturnType<typeof createClientLifecycleHarness>;
const find = (h: Harness, predicate: (node: ReturnType<Harness["elements"]>[number]) => boolean) => h.elements().find(predicate);
// 原值:文件顶部静态引入 ReplyDownReason,只在挂载容器里找文案
// 新值:先建好测试文档再动态引入,并同时查找 document.body
// 原因:组件库 Dialog 在模块加载时才决定把内容挂到 body;测试文档必须先就位
const loadReason = () => import("../src/components/reply-down-reason");
const visibleNodes = (h: Harness) => [...h.elements(), ...h.elements(h.document.body)];
const visibleText = (h: Harness) => `${h.container.text}\n${h.document.body.text}`;
const find = (h: Harness, predicate: (node: ReturnType<Harness["elements"]>[number]) => boolean) => visibleNodes(h).find(predicate);
test("不满意原因 is optional: submitting with nothing picked still thanks the user, after the 👎 save", async () => {
const calls: { url: string; body: Record<string, unknown> }[] = [];
@@ -158,10 +163,11 @@ test("不满意原因 is optional: submitting with nothing picked still thanks t
try {
const key = `${SESSION}:message-5`;
void persistReplyRating(key, "down", "第三答:注意作息。");
const { ReplyDownReason } = await loadReason();
await h.render(<ReplyDownReason feedbackKey={key} onClose={() => { closed += 1; }} />);
assert.ok(h.container.text.includes("哪里不满意?可以不选"));
assert.ok(h.container.text.includes("会把这一轮对话发给我们排查"));
assert.deepEqual(h.elements().filter((node) => node.getAttribute("aria-pressed") !== null).map((node) => node.text), ["答非所问", "内容不准", "太长太空", "语气不对", "其他"]);
assert.ok(visibleText(h).includes("哪里不满意?可以不选"));
assert.ok(visibleText(h).includes("会把这一轮对话发给我们排查"));
assert.deepEqual(visibleNodes(h).filter((node) => node.getAttribute("aria-pressed") !== null).map((node) => node.text), ["答非所问", "内容不准", "太长太空", "语气不对", "其他"]);
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "提交")!);
await h.idle();
assert.deepEqual(calls.map((call) => call.url), ["/api/reply-ratings", "/api/reply-ratings/reason"]);
@@ -188,6 +194,7 @@ test("picked reasons and the note are sent; an unsaved conversation sends nothin
}) as typeof fetch;
const h = createClientLifecycleHarness();
try {
const { ReplyDownReason } = await loadReason();
await h.render(<ReplyDownReason feedbackKey={`${SESSION}:message-3`} onClose={() => {}} />);
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "语气不对")!);
await h.event(find(h, (node) => node.tagName === "BUTTON" && node.text === "答非所问")!);
+5 -2
View File
@@ -13,8 +13,11 @@ function starterEntryBlock(): string {
return css.slice(start, end);
}
test("starter entries stay native buttons with a 36px pill and a 44px hit area", () => {
assert.match(starterHome, /<button[\s\S]*className="starter-entry"/);
test("starter entries stay a 36px pill with a 44px hit area", () => {
// 原值:`<button className="starter-entry"`
// 新值:`<Button className="starter-entry"`
// 原因:K2 今日星语改用组件库 Button,36px 胶囊和 44px 点按区仍由 `.starter-entry` 决定
assert.match(starterHome, /<Button[\s\S]*className="starter-entry"/);
// 原值: 2 新值: 1(生时校正 pill 删除) 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入
assert.equal(starterHome.match(/className="starter-entry"/g)?.length, 1);
assert.doesNotMatch(starterHome, /starter-hero|product-entrypoint|daily-starlanguage-card|birth-rectification-card/);