feat: Markdown 与消息滚动收口
This commit is contained in:
parent
b1571f2052
commit
cf4e1d7706
@ -50,7 +50,7 @@
|
||||
状态: "已完成"
|
||||
- 编号: "F-009"
|
||||
名称: "Claude Desktop 视觉与交互收口"
|
||||
状态: "进行中"
|
||||
状态: "已完成"
|
||||
- 编号: "F-010"
|
||||
名称: "恢复、边界与发布前加固"
|
||||
状态: "待开始"
|
||||
状态: "进行中"
|
||||
|
||||
@ -7,6 +7,8 @@ import { RunStatus } from "../runs/run-status";
|
||||
import { FilePicker } from "../files/file-picker";
|
||||
import { ConversationManagementPanel } from "./conversation-management-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;
|
||||
|
||||
@ -27,7 +29,7 @@ export function ConversationPane(controller: AppController): HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
return main(
|
||||
const root = main(
|
||||
{ class: "main-pane" },
|
||||
header({ class: "mobile-header" }, "Great Agent 2"),
|
||||
div({ class: "pane-content" }, () =>
|
||||
@ -56,7 +58,9 @@ export function ConversationPane(controller: AppController): HTMLElement {
|
||||
{ class: `message-role ${message.role}-role` },
|
||||
message.role === "user" ? "你" : "G",
|
||||
),
|
||||
message.content ? p(message.content) : div(),
|
||||
message.content
|
||||
? MarkdownContent(message.content)
|
||||
: div(),
|
||||
message.attachments.length
|
||||
? div(
|
||||
{ class: "message-attachments" },
|
||||
@ -94,7 +98,7 @@ export function ConversationPane(controller: AppController): HTMLElement {
|
||||
? article(
|
||||
{ class: "message assistant streaming" },
|
||||
span({ class: "message-role assistant-role" }, "G"),
|
||||
p(controller.assistantDraft),
|
||||
MarkdownContent(controller.assistantDraft.val),
|
||||
)
|
||||
: null,
|
||||
() =>
|
||||
@ -147,6 +151,9 @@ export function ConversationPane(controller: AppController): HTMLElement {
|
||||
: div(),
|
||||
),
|
||||
);
|
||||
const scrollContainer = root.querySelector<HTMLElement>(".pane-content");
|
||||
if (scrollContainer) installSmartScroll(scrollContainer);
|
||||
return root;
|
||||
}
|
||||
|
||||
function Composer(
|
||||
|
||||
90
apps/web/src/features/messages/markdown-content.css
Normal file
90
apps/web/src/features/messages/markdown-content.css
Normal 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;
|
||||
}
|
||||
174
apps/web/src/features/messages/markdown-content.ts
Normal file
174
apps/web/src/features/messages/markdown-content.ts
Normal 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);
|
||||
}
|
||||
9
apps/web/src/features/messages/smart-scroll.test.ts
Normal file
9
apps/web/src/features/messages/smart-scroll.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
37
apps/web/src/features/messages/smart-scroll.ts
Normal file
37
apps/web/src/features/messages/smart-scroll.ts
Normal 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;
|
||||
}
|
||||
@ -16,4 +16,5 @@
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
@ -109,13 +109,18 @@
|
||||
|
||||
### F-009——Claude Desktop 视觉与交互收口
|
||||
|
||||
- 状态:进行中
|
||||
- 状态:已完成(Agent 于 2026-08-14 验证通过,等待最终统一人工审核)
|
||||
- 用户可见结果:全部已支持状态在目标视口下贴近确认后的参考界面。
|
||||
- 主要验收: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×720,1440×900 的结构约束由同一桌面断点和既有参考截图覆盖。
|
||||
|
||||
### F-010——恢复、边界与发布前加固
|
||||
|
||||
- 状态:待开始
|
||||
- 状态:进行中
|
||||
- 用户可见结果:刷新、重启、目录离线和数据损坏等边界均有明确恢复行为。
|
||||
- 主要验收:AC-006、AC-017 至 AC-019、AC-023、AC-029、AC-030、AC-036、AC-037。
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user