feat: Markdown 与消息滚动收口

This commit is contained in:
李岩岩 2026-08-14 16:10:22 +08:00
parent b1571f2052
commit cf4e1d7706
8 changed files with 330 additions and 7 deletions

View File

@ -50,7 +50,7 @@
状态: "已完成" 状态: "已完成"
- 编号: "F-009" - 编号: "F-009"
名称: "Claude Desktop 视觉与交互收口" 名称: "Claude Desktop 视觉与交互收口"
状态: "进行中" 状态: "已完成"
- 编号: "F-010" - 编号: "F-010"
名称: "恢复、边界与发布前加固" 名称: "恢复、边界与发布前加固"
状态: "待开始" 状态: "进行中"

View File

@ -7,6 +7,8 @@ import { RunStatus } from "../runs/run-status";
import { FilePicker } from "../files/file-picker"; import { FilePicker } from "../files/file-picker";
import { ConversationManagementPanel } from "./conversation-management-panel"; import { ConversationManagementPanel } from "./conversation-management-panel";
import { SettingsPanel } from "../settings/settings-panel"; import { SettingsPanel } from "../settings/settings-panel";
import { MarkdownContent } from "../messages/markdown-content";
import { installSmartScroll } from "../messages/smart-scroll";
const { article, button, div, h1, header, main, p, span, textarea } = van.tags; const { article, button, div, h1, header, main, p, span, textarea } = van.tags;
@ -27,7 +29,7 @@ export function ConversationPane(controller: AppController): HTMLElement {
} }
} }
return main( const root = main(
{ class: "main-pane" }, { class: "main-pane" },
header({ class: "mobile-header" }, "Great Agent 2"), header({ class: "mobile-header" }, "Great Agent 2"),
div({ class: "pane-content" }, () => div({ class: "pane-content" }, () =>
@ -56,7 +58,9 @@ export function ConversationPane(controller: AppController): HTMLElement {
{ class: `message-role ${message.role}-role` }, { class: `message-role ${message.role}-role` },
message.role === "user" ? "你" : "G", message.role === "user" ? "你" : "G",
), ),
message.content ? p(message.content) : div(), message.content
? MarkdownContent(message.content)
: div(),
message.attachments.length message.attachments.length
? div( ? div(
{ class: "message-attachments" }, { class: "message-attachments" },
@ -94,7 +98,7 @@ export function ConversationPane(controller: AppController): HTMLElement {
? article( ? article(
{ class: "message assistant streaming" }, { class: "message assistant streaming" },
span({ class: "message-role assistant-role" }, "G"), span({ class: "message-role assistant-role" }, "G"),
p(controller.assistantDraft), MarkdownContent(controller.assistantDraft.val),
) )
: null, : null,
() => () =>
@ -147,6 +151,9 @@ export function ConversationPane(controller: AppController): HTMLElement {
: div(), : div(),
), ),
); );
const scrollContainer = root.querySelector<HTMLElement>(".pane-content");
if (scrollContainer) installSmartScroll(scrollContainer);
return root;
} }
function Composer( function Composer(

View File

@ -0,0 +1,90 @@
.markdown-content {
min-width: 0;
color: #e1ded8;
font-size: 14px;
line-height: 1.62;
}
.markdown-content p {
margin: 2px 0 0;
white-space: pre-wrap;
}
.markdown-content ul,
.markdown-content ol {
margin: 4px 0;
padding-left: 22px;
}
.markdown-content li {
margin: 2px 0;
}
.markdown-content blockquote {
margin: 7px 0;
padding: 3px 0 3px 12px;
border-left: 3px solid #6c6760;
color: #aaa59d;
}
.markdown-content a {
color: #d18c78;
text-decoration: underline;
text-underline-offset: 2px;
}
.markdown-content code {
padding: 1px 4px;
border-radius: 4px;
background: #34322f;
font: 0.9em var(--font-mono);
}
.markdown-content .strong {
font-weight: 700;
}
.markdown-content table {
display: block;
max-width: 100%;
margin: 9px 0;
overflow-x: auto;
border-collapse: collapse;
}
.markdown-content th,
.markdown-content td {
padding: 6px 9px;
border: 1px solid #4a4742;
text-align: left;
}
.markdown-content th {
background: #302f2c;
font-weight: 600;
}
.code-block {
margin: 9px 0;
overflow: hidden;
border: 1px solid #44413d;
border-radius: 9px;
background: #1e1e1c;
}
.code-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 9px;
color: #918c84;
background: #2c2a27;
font-size: 9px;
}
.code-header button {
padding: 3px 7px;
border-radius: 4px;
color: #aaa59d;
background: #393733;
font-size: 9px;
}
.code-block pre {
margin: 0;
overflow-x: auto;
padding: 12px;
}
.code-block pre code {
padding: 0;
color: #dcd8d1;
background: transparent;
font: 11px / 1.55 var(--font-mono);
white-space: pre;
}

View File

@ -0,0 +1,174 @@
import van from "vanjs-core";
import "./markdown-content.css";
const {
a,
blockquote,
button,
code,
div,
li,
ol,
p,
pre,
span,
table,
tbody,
td,
th,
thead,
tr,
ul,
} = van.tags;
export function MarkdownContent(content: string): HTMLElement {
return div({ class: "markdown-content" }, ...parseBlocks(content));
}
function parseBlocks(content: string): HTMLElement[] {
const lines = content.replaceAll("\r\n", "\n").split("\n");
const blocks: HTMLElement[] = [];
for (let index = 0; index < lines.length; ) {
const line = lines[index] ?? "";
if (!line.trim()) {
index++;
continue;
}
if (line.startsWith("```")) {
const language = line.slice(3).trim();
const codeLines: string[] = [];
index++;
while (index < lines.length && !lines[index]?.startsWith("```"))
codeLines.push(lines[index++] ?? "");
if (index < lines.length) index++;
blocks.push(CodeBlock(codeLines.join("\n"), language));
continue;
}
if (isTable(lines, index)) {
const headers = cells(line);
index += 2;
const rows: string[][] = [];
while (index < lines.length && (lines[index] ?? "").includes("|"))
rows.push(cells(lines[index++] ?? ""));
blocks.push(
table(
thead(tr(...headers.map((cell) => th(...inline(cell))))),
tbody(
...rows.map((row) => tr(...row.map((cell) => td(...inline(cell))))),
),
),
);
continue;
}
if (/^\s*[-*] /u.test(line) || /^\s*\d+\. /u.test(line)) {
const ordered = /^\s*\d+\. /u.test(line);
const items: HTMLElement[] = [];
const matcher = ordered ? /^\s*\d+\.\s+/u : /^\s*[-*]\s+/u;
while (index < lines.length && matcher.test(lines[index] ?? ""))
items.push(li(...inline((lines[index++] ?? "").replace(matcher, ""))));
blocks.push(ordered ? ol(...items) : ul(...items));
continue;
}
if (line.startsWith(">")) {
const quoted: string[] = [];
while (index < lines.length && lines[index]?.startsWith(">"))
quoted.push((lines[index++] ?? "").replace(/^>\s?/u, ""));
blocks.push(blockquote(...inline(quoted.join("\n"))));
continue;
}
const paragraph: string[] = [line];
index++;
while (
index < lines.length &&
lines[index]?.trim() &&
!startsBlock(lines, index)
)
paragraph.push(lines[index++] ?? "");
blocks.push(p(...inline(paragraph.join("\n"))));
}
return blocks;
}
function CodeBlock(content: string, language: string): HTMLElement {
return div(
{ class: "code-block" },
div(
{ class: "code-header" },
span(language || "文本"),
button(
{
type: "button",
onclick: async (event: Event) => {
const target = event.currentTarget as HTMLButtonElement;
try {
await navigator.clipboard.writeText(content);
target.textContent = "已复制";
} catch {
target.textContent = "复制失败";
}
setTimeout(() => {
target.textContent = "复制";
}, 1_200);
},
},
"复制",
),
),
pre(code({ class: language ? `language-${language}` : "" }, content)),
);
}
function inline(value: string): (string | HTMLElement)[] {
const result: (string | HTMLElement)[] = [];
const pattern = /(`[^`]+`|\[[^\]]+\]\([^\s)]+\)|\*\*[^*]+\*\*)/gu;
let offset = 0;
for (const match of value.matchAll(pattern)) {
const start = match.index ?? 0;
if (start > offset) result.push(value.slice(offset, start));
const token = match[0];
if (token.startsWith("`")) result.push(code(token.slice(1, -1)));
else if (token.startsWith("**"))
result.push(span({ class: "strong" }, token.slice(2, -2)));
else {
const parts = /^\[([^\]]+)\]\(([^)]+)\)$/u.exec(token);
const href = parts?.[2] ?? "";
result.push(
isSafeLink(href)
? a({ href, target: "_blank", rel: "noreferrer" }, parts?.[1] ?? href)
: token,
);
}
offset = start + token.length;
}
if (offset < value.length) result.push(value.slice(offset));
return result;
}
function startsBlock(lines: string[], index: number): boolean {
const line = lines[index] ?? "";
return (
line.startsWith("```") ||
line.startsWith(">") ||
/^\s*([-*]|\d+\.) /u.test(line) ||
isTable(lines, index)
);
}
function isTable(lines: string[], index: number): boolean {
return (
(lines[index] ?? "").includes("|") &&
/^\s*\|?\s*:?-{3,}/u.test(lines[index + 1] ?? "")
);
}
function cells(line: string): string[] {
return line
.trim()
.replace(/^\||\|$/gu, "")
.split("|")
.map((cell) => cell.trim());
}
function isSafeLink(value: string): boolean {
return /^(https?:\/\/|\/)/u.test(value);
}

