fix(rectification): stream answers live and keep mobile chat visible
Use the quiet overlay scrollbar on chat and the house board, replace the blocking mobile dialog with a bottom sheet, and emit agent deltas before billing so users can read progress and stop a run. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4922,3 +4922,51 @@
|
||||
- 相关记录:BUG-322
|
||||
- 复发自:无
|
||||
- 修复版本:dbbcb71b
|
||||
|
||||
## BUG-327 | Web 对话与生时纠正仍露出原生滚动条
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-20
|
||||
- 最近更新:2026-08-20
|
||||
- 影响面:普通 session `.conversation`、生时纠正消息列表、右侧盘面 `.rectification-board__body`
|
||||
- 用户现象:聊天和生时纠正内容超出高度时,直接出现浏览器原生滚动条,和侧栏已有的安静 overlay 滚动条不一致。
|
||||
- 触发条件:普通对话或生时纠正历史超过可视高度,在桌面 Chromium / Firefox 中滚动。
|
||||
- 根因:只有侧栏 `SidebarContent` 使用产品内滚动条;会话区和盘面仍是 `overflow-y: auto` 并保留 `scrollbar-gutter`,因此继续露出系统滚动条。
|
||||
- 修复:会话列表、普通对话和生时纠正盘面共用同一套安静 overlay 滚动条:透明轨道、无 gutter、默认隐藏拇指,悬停或键盘焦点后显示低对比圆角拇指。
|
||||
- 验证:`frontend/tests/sidebar-contract.test.ts`、`frontend/tests/rectification-agentic-entry.test.ts`。
|
||||
- 防复发:独立滚动容器必须同时覆盖标准 scrollbar 属性和 WebKit 伪元素,且不得再用 `scrollbar-gutter` 给系统滚动条留槽。
|
||||
- 相关记录:BUG-042
|
||||
- 复发自:BUG-042(只修了当时的生时纠正消息区,后来的 session 列宽和盘面分栏又回到原生滚动条)
|
||||
- 修复版本:待本次提交
|
||||
|
||||
## BUG-328 | 移动端盘面以模态挡住聊天且关闭入口弱
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-20
|
||||
- 最近更新:2026-08-20
|
||||
- 影响面:生时纠正窄屏盘面、`rectification-board`
|
||||
- 用户现象:打开“当前盘面”后,白色面板盖住大部分聊天,几乎读不到刚才的对话;关闭只能靠一段会折行的文字按钮。
|
||||
- 触发条件:窄于分栏阈值的视口打开宫位表。
|
||||
- 根因:窄屏用 `showModal()` 对话框加不透明 backdrop,并把聊天设为 `inert`;高度约 86dvh / 100dvh,聊天被遮死。
|
||||
- 修复:窄屏改为工作区分栏下半屏 sheet(约 46% 高),聊天留在上半屏可继续阅读;关闭改为 44px 图标按钮,Escape 仍可关闭。
|
||||
- 验证:`frontend/tests/rectification-agentic-entry.test.ts`。
|
||||
- 防复发:窄屏盘面不得再用 modal dialog / backdrop 盖住聊天;关闭控件必须是独立 44px 目标并带 `aria-label="关闭盘面"`。
|
||||
- 相关记录:BUG-322、BUG-326
|
||||
- 复发自:BUG-322(分栏后窄屏改成对话框,聊天被挡住)
|
||||
- 修复版本:待本次提交
|
||||
|
||||
## BUG-329 | 生时纠正 Agent 回答在结算后一次性出现,推理中无法停止
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-20
|
||||
- 最近更新:2026-08-20
|
||||
- 影响面:`POST /api/rectification/agent`、`runV9AgentTurn`、生时纠正输入框
|
||||
- 用户现象:发送经历后接口长时间无增量文字,工具活动与回答在计费完成后才整段出现;推理过程中发送按钮不可用,也无法停止。
|
||||
- 触发条件:任意生时纠正 `message` / `opening` 流,尤其是 `rectification-set-focus` 同轮重试的长等待。
|
||||
- 根因:成功 attempt 的 tool.activity 与 `answer.delta` 被攒到 `billing.complete` 和持久化之后才发给浏览器。前端 fetch 没有 AbortSignal,输入区也没有停止按钮。
|
||||
- 修复:工具活动和回答增量在生成过程中即时发布;失败重试先发 `attempt.reset` 清掉弃用 attempt 的可见文字。前端在推理中显示停止按钮,中止请求;已流出的文字保留。
|
||||
- 验证:`frontend/tests/rectification-v9-stream.test.ts`、`frontend/tests/rectification-agentic-entry.test.ts`。
|
||||
- 防复发:`answer.delta` 必须在 `billing.settled` 之前出现在公共流里;生时纠正 composer 在 `busy` 时必须提供停止按钮并绑定 fetch abort。
|
||||
- 相关记录:BUG-039、BUG-067
|
||||
- 复发自:BUG-039(当时为了 attempt 隔离把增量攒到结算后,V9 流式合同被一起推迟)
|
||||
- 修复版本:待本次提交
|
||||
|
||||
+1
-1
@@ -154,7 +154,7 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
|
||||
- **Mobile:** no icon rail; an off-canvas drawer uses `min(86vw, 320px)` and closes through its scrim or Escape.
|
||||
- **Collapsed content:** logo, new-chat action, one history expansion action, and account avatar. Individual sessions do not become indistinguishable repeated icons.
|
||||
- **Scroll ownership:** header and footer remain fixed; `SidebarContent` is the sole sidebar scroll owner.
|
||||
- **Scrollbar:** `SidebarContent` uses a quiet overlay scrollbar: transparent track, no `scrollbar-gutter`, and a 4px warm thumb mixed from `--color-ink`. The thumb stays transparent until hover or keyboard focus inside the history, then uses `color-mix(in srgb, var(--color-ink) 26%, transparent)`; thumb hover uses 40%. Increased contrast keeps the thumb visible; forced colors restore the system scrollbar.
|
||||
- **Scrollbar:** `SidebarContent`, ordinary session `.conversation`, and the rectification house board use a quiet overlay scrollbar: transparent track, no `scrollbar-gutter`, and a 4px warm thumb mixed from `--color-ink`. The thumb stays transparent until hover or keyboard focus inside the scroller, then uses `color-mix(in srgb, var(--color-ink) 26%, transparent)`; thumb hover uses 40%. Increased contrast keeps the thumb visible; forced colors restore the system scrollbar.
|
||||
- **Motion:** Sidebar state changes are immediate on desktop, tablet, and mobile. The 44px trigger keeps one stable 18px sidebar glyph and never enters an intermediate scale or opacity state.
|
||||
- **Accessibility:** Command/Control+B shortcut outside editable controls, contextual trigger labels, 44px targets, focus return, collapsed-only tooltips, reduced-motion, reduced-transparency, and increased-contrast support.
|
||||
- **State:** session-local; reload uses breakpoint defaults rather than cookie or local-storage persistence.
|
||||
|
||||
@@ -394,20 +394,48 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.chat-app { width: 100%; height: 100%; min-height: 0; overflow: hidden; display: grid; background: var(--color-canvas); grid-template-columns: minmax(0, 1fr); }
|
||||
.sidebar { position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; border-right: 1px solid var(--sidebar-border); color: var(--sidebar-foreground); padding: var(--space-5) var(--space-3) 0; background: var(--sidebar-background); backdrop-filter: saturate(130%) blur(20px); }
|
||||
[data-sidebar="header"] { flex: 0 0 auto; }
|
||||
[data-sidebar="content"] { min-height: 0; flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: transparent transparent; }
|
||||
[data-sidebar="content"]:hover, [data-sidebar="content"]:focus-within { scrollbar-color: color-mix(in srgb, var(--color-ink) 26%, transparent) transparent; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar { width: 10px; background: transparent; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-track, [data-sidebar="content"]::-webkit-scrollbar-corner { background: transparent; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-thumb { background-color: transparent; border: 3px solid transparent; border-radius: 999px; background-clip: content-box; }
|
||||
[data-sidebar="content"]:hover::-webkit-scrollbar-thumb, [data-sidebar="content"]:focus-within::-webkit-scrollbar-thumb { background-color: color-mix(in srgb, var(--color-ink) 26%, transparent); }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--color-ink) 40%, transparent); }
|
||||
[data-sidebar="content"] { min-height: 0; flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; }
|
||||
[data-sidebar="content"],
|
||||
.conversation,
|
||||
.rectification-board__body {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
[data-sidebar="content"]:hover, [data-sidebar="content"]:focus-within,
|
||||
.conversation:hover, .conversation:focus-within,
|
||||
.rectification-board__body:hover, .rectification-board__body:focus-within {
|
||||
scrollbar-color: color-mix(in srgb, var(--color-ink) 26%, transparent) transparent;
|
||||
}
|
||||
[data-sidebar="content"]::-webkit-scrollbar,
|
||||
.conversation::-webkit-scrollbar,
|
||||
.rectification-board__body::-webkit-scrollbar { width: 10px; background: transparent; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-button,
|
||||
.conversation::-webkit-scrollbar-button,
|
||||
.rectification-board__body::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-track, [data-sidebar="content"]::-webkit-scrollbar-corner,
|
||||
.conversation::-webkit-scrollbar-track, .conversation::-webkit-scrollbar-corner,
|
||||
.rectification-board__body::-webkit-scrollbar-track, .rectification-board__body::-webkit-scrollbar-corner { background: transparent; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-thumb,
|
||||
.conversation::-webkit-scrollbar-thumb,
|
||||
.rectification-board__body::-webkit-scrollbar-thumb { background-color: transparent; border: 3px solid transparent; border-radius: 999px; background-clip: content-box; }
|
||||
[data-sidebar="content"]:hover::-webkit-scrollbar-thumb, [data-sidebar="content"]:focus-within::-webkit-scrollbar-thumb,
|
||||
.conversation:hover::-webkit-scrollbar-thumb, .conversation:focus-within::-webkit-scrollbar-thumb,
|
||||
.rectification-board__body:hover::-webkit-scrollbar-thumb, .rectification-board__body:focus-within::-webkit-scrollbar-thumb { background-color: color-mix(in srgb, var(--color-ink) 26%, transparent); }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-thumb:hover,
|
||||
.conversation::-webkit-scrollbar-thumb:hover,
|
||||
.rectification-board__body::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--color-ink) 40%, transparent); }
|
||||
@media (prefers-contrast: more) {
|
||||
[data-sidebar="content"] { scrollbar-color: var(--color-ink-secondary) transparent; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-thumb, [data-sidebar="content"]:hover::-webkit-scrollbar-thumb, [data-sidebar="content"]:focus-within::-webkit-scrollbar-thumb { background-color: var(--color-ink-secondary); }
|
||||
[data-sidebar="content"],
|
||||
.conversation,
|
||||
.rectification-board__body { scrollbar-color: var(--color-ink-secondary) transparent; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-thumb, [data-sidebar="content"]:hover::-webkit-scrollbar-thumb, [data-sidebar="content"]:focus-within::-webkit-scrollbar-thumb,
|
||||
.conversation::-webkit-scrollbar-thumb, .conversation:hover::-webkit-scrollbar-thumb, .conversation:focus-within::-webkit-scrollbar-thumb,
|
||||
.rectification-board__body::-webkit-scrollbar-thumb, .rectification-board__body:hover::-webkit-scrollbar-thumb, .rectification-board__body:focus-within::-webkit-scrollbar-thumb { background-color: var(--color-ink-secondary); }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
[data-sidebar="content"] { scrollbar-width: auto; scrollbar-color: auto; }
|
||||
[data-sidebar="content"],
|
||||
.conversation,
|
||||
.rectification-board__body { scrollbar-width: auto; scrollbar-color: auto; }
|
||||
}
|
||||
[data-sidebar="footer"] { flex: 0 0 auto; }
|
||||
[data-sidebar="sidebar"]:focus { outline: none; }
|
||||
@@ -482,7 +510,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.chat-header-actions > * { flex: 0 0 auto; }
|
||||
.credit-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 11px; cursor: pointer; font-size: 13px; font-variant-numeric: tabular-nums; transition: background-color 120ms ease-out, transform 120ms ease-out; min-width: 64px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); color: var(--color-ink-secondary); font-weight: 500; }
|
||||
|
||||
.conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding-bottom: var(--composer-reserve); background: var(--color-canvas); }
|
||||
.conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--composer-reserve); background: var(--color-canvas); }
|
||||
.conversation.is-empty { display: grid; place-items: center; padding: var(--space-8); }
|
||||
.welcome { width: min(820px, 100%); padding: var(--space-10) 0 var(--space-16); }
|
||||
.welcome > .onboarding-message:first-child { padding-bottom: var(--space-8); }
|
||||
@@ -1124,17 +1152,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.paywall-footer > a, .paywall-footer > button { width: 100%; }
|
||||
.dialog-actions { flex-direction: column-reverse; }
|
||||
.dialog-actions > button { width: 100%; }
|
||||
dialog.rectification-board {
|
||||
width: 100%;
|
||||
height: min(86dvh, 100%);
|
||||
margin-block-start: auto;
|
||||
margin-inline: 0;
|
||||
border-inline-start: 0;
|
||||
border-block-start: 1px solid var(--color-border);
|
||||
border-start-start-radius: var(--radius-lg);
|
||||
border-start-end-radius: var(--radius-lg);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.auth-story { min-height: 0; padding: var(--space-5); }
|
||||
.auth-story h2 { font-size: var(--type-title-lg); }
|
||||
.auth-panel { padding: var(--space-6) var(--space-5) max(var(--space-6), env(safe-area-inset-bottom)); }
|
||||
@@ -1612,10 +1629,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
padding: var(--space-10) var(--session-column-gutter) var(--space-24);
|
||||
}
|
||||
|
||||
.conversation:not(.is-empty):not(.is-rectification) {
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
|
||||
.conversation:not(.is-empty):not(.is-rectification) .message {
|
||||
padding: 0;
|
||||
animation: none;
|
||||
@@ -1871,6 +1884,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.rectification-workspace.is-compact {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.rectification-workspace.is-compact.is-board-open {
|
||||
grid-template-rows: minmax(8rem, 1fr) minmax(13rem, 46%);
|
||||
}
|
||||
.rectification-workspace__chat {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -1897,21 +1913,20 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
background: var(--color-canvas-soft);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
dialog.rectification-board {
|
||||
width: min(22.5rem, 100%);
|
||||
height: 100dvh;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
margin-inline-start: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-inline-start: 1px solid var(--color-border);
|
||||
background: var(--color-canvas-soft);
|
||||
color: var(--color-ink);
|
||||
.rectification-board.is-sheet {
|
||||
border-inline-start: 0;
|
||||
border-block-start: 1px solid var(--color-border);
|
||||
border-start-start-radius: var(--radius-lg);
|
||||
border-start-end-radius: var(--radius-lg);
|
||||
box-shadow: 0 -8px 28px color-mix(in srgb, var(--color-ink) 10%, transparent);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
dialog.rectification-board::backdrop {
|
||||
background: var(--color-scrim);
|
||||
.rectification-board__handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
margin: var(--space-2) auto 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-ink) 18%, transparent);
|
||||
}
|
||||
.rectification-board__header {
|
||||
position: sticky;
|
||||
@@ -1919,17 +1934,21 @@ dialog.rectification-board::backdrop {
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4) var(--space-4) var(--space-3);
|
||||
padding: var(--space-3) var(--space-4) var(--space-3);
|
||||
background: var(--color-canvas-soft);
|
||||
}
|
||||
.rectification-board__title-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
}
|
||||
.rectification-board__header h2 {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--type-title-sm);
|
||||
font-weight: 600;
|
||||
@@ -1942,15 +1961,24 @@ dialog.rectification-board::backdrop {
|
||||
font-weight: 600;
|
||||
}
|
||||
.rectification-board__close {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
margin-inline-start: auto;
|
||||
padding: 0 var(--space-3);
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease-out, transform 120ms ease-out;
|
||||
}
|
||||
.rectification-board__close svg { width: 18px; height: 18px; }
|
||||
.rectification-board__close:hover { background: var(--color-canvas-muted); }
|
||||
.rectification-board__close:active { transform: scale(.96); }
|
||||
.rectification-board__close:focus-visible,
|
||||
.rectification-board-peek:focus-visible {
|
||||
outline: 2px solid var(--color-focus);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { ArrowUp, Square } from "lucide-react";
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { parseAgentReply } from "@/lib/agent-reply";
|
||||
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
|
||||
@@ -258,6 +258,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const keyCounter = useRef(0);
|
||||
const openingStarted = useRef(false);
|
||||
const previousBoardResult = useRef<CandidateResult>(null);
|
||||
const runAbort = useRef<AbortController | null>(null);
|
||||
const [compactBoard, setCompactBoard] = useState(false);
|
||||
const [boardOpen, setBoardOpen] = useState(false);
|
||||
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
|
||||
@@ -284,6 +285,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}, [candidateResult]);
|
||||
|
||||
const closeBoard = useCallback(() => setBoardOpen(false), []);
|
||||
const toggleBoard = useCallback(() => setBoardOpen((current) => !current), []);
|
||||
|
||||
const stopRun = useCallback(() => {
|
||||
runAbort.current?.abort();
|
||||
}, []);
|
||||
|
||||
const setPending = useCallback((value: boolean) => {
|
||||
setBusy(value);
|
||||
@@ -349,10 +355,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
let activityReceiptState = createRectificationActivityReceiptState();
|
||||
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
|
||||
let completedTurnId: string | undefined;
|
||||
const abortController = new AbortController();
|
||||
runAbort.current = abortController;
|
||||
try {
|
||||
const response = await fetch("/api/rectification/agent", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
signal: abortController.signal,
|
||||
body: JSON.stringify({
|
||||
caseId,
|
||||
sessionId,
|
||||
@@ -418,6 +427,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? { ...message, text: parsed.text, state: "streaming" }
|
||||
: message));
|
||||
} else if (event.type === "attempt.reset") {
|
||||
raw = "";
|
||||
activityReceiptState = createRectificationActivityReceiptState();
|
||||
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
|
||||
completedTurnId = undefined;
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? { ...message, text: "", state: "thinking", completedReceipt: undefined, failed: false, turnId: undefined }
|
||||
: message));
|
||||
} else if (event.type === "run.failed") {
|
||||
streamFailed = true;
|
||||
} else if (event.type === "error") {
|
||||
@@ -483,7 +500,28 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
if (result) setCandidateResult(result);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
} catch (caught) {
|
||||
const aborted = caught instanceof DOMException
|
||||
? caught.name === "AbortError"
|
||||
: caught instanceof Error && caught.name === "AbortError";
|
||||
if (aborted) {
|
||||
const parsed = parseAgentReply(raw);
|
||||
setMessages((current) => current.flatMap((message): RenderMessage[] => {
|
||||
if (message.renderKey !== assistantRenderKey) return [message];
|
||||
if (parsed.text) {
|
||||
return [{
|
||||
...message,
|
||||
text: parsed.text,
|
||||
state: "settled",
|
||||
completedReceipt,
|
||||
failed: false,
|
||||
turnId: completedTurnId,
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setError("生时校正暂时不可用,请稍后再试。");
|
||||
setMessages((current) => current.flatMap((message): RenderMessage[] => {
|
||||
if (message.renderKey !== assistantRenderKey) return [message];
|
||||
@@ -498,6 +536,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
: [];
|
||||
}));
|
||||
} finally {
|
||||
if (runAbort.current === abortController) runAbort.current = null;
|
||||
setPending(false);
|
||||
}
|
||||
}, [busy, caseId, loadCandidate, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]);
|
||||
@@ -650,7 +689,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
ref={workspace}
|
||||
className={`rectification-workspace${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}`}
|
||||
>
|
||||
<div className="rectification-workspace__chat" inert={compactBoard && boardOpen ? true : undefined}>
|
||||
<div className="rectification-workspace__chat">
|
||||
<section ref={conversation} className="conversation is-rectification" aria-label="生时校正对话" aria-busy={busy || regeneratingMessageKey !== null}>
|
||||
<div className="message-list">
|
||||
{pendingConsultationQuestion?.trim() && (
|
||||
@@ -745,7 +784,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
result={candidateResult}
|
||||
expanded={boardOpen}
|
||||
boardId={boardId}
|
||||
onOpen={() => setBoardOpen(true)}
|
||||
onOpen={toggleBoard}
|
||||
/>
|
||||
)}
|
||||
<form className="composer" onSubmit={submit}>
|
||||
@@ -765,9 +804,22 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button aria-label="发送" disabled={!draft.trim() || !canSend} size="icon" type="submit">
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</Button>
|
||||
{busy ? (
|
||||
<Button
|
||||
className="composer-stop"
|
||||
aria-label="停止回答"
|
||||
title="停止当前推理,已生成内容会保留"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={stopRun}
|
||||
>
|
||||
<Square aria-hidden="true" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button aria-label="发送" disabled={!draft.trim() || !canSend} size="icon" type="submit">
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="composer-footer">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
groupWindowTransitions,
|
||||
rectificationBoardPeekCopy,
|
||||
@@ -63,6 +64,7 @@ function RectificationBoardBody({
|
||||
|
||||
return (
|
||||
<>
|
||||
{compact ? <div className="rectification-board__handle" aria-hidden="true" /> : null}
|
||||
<header className="rectification-board__header">
|
||||
<div className="rectification-board__title-row">
|
||||
<h2 id={titleId}>当前盘面</h2>
|
||||
@@ -71,9 +73,10 @@ function RectificationBoardBody({
|
||||
<button
|
||||
className="rectification-board__close"
|
||||
type="button"
|
||||
aria-label="关闭盘面"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭盘面
|
||||
<X aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -207,48 +210,31 @@ export function RectificationBoard({
|
||||
titleId: string;
|
||||
onClose: () => void;
|
||||
}>) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const body = (
|
||||
<RectificationBoardBody
|
||||
result={result}
|
||||
savedStatus={savedStatus}
|
||||
diff={diff}
|
||||
compact={compact}
|
||||
titleId={titleId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const node = dialogRef.current;
|
||||
if (!compact || !node) return;
|
||||
if (open) {
|
||||
if (!node.open) node.showModal();
|
||||
} else if (node.open) {
|
||||
node.close();
|
||||
}
|
||||
}, [compact, open]);
|
||||
if (!compact || !open) return;
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [compact, open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!compact) return;
|
||||
const node = dialogRef.current;
|
||||
if (!node) return;
|
||||
const handleClose = () => onClose();
|
||||
node.addEventListener("close", handleClose);
|
||||
return () => node.removeEventListener("close", handleClose);
|
||||
}, [compact, onClose]);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<dialog ref={dialogRef} id={boardId} className="rectification-board" aria-labelledby={titleId}>
|
||||
{body}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
if (compact && !open) return null;
|
||||
|
||||
return (
|
||||
<aside id={boardId} className="rectification-board" aria-labelledby={titleId}>
|
||||
{body}
|
||||
<aside
|
||||
id={boardId}
|
||||
className={`rectification-board${compact ? " is-sheet" : ""}`}
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<RectificationBoardBody
|
||||
result={result}
|
||||
savedStatus={savedStatus}
|
||||
diff={diff}
|
||||
compact={compact}
|
||||
titleId={titleId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* V10 rectification turn runner over the durable V9 Case/Evidence domains.
|
||||
*
|
||||
* Every model attempt is isolated. Text, tool sets, usage and public receipt
|
||||
* events are committed only from the successful attempt. The browser never
|
||||
* sees partial text from an attempt that is retried or abandoned.
|
||||
* Tool activity and answer tokens are published as they happen so the browser
|
||||
* can render progress. A retried attempt emits `attempt.reset` first so the
|
||||
* client discards the abandoned attempt's visible text. Durable receipts,
|
||||
* billing, and settled history still come only from the successful attempt.
|
||||
*/
|
||||
import type { Agent } from "@mastra/core/agent";
|
||||
import { RectificationAgentAction, resolveRectificationStepBudget } from "@/mastra/agentic-rectification";
|
||||
@@ -316,6 +317,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
|| outcome.status === "failed"
|
||||
|| !shouldAutoRetry(outcome.errorCode ?? "run_failed", signal)
|
||||
|| attemptNumber === MAX_ATTEMPTS) break;
|
||||
await emit({ type: "attempt.reset" });
|
||||
}
|
||||
|
||||
const outcome = finalOutcome ?? {
|
||||
@@ -336,7 +338,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
if (!outcome.ok) {
|
||||
await billing.release();
|
||||
await finalizeTurn(outcome.status, null, outcome.attemptId, null);
|
||||
for (const event of outcome.events) await emit(event);
|
||||
await emit({ type: "run.failed" });
|
||||
return {
|
||||
ok: false,
|
||||
@@ -404,9 +405,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
);
|
||||
await finalizeTurn("completed", outcome.answerText, outcome.attemptId, outcome.attemptId, true);
|
||||
|
||||
for (const event of outcome.events) await emit(event);
|
||||
await emit({ type: "billing.settled" });
|
||||
for (const text of outcome.answerDeltas) await emit({ type: "answer.delta", text });
|
||||
await emit({ type: "run.completed", turnId });
|
||||
|
||||
return {
|
||||
@@ -463,13 +462,18 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
let phaseSequence = 0;
|
||||
|
||||
const recordPhase = async (phase: string, tool: string | null = null) => {
|
||||
if (phase === "answer.delta" || emittedKeys.has(`${phase}:${tool ?? ""}`)) return;
|
||||
if (phase === "answer.delta" || phase === "attempt.reset" || emittedKeys.has(`${phase}:${tool ?? ""}`)) return;
|
||||
emittedKeys.add(`${phase}:${tool ?? ""}`);
|
||||
phases.push(phase);
|
||||
phaseSequence += 1;
|
||||
await persistCommittedPhase(phase, tool, attemptId, phaseSequence);
|
||||
};
|
||||
|
||||
const publish = async (event: PublicStreamEvent) => {
|
||||
events.push(event);
|
||||
await emit(event);
|
||||
};
|
||||
|
||||
try {
|
||||
await recordPhase("run.started");
|
||||
await insertV9SkillRunReceipt(
|
||||
@@ -482,7 +486,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
skillPackage,
|
||||
);
|
||||
await recordPhase("skill.bound");
|
||||
events.push({ type: "skill.bound" });
|
||||
await publish({ type: "skill.bound" });
|
||||
emittedKeys.add("event:skill.bound::");
|
||||
|
||||
const result = await (agent as unknown as {
|
||||
@@ -537,7 +541,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
|
||||
const activityEvent = mapStreamChunkToActivity(chunk as never);
|
||||
if (activityEvent) {
|
||||
events.push(activityEvent);
|
||||
await publish(activityEvent);
|
||||
if (activityEvent.status === "completed" || activityEvent.status === "failed") {
|
||||
toolTerminalStatus.set(activityEvent.tool, activityEvent.status);
|
||||
}
|
||||
@@ -562,22 +566,26 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
if (phaseEvent.type === "answer.delta") {
|
||||
const text = phaseEvent.text ?? "";
|
||||
if (text) {
|
||||
const hadVisible = Boolean(answerText.trim());
|
||||
answerText += text;
|
||||
answerDeltas.push(text);
|
||||
if (hadVisible || text.trim()) {
|
||||
await emit(phaseEvent);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await recordPhase(phaseEvent.type, phaseEvent.tool ?? null);
|
||||
const key = `${phaseEvent.type}:${phaseEvent.tool ?? ""}:${(phaseEvent.methods ?? []).join(",")}`;
|
||||
if (!emittedKeys.has(`event:${key}`)) {
|
||||
emittedKeys.add(`event:${key}`);
|
||||
events.push(phaseEvent);
|
||||
await publish(phaseEvent);
|
||||
}
|
||||
if (phaseEvent.type === "case.loaded") {
|
||||
caseLoaded = true;
|
||||
if (!intentClassified) {
|
||||
intentClassified = true;
|
||||
await recordPhase("intent.classified");
|
||||
events.push({ type: "intent.classified" });
|
||||
await publish({ type: "intent.classified" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -611,7 +619,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
|
||||
const usage = await (result.totalUsage ?? Promise.resolve({ inputTokens: 0, outputTokens: 0 }));
|
||||
await recordPhase("answer.composed");
|
||||
events.push({ type: "answer.composed" });
|
||||
await publish({ type: "answer.composed" });
|
||||
return {
|
||||
ok: true,
|
||||
status: "completed",
|
||||
|
||||
@@ -23,6 +23,7 @@ export const PUBLIC_RECTIFICATION_PHASES = [
|
||||
"answer.composed",
|
||||
"billing.settled",
|
||||
"answer.delta",
|
||||
"attempt.reset",
|
||||
"run.completed",
|
||||
"run.failed",
|
||||
] as const;
|
||||
|
||||
@@ -362,16 +362,48 @@ test("the natal house table is a live board beside the chat, not a message", ()
|
||||
assert.match(chat, /await loadCandidate\(\)\.then\(\(result\) => \{/);
|
||||
assert.match(board, /aria-live="polite"/);
|
||||
assert.match(board, /groupWindowTransitions/);
|
||||
assert.match(board, /<dialog/);
|
||||
assert.match(board, /<aside/);
|
||||
assert.doesNotMatch(board, /<dialog/);
|
||||
assert.doesNotMatch(board, /showModal/);
|
||||
assert.doesNotMatch(board, /onCloseRef\.current = onClose/);
|
||||
assert.match(board, /node\.addEventListener\("close", handleClose\)/);
|
||||
assert.match(board, /}, \[compact, onClose\]\);/);
|
||||
assert.match(board, /event\.key === "Escape"/);
|
||||
assert.match(board, /aria-label="关闭盘面"/);
|
||||
assert.match(board, /className="rectification-board__handle"/);
|
||||
assert.match(chat, /RectificationBoardPeek/);
|
||||
assert.match(chat, /onOpen=\{toggleBoard\}/);
|
||||
assert.doesNotMatch(chat, /inert=\{compactBoard && boardOpen/);
|
||||
assert.match(houseTable, /当前本命宫位/);
|
||||
assert.match(houseTable, /补充经历后会按新线索重算/);
|
||||
assert.doesNotMatch(houseTable, /<ChatMessageRow/);
|
||||
});
|
||||
|
||||
test("compact board shares the viewport with chat and uses an icon close control", () => {
|
||||
assert.match(styles, /\.rectification-workspace\.is-compact\.is-board-open \{[\s\S]*grid-template-rows: minmax\(8rem, 1fr\) minmax\(13rem, 46%\)/);
|
||||
assert.match(styles, /\.rectification-board\.is-sheet \{[\s\S]*border-block-start: 1px solid var\(--color-border\)/);
|
||||
assert.doesNotMatch(styles, /dialog\.rectification-board/);
|
||||
assert.match(styles, /\.rectification-board__close \{[\s\S]*width: 44px/);
|
||||
assert.match(styles, /\.rectification-board__close \{[\s\S]*min-height: 44px/);
|
||||
assert.match(board, /<X aria-hidden="true"/);
|
||||
assert.match(chat, /className=\{`rectification-workspace\$\{compactBoard \? " is-compact" : ""\}\$\{boardOpen \? " is-board-open" : ""\}`\}/);
|
||||
});
|
||||
|
||||
test("rectification composer can stop a live agent run", () => {
|
||||
assert.match(chat, /signal: abortController\.signal/);
|
||||
assert.match(chat, /runAbort\.current\?\.abort\(\)/);
|
||||
assert.match(chat, /className="composer-stop"/);
|
||||
assert.match(chat, /aria-label="停止回答"/);
|
||||
assert.match(chat, /event\.type === "attempt\.reset"/);
|
||||
assert.match(chat, /caught\.name === "AbortError"/);
|
||||
assert.match(chat, /showActivity=\{displayedMessage\.state === "thinking"\}/);
|
||||
});
|
||||
|
||||
test("conversation and house board reuse the quiet overlay scrollbar", () => {
|
||||
assert.match(styles, /\[data-sidebar="content"\],\s*\n\.conversation,\s*\n\.rectification-board__body \{/);
|
||||
assert.match(styles, /\.conversation \{[^}]*overflow-y:\s*auto/);
|
||||
assert.doesNotMatch(styles, /\.conversation \{[^}]*scrollbar-gutter/);
|
||||
assert.doesNotMatch(styles, /\.conversation:not\(\.is-empty\):not\(\.is-rectification\) \{[^}]*scrollbar-gutter/);
|
||||
});
|
||||
|
||||
test("time-selection cards appear under the latest settled agent bubble only after offer-candidates", () => {
|
||||
const messageLoop = chat.slice(
|
||||
chat.indexOf("{messages.map((message) => {"),
|
||||
|
||||
@@ -153,6 +153,7 @@ test("streamToolNames exposes only allowlisted rectification tools", () => {
|
||||
|
||||
test("safePublicEvent drops anything outside the allowlist", () => {
|
||||
assert.deepEqual(safePublicEvent({ type: "answer.delta", text: "你好" }), { type: "answer.delta", text: "你好" });
|
||||
assert.deepEqual(safePublicEvent({ type: "attempt.reset" }), { type: "attempt.reset" });
|
||||
assert.deepEqual(safePublicEvent({ type: "skill.loaded" }), { type: "skill.loaded" });
|
||||
assert.deepEqual(
|
||||
safePublicEvent({ type: "run.completed", turnId: TURN_ID }),
|
||||
@@ -448,6 +449,53 @@ test("answer deltas stream in order and reasoning is never forwarded", async ()
|
||||
assert.equal(emitted.some((event) => String(event.type).includes("raw")), false);
|
||||
});
|
||||
|
||||
test("answer deltas and tool activity are published before billing settles", async () => {
|
||||
let billingStarted = false;
|
||||
const seenBeforeBilling: string[] = [];
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
||||
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
|
||||
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
|
||||
});
|
||||
const { options, emitted, billing } = runOptions({
|
||||
accounting: accounting.client,
|
||||
billing: {
|
||||
reserve: async () => { billing.reserved += 1; return { success: true, status: 200 }; },
|
||||
complete: async () => {
|
||||
billingStarted = true;
|
||||
billing.completed += 1;
|
||||
return true;
|
||||
},
|
||||
release: async () => { billing.released += 1; return true; },
|
||||
},
|
||||
emit: (event) => {
|
||||
if (!billingStarted && (event.type === "answer.delta" || event.type === "tool.activity" || event.type === "answer.composed")) {
|
||||
seenBeforeBilling.push(event.type);
|
||||
}
|
||||
emitted.push(event);
|
||||
},
|
||||
buildAgent: async () => fakeAgentStream([
|
||||
chunk("start"),
|
||||
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
|
||||
chunk("tool-result", { toolName: "skill" }),
|
||||
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-read-case" }),
|
||||
chunk("text-delta", { text: "先记下这件事。" }),
|
||||
chunk("finish"),
|
||||
]) as never,
|
||||
});
|
||||
|
||||
const result = await runV9AgentTurn(options);
|
||||
assert.equal(result.ok, true);
|
||||
assert.ok(seenBeforeBilling.includes("tool.activity"));
|
||||
assert.ok(seenBeforeBilling.includes("answer.delta"));
|
||||
assert.ok(seenBeforeBilling.includes("answer.composed"));
|
||||
const deltaIndex = emitted.findIndex((event) => event.type === "answer.delta");
|
||||
const billingIndex = emitted.findIndex((event) => event.type === "billing.settled");
|
||||
assert.ok(deltaIndex >= 0 && billingIndex > deltaIndex);
|
||||
});
|
||||
|
||||
test("half-failure never becomes settled history and releases usage", async () => {
|
||||
const { options, emitted, billing } = runOptions({
|
||||
buildAgent: async () => fakeAgentStream([
|
||||
@@ -670,22 +718,18 @@ for (const failureCode of ["stream_aborted", "stream_unfinished", "empty_stream"
|
||||
"rectification-read-case",
|
||||
"rectification-record-evidence-batch",
|
||||
]);
|
||||
const resetAt = emitted.findIndex((event) => event.type === "attempt.reset");
|
||||
assert.ok(resetAt >= 0);
|
||||
assert.equal(
|
||||
emitted.some((event) => event.type === "answer.delta" && event.text?.includes("失败 attempt")),
|
||||
failureCode !== "empty_stream",
|
||||
);
|
||||
assert.deepEqual(
|
||||
emitted.filter((event) => event.type === "answer.delta"),
|
||||
emitted.slice(resetAt + 1).filter((event) => event.type === "answer.delta"),
|
||||
[{ type: "answer.delta", text: "第二次 attempt 成功" }],
|
||||
);
|
||||
assert.equal(
|
||||
emitted.some((event) => event.type === "answer.delta" && event.text?.includes("失败 attempt")),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
emitted.some((event) =>
|
||||
event.type === "tool.activity"
|
||||
&& (event as { tool?: string }).tool === "rectification-set-focus"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
emitted.some((event) =>
|
||||
emitted.slice(resetAt + 1).some((event) =>
|
||||
event.type === "tool.activity"
|
||||
&& (event as { tool?: string }).tool === "rectification-record-evidence-batch"),
|
||||
true,
|
||||
@@ -793,7 +837,8 @@ test("a failed set-focus cannot complete a question turn even when the agent emi
|
||||
assert.equal(result.answerText, "");
|
||||
assert.deepEqual(result.toolsUsed, ["rectification-read-case", "rectification-set-focus"]);
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
|
||||
assert.equal(emitted.some((event) => event.type === "answer.delta"), false);
|
||||
assert.equal(emitted.some((event) => event.type === "answer.delta"), true);
|
||||
assert.equal(emitted.some((event) => event.type === "attempt.reset"), true);
|
||||
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
|
||||
assert.equal(
|
||||
emitted.some((event) => event.type === "tool.activity"
|
||||
@@ -988,7 +1033,7 @@ test("thinking-mode tool_choice rejection fails the opening turn without a secon
|
||||
assert.equal(result.turnStatus, "failed");
|
||||
assert.equal(result.errorCode, "thinking_tool_choice_unsupported");
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
|
||||
assert.deepEqual(emitted, [{ type: "run.started" }, { type: "run.failed" }]);
|
||||
assert.deepEqual(emitted, [{ type: "run.started" }, { type: "skill.bound" }, { type: "run.failed" }]);
|
||||
assert.equal(
|
||||
accounting.calls.filter((call) => call.fn === "create_agentic_rectification_run_attempt").length,
|
||||
1,
|
||||
|
||||
@@ -296,6 +296,7 @@ test("styles the session history scrollbar as a quiet overlay", () => {
|
||||
assert.match(globalStyles, /@media\s*\(prefers-contrast:\s*more\)[\s\S]*\[data-sidebar="content"\][^{]*\{[^}]*scrollbar-color:\s*var\(--color-ink-secondary\)\s+transparent/);
|
||||
assert.match(globalStyles, /@media\s*\(forced-colors:\s*active\)[\s\S]*\[data-sidebar="content"\][^{]*\{[^}]*scrollbar-color:\s*auto/);
|
||||
assert.match(readProjectFile("DESIGN.md"), /quiet overlay scrollbar/);
|
||||
assert.match(readProjectFile("DESIGN.md"), /ordinary session/);
|
||||
});
|
||||
|
||||
test("renders each session title and menu as one unified row surface", () => {
|
||||
|
||||
Reference in New Issue
Block a user