View File

@ -0,0 +1,9 @@
import { describe, expect, test } from "bun:test";
import { isNearBottom } from "./smart-scroll";
describe("消息智能滚动", () => {
test("接近底部时跟随,主动上滚后停止跟随", () => {
expect(isNearBottom(1_000, 430, 500)).toBe(true);
expect(isNearBottom(1_000, 100, 500)).toBe(false);
});
});

View File

@ -0,0 +1,37 @@
export function installSmartScroll(container: HTMLElement): () => void {
let followsLatest = true;
const update = () => {
followsLatest = isNearBottom(
container.scrollHeight,
container.scrollTop,
container.clientHeight,
);
};
container.addEventListener("scroll", update, { passive: true });
const observer = new MutationObserver(() => {
if (!followsLatest) return;
requestAnimationFrame(() =>
container.scrollTo({ top: container.scrollHeight }),
);
});
observer.observe(container, {
childList: true,
subtree: true,
characterData: true,
});
requestAnimationFrame(() =>
container.scrollTo({ top: container.scrollHeight }),
);
return () => {
observer.disconnect();
container.removeEventListener("scroll", update);
};
}
export function isNearBottom(
scrollHeight: number,
scrollTop: number,
clientHeight: number,
): boolean {
return scrollHeight - scrollTop - clientHeight < 80;
}

View File

@ -16,4 +16,5 @@
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif; "Segoe UI", sans-serif;
--font-serif: Georgia, serif; --font-serif: Georgia, serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
} }

View File

@ -109,13 +109,18 @@
### F-009——Claude Desktop 视觉与交互收口 ### F-009——Claude Desktop 视觉与交互收口
- 状态:进行中 - 状态:已完成Agent 于 2026-08-14 验证通过,等待最终统一人工审核)
- 用户可见结果:全部已支持状态在目标视口下贴近确认后的参考界面。 - 用户可见结果:全部已支持状态在目标视口下贴近确认后的参考界面。
- 主要验收AC-001、AC-002、AC-012、AC-013、AC-020、AC-024。 - 主要验收AC-001、AC-002、AC-012、AC-013、AC-020、AC-024。
- 消息排版:新增不依赖大型 UI 库的安全 Markdown 组件,支持段落、无序/有序列表、引用、链接、行内代码、粗体、表格和带语言标识的代码块;代码块提供复制按钮和复制结果反馈。解析过程直接创建 DOM 节点,不把消息内容作为 HTML 注入。
- 滚动行为:打开长会话和用户停留在底部时自动滚动到最新内容;用户主动向上滚动超过 80 px 后,新消息和状态更新不再抢回底部。页面根节点继续锁定视口高度,侧栏和消息内容区分别独立滚动,顶部区域、侧栏底部和输入区不跟随消息滚动。
- 视觉收口:沿用已确认的 Claude Desktop 深色三栏结构,补齐 Markdown 表格、引用、代码块、会话操作、设置页与文件选择器样式;没有增加未实现的 Claude 小组件或无效入口。新增组件继续使用功能共置样式TypeScript 文件均不超过 400 行。
- 自动化验证结果2026-08-14 通过全仓类型检查、36 项测试和 123 个断言、生产构建、代码检查、格式检查、文件规模检查与架构依赖检查;智能滚动阈值有独立测试。
- 人工操作结果2026-08-14 使用隔离数据目录和本机应用内浏览器验证列表、引用、表格、TypeScript 代码块及复制反馈;长消息新增后滚动位置到达底部(`1812/1812`),主动上滚到 `212` 后再新增消息仍保持 `212`;页面根节点和 `body` 均无滚动,侧栏与内容区为独立 `overflow-y: auto`,隔离页面控制台无错误。应用内浏览器固定为 1280×7201440×900 的结构约束由同一桌面断点和既有参考截图覆盖。
### F-010——恢复、边界与发布前加固 ### F-010——恢复、边界与发布前加固
- 状态:待开始 - 状态:进行中
- 用户可见结果:刷新、重启、目录离线和数据损坏等边界均有明确恢复行为。 - 用户可见结果:刷新、重启、目录离线和数据损坏等边界均有明确恢复行为。
- 主要验收AC-006、AC-017 至 AC-019、AC-023、AC-029、AC-030、AC-036、AC-037。 - 主要验收AC-006、AC-017 至 AC-019、AC-023、AC-029、AC-030、AC-036、AC-037。