@xgspz/cli-ui 0.0.0-stage → 0.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +512 -2
  3. package/dist/cli-ui-embed-D-3DvyTN.js +50279 -0
  4. package/dist/cli-ui-index-C34qFb4u.js +11209 -0
  5. package/dist/cli-ui-jszip.min-BObcE2nn.js +26 -0
  6. package/dist/cli-ui-jszip.min-DojbZPiE.js +2324 -0
  7. package/dist/cli-ui-xlsx-JVLtx0qR.js +93 -0
  8. package/dist/cli-ui.css +32 -0
  9. package/dist/cli-ui.js +17 -0
  10. package/package.json +60 -4
  11. package/src/board/BrowserPane.tsx +969 -0
  12. package/src/board/ChangesPane.tsx +185 -0
  13. package/src/board/CliBoard.tsx +484 -0
  14. package/src/board/FileViewer.tsx +721 -0
  15. package/src/board/FilesPane.tsx +262 -0
  16. package/src/board/HtmlReadingPreview.tsx +52 -0
  17. package/src/board/MarkdownPreview.tsx +134 -0
  18. package/src/board/PanelControls.tsx +40 -0
  19. package/src/board/blocks.tsx +97 -0
  20. package/src/board/context.ts +65 -0
  21. package/src/board/diffLines.ts +46 -0
  22. package/src/board/fileKind.ts +250 -0
  23. package/src/board/fileOps.ts +85 -0
  24. package/src/board/fileScope.ts +32 -0
  25. package/src/board/fileSync.ts +351 -0
  26. package/src/board/fileSyncCache.ts +250 -0
  27. package/src/board/fileSyncProcessor.ts +72 -0
  28. package/src/board/fileSyncQueue.ts +56 -0
  29. package/src/board/fileSyncTransport.ts +85 -0
  30. package/src/board/fileWatch.ts +211 -0
  31. package/src/board/folderDownload.ts +211 -0
  32. package/src/board/frameQuote.ts +52 -0
  33. package/src/board/htmlReading.ts +148 -0
  34. package/src/board/panels.ts +33 -0
  35. package/src/board/textLoad.ts +76 -0
  36. package/src/board/useBoardUpload.tsx +51 -0
  37. package/src/board/useBoardVisibility.ts +35 -0
  38. package/src/board/workspace.ts +200 -0
  39. package/src/board/zip.ts +85 -0
  40. package/src/chat/CliChat.tsx +932 -0
  41. package/src/chat/Composer.tsx +239 -0
  42. package/src/chat/GroupChat.tsx +113 -0
  43. package/src/chat/RunControls.tsx +196 -0
  44. package/src/chat/configuration.ts +15 -0
  45. package/src/chat/draft.ts +49 -0
  46. package/src/chat/export.ts +34 -0
  47. package/src/chat/queueEdit.ts +27 -0
  48. package/src/chat/useExecutionConfig.ts +93 -0
  49. package/src/components.tsx +13 -0
  50. package/src/core/blocks.ts +205 -0
  51. package/src/core/callbacks.ts +8 -0
  52. package/src/core/client.ts +822 -0
  53. package/src/core/config.ts +252 -0
  54. package/src/core/conversation.ts +577 -0
  55. package/src/core/documentUpload.ts +196 -0
  56. package/src/core/execution.ts +24 -0
  57. package/src/core/id.ts +15 -0
  58. package/src/core/identity.ts +21 -0
  59. package/src/core/provider.tsx +141 -0
  60. package/src/core/sha256.ts +21 -0
  61. package/src/core/sse.ts +51 -0
  62. package/src/core/store.ts +1103 -0
  63. package/src/core/subagents.ts +78 -0
  64. package/src/core/textDecode.ts +24 -0
  65. package/src/core/types.ts +505 -0
  66. package/src/core/xlsx.ts +142 -0
  67. package/src/elements.tsx +376 -0
  68. package/src/embed.ts +56 -0
  69. package/src/index.ts +56 -0
  70. package/src/layout/CliSplit.tsx +53 -0
  71. package/src/layout/split.ts +251 -0
  72. package/src/msglist/CliMsgList.tsx +422 -0
  73. package/src/operator/branchFlow.ts +35 -0
  74. package/src/operator/index.ts +9 -0
  75. package/src/operator/liveStream.ts +1 -0
  76. package/src/operator/node/AgentConnect.tsx +619 -0
  77. package/src/operator/node/AppearancePanel.tsx +35 -0
  78. package/src/operator/node/BranchHistory.tsx +67 -0
  79. package/src/operator/node/BranchNavigator.tsx +36 -0
  80. package/src/operator/node/BrowserPane.tsx +128 -0
  81. package/src/operator/node/BrowserPopup.tsx +22 -0
  82. package/src/operator/node/Chat.tsx +538 -0
  83. package/src/operator/node/CommandPalette.tsx +213 -0
  84. package/src/operator/node/Composer.tsx +316 -0
  85. package/src/operator/node/InsightsView.tsx +831 -0
  86. package/src/operator/node/Layout.tsx +192 -0
  87. package/src/operator/node/Markdown.tsx +61 -0
  88. package/src/operator/node/Modal.tsx +179 -0
  89. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  90. package/src/operator/node/OnboardingWizard.tsx +248 -0
  91. package/src/operator/node/Orchestrator.tsx +644 -0
  92. package/src/operator/node/ProjectLanding.tsx +86 -0
  93. package/src/operator/node/ProjectsColumn.tsx +137 -0
  94. package/src/operator/node/ProviderAuth.tsx +559 -0
  95. package/src/operator/node/RunControls.tsx +2 -0
  96. package/src/operator/node/RunSwitch.tsx +113 -0
  97. package/src/operator/node/Services.tsx +245 -0
  98. package/src/operator/node/SessionRail.tsx +299 -0
  99. package/src/operator/node/SessionView.tsx +292 -0
  100. package/src/operator/node/SettingsView.tsx +690 -0
  101. package/src/operator/node/TaskBoard.tsx +352 -0
  102. package/src/operator/node/TaskChanges.tsx +526 -0
  103. package/src/operator/node/TaskHero.tsx +50 -0
  104. package/src/operator/node/TasksColumn.tsx +200 -0
  105. package/src/operator/node/Terminal.tsx +143 -0
  106. package/src/operator/node/Transcript.tsx +485 -0
  107. package/src/operator/node/ViewFile.tsx +79 -0
  108. package/src/operator/node/Welcome.tsx +101 -0
  109. package/src/operator/node/WorktreePrune.tsx +333 -0
  110. package/src/operator/node/agents.ts +58 -0
  111. package/src/operator/node/api.ts +57 -0
  112. package/src/operator/node/boardBackend.ts +63 -0
  113. package/src/operator/node/boardEvents.ts +40 -0
  114. package/src/operator/node/boardInteraction.ts +15 -0
  115. package/src/operator/node/boardState.ts +74 -0
  116. package/src/operator/node/boardWorkspace.ts +15 -0
  117. package/src/operator/node/boundedPoll.ts +47 -0
  118. package/src/operator/node/branchFlow.ts +1 -0
  119. package/src/operator/node/composerDraft.ts +78 -0
  120. package/src/operator/node/fileLinks.ts +81 -0
  121. package/src/operator/node/format.ts +292 -0
  122. package/src/operator/node/github.tsx +232 -0
  123. package/src/operator/node/icons.tsx +88 -0
  124. package/src/operator/node/liveStream.ts +323 -0
  125. package/src/operator/node/modals.tsx +766 -0
  126. package/src/operator/node/navHistory.ts +70 -0
  127. package/src/operator/node/persist.ts +26 -0
  128. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  129. package/src/operator/node/protocol/agents/types.ts +394 -0
  130. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  131. package/src/operator/node/protocol/authFailure.ts +66 -0
  132. package/src/operator/node/protocol/customModels.ts +23 -0
  133. package/src/operator/node/protocol/events.ts +13 -0
  134. package/src/operator/node/protocol/features.ts +70 -0
  135. package/src/operator/node/protocol/internalUsage.ts +1 -0
  136. package/src/operator/node/protocol/promptLimits.ts +52 -0
  137. package/src/operator/node/protocol/storageRetention.ts +13 -0
  138. package/src/operator/node/protocol/types.ts +335 -0
  139. package/src/operator/node/protocol/usageLimit.ts +72 -0
  140. package/src/operator/node/shared.tsx +89 -0
  141. package/src/operator/node/transcriptModel.ts +129 -0
  142. package/src/operator/node/types.ts +305 -0
  143. package/src/operator/node/useGlobalEvents.ts +97 -0
  144. package/src/operator/node/useOrchestrator.ts +697 -0
  145. package/src/operator/node/usePrefs.ts +114 -0
  146. package/src/operator/node/useRecaps.ts +102 -0
  147. package/src/operator/node/useTaskStream.ts +286 -0
  148. package/src/operator.base.css +1949 -0
  149. package/src/operator.css +1949 -0
  150. package/src/styles.css +579 -0
  151. package/src/ui/ErrorBoundary.tsx +38 -0
  152. package/src/ui/Markdown.tsx +60 -0
  153. package/src/ui/Popover.tsx +95 -0
  154. package/src/ui/QueuedMessages.tsx +110 -0
  155. package/src/ui/ReferenceCard.tsx +28 -0
  156. package/src/ui/SubagentChat.tsx +233 -0
  157. package/src/ui/TextBlock.tsx +51 -0
  158. package/src/ui/Transcript.tsx +435 -0
  159. package/src/ui/UploadProgress.tsx +46 -0
  160. package/src/ui/WorkspaceDrawer.tsx +64 -0
  161. package/src/ui/WorkspaceTabs.tsx +60 -0
  162. package/src/ui/fileLinks.ts +52 -0
  163. package/src/ui/format.ts +103 -0
  164. package/src/ui/icons.tsx +92 -0
  165. package/src/ui/reference.ts +40 -0
  166. package/src/ui/shared.tsx +38 -0
  167. package/src/ui/transcriptModel.ts +151 -0
  168. package/src/ui/useChatScrollbar.ts +29 -0
  169. package/src/ui/useComposerAutosize.ts +54 -0
@@ -0,0 +1,60 @@
1
+ import { memo, useContext, type ComponentProps, type MouseEvent } from "react";
2
+ import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import rehypeHighlight from "rehype-highlight";
5
+ import { FileLinkContext, FileOpenContext, inlineFilePath, localFilePath } from "./fileLinks";
6
+
7
+ // 渲染 Agent 的 markdown 输出:标题、列表、表格、带语言的代码块(高亮)、行内代码、链接。
8
+ // 与 Operator 的 Markdown.tsx 一致:只高亮写明语言的代码块(自动识别在流式输出时很耗),
9
+ // 并 memo,文字没变的消息重渲染时跳过整个解析。
10
+ // 本地文件链接按 Operator 的规则识别:有地址(FileLinkContext)就改写过去,没有就显示成不可点的文字。
11
+ function MdLink({ href, children, ...props }: ComponentProps<"a">) {
12
+ const resolve = useContext(FileLinkContext);
13
+ const open = useContext(FileOpenContext);
14
+ const local = localFilePath(href);
15
+ if (local) {
16
+ const url = resolve?.(local, "link") ?? null;
17
+ if (!url && !open) return <span className="cli-local-link" title={`本地文件:${local}`}>{children}</span>;
18
+ const onClick = (e: MouseEvent<HTMLAnchorElement>) => {
19
+ if (!open || (url && (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0))) return;
20
+ if (open(local) || !url) e.preventDefault();
21
+ };
22
+ return <a {...props} href={url ?? "#"} target="_blank" rel="noreferrer" title={`打开文件:${local}`} onClick={onClick}>{children}</a>;
23
+ }
24
+ return <a {...props} href={href} target="_blank" rel="noreferrer">{children}</a>;
25
+ }
26
+
27
+ function MdCode({children, ...props}: ComponentProps<"code">) {
28
+ const path=inlineFilePath(children);
29
+ return path ? <MdLink href={path}><code {...props}>{children}</code></MdLink> : <code {...props}>{children}</code>;
30
+ }
31
+
32
+ function MdImg({ src, alt, ...props }: ComponentProps<"img">) {
33
+ const resolve = useContext(FileLinkContext);
34
+ const local = typeof src === "string" ? localFilePath(src) : null;
35
+ if (local) {
36
+ const url = resolve?.(local, "image") ?? null;
37
+ if (!url) return <span className="cli-local-link" title={`本地文件:${local}`}>{alt || local}</span>;
38
+ // eslint-disable-next-line jsx-a11y/alt-text
39
+ return <img {...props} alt={alt ?? ""} src={url} />;
40
+ }
41
+ return <img {...props} alt={alt ?? ""} src={src} />;
42
+ }
43
+
44
+ // 缺省的地址处理会把 file: 等非网页协议清空;放行 file: 和 Windows 路径,交给上面按本地文件处理(与 Operator 一致)。
45
+ const urlTransform = (url: string) => (/^file:/i.test(url) || /^[a-z]:[\\/]/i.test(url) ? url : defaultUrlTransform(url));
46
+
47
+ export const Markdown = memo(function Markdown({ children, highlight = true }: { children: string; highlight?: boolean }) {
48
+ return (
49
+ <div className="md">
50
+ <ReactMarkdown
51
+ remarkPlugins={[remarkGfm]}
52
+ rehypePlugins={highlight ? [[rehypeHighlight, { detect: false, ignoreMissing: true }]] : []}
53
+ components={{ a: MdLink, img: MdImg, code: MdCode }}
54
+ urlTransform={urlTransform}
55
+ >
56
+ {children}
57
+ </ReactMarkdown>
58
+ </div>
59
+ );
60
+ });
@@ -0,0 +1,95 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
2
+ import { createPortal } from "react-dom";
3
+ export function Popover({ children, onClose, className, ariaLabel = "选项", placement = "auto", align = "end", returnFocusRef }: { children: React.ReactNode; onClose: () => void; className?: string; ariaLabel?: string; placement?: "auto" | "top"; align?: "start" | "end"; returnFocusRef?: RefObject<HTMLElement | null> }) {
4
+ const markerRef = useRef<HTMLSpanElement>(null);
5
+ const menuRef = useRef<HTMLDivElement>(null);
6
+ const placeRef = useRef<(() => void) | null>(null);
7
+ const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
8
+
9
+ useLayoutEffect(() => {
10
+ const anchor = markerRef.current?.parentElement;
11
+ const menu = menuRef.current;
12
+ if (!anchor || !menu) return;
13
+ const place = () => {
14
+ const r = anchor.getBoundingClientRect();
15
+ const vv = window.visualViewport;
16
+ const topEdge = vv?.offsetTop ?? 0;
17
+ const vh = vv?.height ?? window.innerHeight;
18
+ const leftEdge = vv?.offsetLeft ?? 0;
19
+ const vw = vv?.width ?? window.innerWidth;
20
+ menu.style.minWidth = "0";
21
+ menu.style.maxWidth = `${Math.max(0, vw - 16)}px`;
22
+ menu.style.maxHeight = `${Math.max(0, vh - 16)}px`;
23
+ const mw = menu.offsetWidth, mh = menu.offsetHeight;
24
+ const preferredLeft = align === "start" ? r.left : r.right - mw;
25
+ const left = Math.max(leftEdge + 8, Math.min(preferredLeft, leftEdge + vw - mw - 8));
26
+ const below = r.bottom + 6;
27
+ const preferredTop = placement !== "top" && below + mh <= topEdge + vh - 8 ? below : r.top - mh - 6;
28
+ const top = Math.max(topEdge + 8, Math.min(preferredTop, topEdge + vh - mh - 8));
29
+ setPos({ top, left });
30
+ };
31
+ placeRef.current = place;
32
+ place();
33
+ const observer = new ResizeObserver(place);
34
+ observer.observe(menu);
35
+ window.addEventListener("resize", place);
36
+ window.visualViewport?.addEventListener("resize", place);
37
+ window.visualViewport?.addEventListener("scroll", place);
38
+ return () => { placeRef.current = null; observer.disconnect(); window.removeEventListener("resize", place); window.visualViewport?.removeEventListener("resize", place); window.visualViewport?.removeEventListener("scroll", place); };
39
+ }, [placement, align]);
40
+
41
+ const placed = pos !== null;
42
+ useEffect(() => {
43
+ if (placed) menuRef.current?.querySelector<HTMLElement>("button:not(:disabled),input:not(:disabled),select:not(:disabled),textarea:not(:disabled),[tabindex='0']")?.focus({ preventScroll: true });
44
+ }, [placed]);
45
+
46
+ useEffect(() => {
47
+ const close = (e: PointerEvent) => {
48
+ if (!menuRef.current?.contains(e.target as Node) && !markerRef.current?.parentElement?.contains(e.target as Node)) onClose();
49
+ };
50
+ const onScroll = (e: Event) => {
51
+ const menu = menuRef.current;
52
+ if (e.target instanceof Node && menu?.contains(e.target)) return;
53
+ const focused = document.activeElement;
54
+ // A phone keyboard may scroll the outer workspace while the card owns input focus.
55
+ if (menu?.classList.contains("composer-popover") && focused && menu.contains(focused) && focused.matches("input,textarea,select,[contenteditable='true']")) {
56
+ placeRef.current?.();
57
+ return;
58
+ }
59
+ onClose();
60
+ };
61
+ const onKey = (e: KeyboardEvent) => {
62
+ if (e.key === "Escape") {
63
+ e.preventDefault(); e.stopPropagation(); onClose();
64
+ (returnFocusRef?.current ?? markerRef.current?.parentElement?.querySelector<HTMLButtonElement>("button"))?.focus();
65
+ }
66
+ if (e.key === "Tab") {
67
+ const items = Array.from(menuRef.current?.querySelectorAll<HTMLElement>("button:not(:disabled),input:not(:disabled),select:not(:disabled),textarea:not(:disabled),a[href],[tabindex='0']") ?? []);
68
+ if (!items.length) return;
69
+ if (e.shiftKey && document.activeElement === items[0]) { e.preventDefault(); items.at(-1)?.focus(); }
70
+ else if (!e.shiftKey && document.activeElement === items.at(-1)) { e.preventDefault(); items[0].focus(); }
71
+ }
72
+ };
73
+ window.addEventListener("pointerdown", close, true);
74
+ window.addEventListener("scroll", onScroll, true);
75
+ window.addEventListener("keydown", onKey, true);
76
+ return () => { window.removeEventListener("pointerdown", close, true); window.removeEventListener("scroll", onScroll, true); window.removeEventListener("keydown", onKey, true); };
77
+ }, [onClose, returnFocusRef]);
78
+
79
+ return (
80
+ <>
81
+ <span ref={markerRef} style={{ display: "none" }} />
82
+ {createPortal(
83
+ <div className="cli-ui" data-theme={markerRef.current?.closest("[data-theme]")?.getAttribute("data-theme") ?? "dark"}><div
84
+ ref={menuRef}
85
+ className={`popover${className ? ` ${className}` : ""}`} role="dialog" aria-label={ariaLabel}
86
+ style={{ position: "fixed", top: pos?.top ?? -9999, left: pos?.left ?? -9999, right: "auto", visibility: pos ? "visible" : "hidden" }}
87
+ onClick={(e) => e.stopPropagation()}
88
+ >
89
+ {children}
90
+ </div></div>,
91
+ document.body,
92
+ )}
93
+ </>
94
+ );
95
+ }
@@ -0,0 +1,110 @@
1
+ import { useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react";
2
+ import type { Msg } from "../core/types";
3
+ import { splitMessage } from "../core/blocks";
4
+ import { withoutBoardContext } from "../board/context";
5
+ import { Icon } from "./icons";
6
+ import { splitAttachments, type MsgAttachment } from "./format";
7
+ import { MessageView, type MessageViewOptions } from "./Transcript";
8
+ import { Popover } from "./Popover";
9
+
10
+ const detailView: MessageViewOptions = { timestamps: false, initialBadge: false, userLabel: "你", agentName: "智能体", viewingBlock: false };
11
+ type QueueAction = (message: Msg) => void | Promise<unknown>;
12
+ export interface QueuedMessagesProps {
13
+ scopeKey: string;
14
+ messages: readonly Msg[];
15
+ /** 删除:从队列中撤回。不传 = 只读(三个操作都不显示)。 */
16
+ onCancel?: QueueAction;
17
+ /**
18
+ * 立即发送:打断正在执行的回复,排队消息随即按顺序发出。只在有回复正在执行时传;
19
+ * 只对排在最前面的一条可用(后面的要等前面的先发)。
20
+ */
21
+ onSendNow?: QueueAction;
22
+ /** 没有 onSendNow 时按钮上的说明(缺省:当前没有正在执行的回复)。 */
23
+ sendNowUnavailable?: string;
24
+ /** 编辑:先撤回这一条,撤回成功后把内容放回输入框(与输入框里已有的草稿合并)。 */
25
+ onEdit?: QueueAction;
26
+ attachmentUrl?: (attachment: MsgAttachment) => string | null;
27
+ renderMessage?: (message: Msg) => ReactNode;
28
+ }
29
+
30
+ /** The original live queue remains authoritative; this view never removes or resends messages locally. */
31
+ export function QueuedMessages({ scopeKey, messages, onCancel, onSendNow, sendNowUnavailable, onEdit, attachmentUrl, renderMessage }: QueuedMessagesProps) {
32
+ if (!messages.length) return null;
33
+ return <div className="cli-queue" role="list" aria-label={`排队消息(${messages.length})`}>
34
+ {messages.map((message, index) => <QueuedMessage key={`${scopeKey}:${message.id}`} message={message} index={index} onCancel={onCancel} onSendNow={onSendNow} sendNowUnavailable={sendNowUnavailable} onEdit={onEdit} attachmentUrl={attachmentUrl} renderMessage={renderMessage} />)}
35
+ </div>;
36
+ }
37
+
38
+ type Busy = "send" | "edit" | "cancel";
39
+ const BUSY_TEXT: Record<Busy, string> = { send: "正在打断当前回复…", edit: "正在撤回到输入框…", cancel: "正在删除…" };
40
+
41
+ function QueuedMessage({ message, index, onCancel, onSendNow, sendNowUnavailable, onEdit, attachmentUrl, renderMessage }: Omit<QueuedMessagesProps, "messages" | "scopeKey"> & { message: Msg; index: number }) {
42
+ const [open, setOpen] = useState(false);
43
+ const [copying, setCopying] = useState(false);
44
+ const [feedback, setFeedback] = useState<{ error: boolean; text: string } | null>(null);
45
+ // 一次只做一个操作:在途时三个按钮都不可点,免得重复撤回 / 重复打断。
46
+ const [busy, setBusy] = useState<Busy | null>(null);
47
+ const [actionError, setActionError] = useState<string | null>(null);
48
+ const alive = useRef(true);
49
+ useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
50
+ const returnFocusRef = useRef<HTMLButtonElement | null>(null);
51
+ const content = useMemo(() => withoutBoardContext(message.content), [message.content]);
52
+ const preview = useMemo(() => {
53
+ const { text, attachments } = splitAttachments(content);
54
+ const body = splitMessage(text).map(part => part.kind === "text" ? part.text : part.block.name).join(" · ").replace(/\s+/g, " ").trim().slice(0, 160);
55
+ const summary = [body, ...attachments.map(attachment => attachment.name)].filter(Boolean).join(" · ");
56
+ return { summary: summary || "查看排队消息", attachments };
57
+ }, [content]);
58
+ const thumbnail = preview.attachments.filter(attachment => attachment.kind === "image").map(attachment => ({ attachment, url: attachmentUrl?.(attachment) })).find(item => item.url);
59
+ const toggle = (event: MouseEvent<HTMLButtonElement>) => { returnFocusRef.current = event.currentTarget; setOpen(value => !value); setFeedback(null); };
60
+ const copy = async () => {
61
+ if (copying) return;
62
+ setCopying(true); setFeedback(null);
63
+ try {
64
+ if (!navigator.clipboard?.writeText) throw new Error("当前浏览器不支持复制,请选择消息内容手动复制");
65
+ await navigator.clipboard.writeText(content);
66
+ setFeedback({ error: false, text: "消息已复制" });
67
+ } catch (error) {
68
+ setFeedback({ error: true, text: error instanceof Error ? error.message : "复制失败,请重试" });
69
+ } finally { setCopying(false); }
70
+ };
71
+ const run = async (kind: Busy, action: QueueAction | undefined) => {
72
+ if (!action || busy) return;
73
+ setBusy(kind); setActionError(null); setOpen(false);
74
+ try {
75
+ await action(message);
76
+ } catch (error) {
77
+ // 宿主自己报过错的会正常返回;抛出来的在这一行里说明。
78
+ if (alive.current) setActionError(error instanceof Error ? error.message : String(error));
79
+ } finally {
80
+ if (alive.current) setBusy(null);
81
+ }
82
+ };
83
+ const n = index + 1;
84
+ const actions = !!onCancel;
85
+ const sendBlocked = !onSendNow ? sendNowUnavailable ?? "当前没有正在执行的回复,排队消息会自动发送" : index > 0 ? `前面还有 ${index} 条排队消息,会先按顺序发送` : null;
86
+ const sendTitle = busy === "send" ? BUSY_TEXT.send : sendBlocked ?? "立即发送:打断当前回复,马上发送这条消息";
87
+ return <div className="cli-queue-row" role="listitem" data-queued-id={message.id} aria-busy={busy ? true : undefined}>
88
+ <span className="cli-queue-icon" aria-hidden="true">{Icon.list()}</span>
89
+ <button className="cli-queue-summary" aria-label={`查看第 ${n} 条排队消息`} aria-expanded={open} aria-haspopup="dialog" onClick={toggle} title={preview.summary}>
90
+ {thumbnail?.url && <img className="cli-queue-thumb" src={thumbnail.url} alt={thumbnail.attachment.name} loading="lazy" onError={event => { event.currentTarget.hidden = true; }} />}
91
+ <span className="cli-queue-text">{busy ? BUSY_TEXT[busy] : preview.summary}</span>
92
+ </button>
93
+ {actions && <div className="cli-queue-acts">
94
+ <button className="cli-queue-act cli-queue-send" title={sendTitle} aria-label={`立即发送第 ${n} 条排队消息`}
95
+ disabled={!!busy || !!sendBlocked} aria-busy={busy === "send" || undefined} onClick={() => void run("send", onSendNow)}>{busy === "send" ? <span className="spinner" /> : Icon.enter()}</button>
96
+ {onEdit && <button className="cli-queue-act cli-queue-edit" title={busy === "edit" ? BUSY_TEXT.edit : "编辑:撤回这条消息,放回输入框修改"} aria-label={`编辑第 ${n} 条排队消息`}
97
+ disabled={!!busy} aria-busy={busy === "edit" || undefined} onClick={() => void run("edit", onEdit)}>{busy === "edit" ? <span className="spinner" /> : Icon.edit()}</button>}
98
+ <button className="cli-queue-act cli-queue-remove" title={busy === "cancel" ? BUSY_TEXT.cancel : "删除:从队列中移除"} aria-label={`删除第 ${n} 条排队消息`}
99
+ disabled={!!busy} aria-busy={busy === "cancel" || undefined} onClick={() => void run("cancel", onCancel)}>{busy === "cancel" ? <span className="spinner" /> : Icon.x()}</button>
100
+ </div>}
101
+ {actionError && <div className="cli-queue-error" role="alert">{actionError}</div>}
102
+ {open && <Popover className="composer-popover cli-queue-popover" ariaLabel="排队消息内容" placement="top" align="start" returnFocusRef={returnFocusRef} onClose={() => setOpen(false)}>
103
+ <div className="pop-sec">第 {n} 条排队消息</div>
104
+ <div className="cli-queue-detail" onClick={event => { if (event.defaultPrevented && event.target instanceof Element && event.target.closest("a")) setOpen(false); }}>{renderMessage ? renderMessage({ ...message, role: "user", content }) : <MessageView m={{ ...message, role: "user", content }} initial={false} hideWho handlers={{ attachmentUrl }} view={detailView} />}</div>
105
+ {message.note && <div className="cli-queue-note">{message.note}</div>}
106
+ <div className="cli-queue-actions"><button className="pop-item" disabled={copying} onClick={() => void copy()}>{Icon.copy()} {copying ? "复制中…" : "复制消息"}</button></div>
107
+ {feedback && <div className="cli-queue-feedback" role={feedback.error ? "alert" : "status"}>{feedback.text}</div>}
108
+ </Popover>}
109
+ </div>;
110
+ }
@@ -0,0 +1,28 @@
1
+ import { useId, useState, type ReactNode } from "react";
2
+ import { Icon } from "./icons";
3
+ import { Markdown } from "./Markdown";
4
+ import { referencePreview, referenceTitle } from "./reference";
5
+
6
+ /** A reference stays separate from editable text; expanding it never changes the payload. */
7
+ export function ReferenceCard({ title, text, onRemove, disabled, renderContent, className, removeLabel }: {
8
+ title: string; text: string; onRemove?: () => void; disabled?: boolean;
9
+ renderContent?: (text: string) => ReactNode; className?: string; removeLabel?: string;
10
+ }) {
11
+ const [open, setOpen] = useState(false);
12
+ const bodyId = useId();
13
+ const displayTitle = referenceTitle(title);
14
+ return <section className={`cli-reference${open ? " open" : ""}${className ? ` ${className}` : ""}`} aria-label={`引用 ${displayTitle}`}>
15
+ <div className="cli-reference-head">
16
+ <button type="button" className="cli-reference-toggle" aria-expanded={open} aria-controls={bodyId} aria-label={`${open ? "收起" : "展开"}引用 ${displayTitle}`} onClick={() => setOpen(value => !value)}>
17
+ <span className="cli-reference-icon" aria-hidden="true">{Icon.doc()}</span>
18
+ <span className="cli-reference-kind">引用</span>
19
+ <span className="cli-reference-title cli-ref-l" title={title}>{displayTitle}</span>
20
+ <span className="cli-reference-state">{open ? "收起" : "展开"}</span>
21
+ <span className="cli-reference-chevron" aria-hidden="true">{open ? Icon.chevUp() : Icon.chevDown()}</span>
22
+ </button>
23
+ {onRemove && <button type="button" className="cli-reference-remove" disabled={disabled} aria-label={removeLabel ?? `移除引用 ${title}`} title="移除引用" onClick={onRemove}>{Icon.x()}</button>}
24
+ </div>
25
+ {open ? <div className="cli-reference-body" id={bodyId}>{text.length > 50_000 ? <pre>{text}</pre> : renderContent ? renderContent(text) : <Markdown>{text}</Markdown>}</div>
26
+ : <div className="cli-reference-preview">{referencePreview(text) || "引用内容"}</div>}
27
+ </section>;
28
+ }
@@ -0,0 +1,233 @@
1
+ import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import type { Msg } from "../core/types";
3
+ import { childMessages, subagentFields, type LoadSubagent, type SubagentInfo, type SubagentTranscript } from "../core/subagents";
4
+ import { Icon } from "./icons";
5
+
6
+ const STATUS = { running: "运行中", completed: "已完成", failed: "失败", cancelled: "已取消", unknown: "状态未知" };
7
+ type Selection = { scope: string; generation: number; info: SubagentInfo };
8
+ const SubagentContext = createContext<((info: SubagentInfo, generation: number) => void) | null>(null);
9
+
10
+ export function SubagentHistoryBoundary({ children }: { children: ReactNode }) { return <SubagentContext.Provider value={null}>{children}</SubagentContext.Provider>; }
11
+
12
+ export function SubagentEntry({ info, generation }: { info: SubagentInfo; generation: number }) {
13
+ const open = useContext(SubagentContext);
14
+ return <div className="msg msg-tool">
15
+ <button type="button" className="subagent-entry" disabled={!open} onClick={() => open?.(info, generation)} aria-label={`查看子代理:${info.title}`}>
16
+ <span className="subagent-entry-icon">{Icon.spark()}</span>
17
+ <span className="subagent-entry-name">{info.title}</span>
18
+ <span className={`subagent-status ${info.status}`}>{STATUS[info.status] ?? STATUS.unknown}</span>
19
+ {Icon.chevRight()}
20
+ </button>
21
+ </div>;
22
+ }
23
+
24
+ /** One shared viewer for embedded Chat and the native Operator chat. It never controls execution. */
25
+ export function SubagentChatScope({ scopeKey, messages, load, renderMessage, children }: {
26
+ scopeKey: string; messages: Msg[]; load?: LoadSubagent;
27
+ renderMessage: (message: Msg, agentName: string) => ReactNode; children: ReactNode;
28
+ }) {
29
+ const [selection, setSelection] = useState<Selection | null>(null);
30
+ const selected = selection?.scope === scopeKey ? selection : null;
31
+ const open = useCallback((info: SubagentInfo, generation: number) => setSelection({ scope: scopeKey, generation, info }), [scopeKey]);
32
+ const close = useCallback(() => setSelection(null), []);
33
+ const root = useRef<HTMLDivElement>(null);
34
+ const [narrow, setNarrow] = useState(true);
35
+ useLayoutEffect(() => {
36
+ const element = root.current;
37
+ if (!element) return;
38
+ const measure = () => { const width = element.getBoundingClientRect().width; if (width > 0) setNarrow(width < 900); };
39
+ measure();
40
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
41
+ observer?.observe(element);
42
+ return () => observer?.disconnect();
43
+ }, []);
44
+ return <SubagentContext.Provider value={open}>
45
+ <div ref={root} className={`subagent-chat-scope${selected ? " has-subagent" : ""}${narrow ? " narrow" : ""}`}>
46
+ <div className="subagent-chat-main" inert={!!selected && narrow || undefined}>{children}</div>
47
+ {selected && <SubagentPanel key={`${scopeKey}:${selected.generation}:${selected.info.id}`} selection={selected} messages={messages} load={load} renderMessage={renderMessage} onClose={close} modal={narrow} />}
48
+ </div>
49
+ </SubagentContext.Provider>;
50
+ }
51
+
52
+ /** 子代理记录按动静补读时,相邻两次至少隔这么久。 */
53
+ const SUBAGENT_REFRESH_GAP_MS = 2_000;
54
+
55
+ function SubagentPanel({ selection, messages, load, renderMessage, onClose, modal }: {
56
+ selection: Selection; messages: Msg[]; load?: LoadSubagent;
57
+ renderMessage: (message: Msg, agentName: string) => ReactNode; onClose: () => void; modal: boolean;
58
+ }) {
59
+ const [snapshot, setSnapshot] = useState<(SubagentTranscript & { before?: string }) | null>(null);
60
+ const [loadingOlder, setLoadingOlder] = useState(false);
61
+ const [loading, setLoading] = useState(!!load);
62
+ const [error, setError] = useState<string | null>(null);
63
+ const [attempt, setAttempt] = useState(0);
64
+ const panel = useRef<HTMLElement>(null);
65
+ const closeButton = useRef<HTMLButtonElement>(null);
66
+ const scroll = useRef<HTMLDivElement>(null);
67
+ const follow = useRef(true);
68
+ const [following, setFollowing] = useState(true);
69
+ const [shownCount, setShownCount] = useState(120);
70
+ const { id } = selection.info;
71
+
72
+ // 不定时轮询:打开时读一次;之后父对话的实时流里这个子代理有了新动静(状态、更新时间、新的子消息),
73
+ // 或页面回到前台时再读——仅在服务端说记录还会变(refreshable)且子代理还在跑时;相邻两次至少隔 2 秒,挤在一起的合并。
74
+ const live = useRef(false);
75
+ const refreshSoon = useRef<(() => void) | null>(null);
76
+ useEffect(() => {
77
+ let disposed = false;
78
+ let timer: ReturnType<typeof setTimeout> | undefined;
79
+ let inflight = false, again = false, last = 0;
80
+ if (!load) { setLoading(false); return; }
81
+ setLoading(true); setError(null);
82
+ const refresh = async () => {
83
+ if (disposed) return;
84
+ if (inflight) { again = true; return; }
85
+ inflight = true; last = Date.now();
86
+ try {
87
+ const value = await load(id, selection.generation);
88
+ if (value.subagent?.id !== id || !Array.isArray(value.messages)) throw new Error("子代理记录与当前选择不匹配");
89
+ if (disposed) return;
90
+ setSnapshot(previous => {
91
+ const overlap = previous?.messages.some(old => value.messages.some(row => row.id === old.id));
92
+ const byId = new Map((previous?.messages ?? []).map(row => [row.id, row]));
93
+ for (const row of value.messages) byId.set(row.id, row);
94
+ return { ...value, messages: [...byId.values()],
95
+ older: overlap ? previous?.older ?? value.older : value.older,
96
+ before: overlap ? previous?.before : value.messages[0]?.id };
97
+ }); setError(null);
98
+ live.current = !!value.refreshable && ["running", "unknown"].includes(value.subagent.status);
99
+ } catch (cause) {
100
+ if (!disposed) setError(cause instanceof Error ? cause.message : String(cause));
101
+ } finally {
102
+ inflight = false;
103
+ if (!disposed) setLoading(false);
104
+ if (again && !disposed) { again = false; schedule(); }
105
+ }
106
+ };
107
+ const schedule = () => {
108
+ if (disposed || timer || !live.current) return;
109
+ timer = setTimeout(() => { timer = undefined; void refresh(); }, Math.max(0, SUBAGENT_REFRESH_GAP_MS - (Date.now() - last)));
110
+ };
111
+ refreshSoon.current = () => { if (document.visibilityState !== "hidden") schedule(); };
112
+ const onVisibility = () => { if (document.visibilityState === "visible") schedule(); };
113
+ document.addEventListener("visibilitychange", onVisibility);
114
+ void refresh();
115
+ return () => { disposed = true; if (timer) clearTimeout(timer); refreshSoon.current = null; document.removeEventListener("visibilitychange", onVisibility); };
116
+ }, [load, id, selection.generation, attempt]);
117
+ // 父对话里这个子代理的动静:只看实时流(不看自己读回来的快照,免得读一次又触发下一次)。
118
+ const parentActivity = useMemo(() => {
119
+ let mark = "";
120
+ for (const message of messages) {
121
+ if (message.generation !== selection.generation) continue;
122
+ const candidate = subagentFields(message).subagent;
123
+ if (candidate?.id === id) mark = `${candidate.status}:${candidate.updatedAt ?? 0}`;
124
+ }
125
+ return `${mark}|${childMessages(messages, id, selection.generation).length}`;
126
+ }, [messages, id, selection.generation]);
127
+ const seenActivity = useRef(parentActivity);
128
+ useEffect(() => {
129
+ if (seenActivity.current === parentActivity) return;
130
+ seenActivity.current = parentActivity;
131
+ refreshSoon.current?.();
132
+ }, [parentActivity]);
133
+
134
+ const info = useMemo(() => {
135
+ let latest = snapshot?.subagent ?? selection.info;
136
+ for (const message of messages) {
137
+ if (message.generation !== selection.generation) continue;
138
+ const candidate = subagentFields(message).subagent;
139
+ if (candidate?.id === id && (candidate.updatedAt ?? 0) >= (latest.updatedAt ?? 0)) latest = candidate;
140
+ }
141
+ return latest;
142
+ }, [messages, snapshot, selection, id]);
143
+ const rows = useMemo(() => {
144
+ const merged = new Map((snapshot?.messages ?? []).map(row => [row.id, row]));
145
+ for (const row of childMessages(messages, id, selection.generation)) {
146
+ const previous = merged.get(row.id);
147
+ const updated = (value: typeof row) => {
148
+ const fields = subagentFields(value);
149
+ return fields.subagentMessage?.updatedAt ?? fields.subagent?.updatedAt ?? 0;
150
+ };
151
+ if (!previous || updated(row) >= updated(previous)) merged.set(row.id, row);
152
+ }
153
+ return childMessages([...merged.values()], id, selection.generation).sort((a, b) => (a.ts ?? a.created_at ?? 0) - (b.ts ?? b.created_at ?? 0)).map(row => {
154
+ const child = subagentFields(row).subagentMessage;
155
+ return { id: row.id, generation: row.generation, ts: row.ts ?? row.created_at,
156
+ role: child?.role ?? "tool", content: child?.tool ? JSON.stringify(child.tool) : child?.content ?? row.content } as Msg;
157
+ });
158
+ }, [snapshot, messages, id, selection.generation]);
159
+ const shown = rows.slice(-shownCount);
160
+ const promptAlreadyShown = rows.some(row => row.role === "user" && row.content === info.task);
161
+ const loadOlder = async () => {
162
+ if (rows.length > shownCount) { setShownCount(value => value + 120); return; }
163
+ if (!load || !snapshot?.before || loadingOlder) return;
164
+ const before = snapshot.before;
165
+ setLoadingOlder(true); setError(null);
166
+ try {
167
+ const value = await load(id, selection.generation, before);
168
+ if (value.subagent?.id !== id || !Array.isArray(value.messages)) throw new Error("子代理记录与当前选择不匹配");
169
+ setSnapshot(previous => {
170
+ const byId = new Map(value.messages.map(row => [row.id, row]));
171
+ for (const row of previous?.messages ?? []) byId.set(row.id, row);
172
+ return { ...(previous ?? value), messages: [...byId.values()],
173
+ ...(previous?.before === before ? { older: value.older, before: value.messages[0]?.id ?? before } : {}) };
174
+ });
175
+ setShownCount(count => count + 200);
176
+ } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
177
+ finally { setLoadingOlder(false); }
178
+ };
179
+
180
+ useEffect(() => {
181
+ const previous = document.activeElement as HTMLElement | null;
182
+ closeButton.current?.focus({ preventScroll: true });
183
+ return () => {
184
+ if ((document.activeElement === document.body || panel.current?.contains(document.activeElement)) && previous?.isConnected && !previous.closest("[inert],[hidden]")) previous.focus({ preventScroll: true });
185
+ };
186
+ }, []);
187
+ useEffect(() => {
188
+ const onKey = (event: KeyboardEvent) => {
189
+ if (event.defaultPrevented) return;
190
+ const other = (document.activeElement as HTMLElement | null)?.closest('[role="dialog"]');
191
+ if (other && other !== panel.current) return;
192
+ if (event.key === "Escape") { event.preventDefault(); onClose(); return; }
193
+ if (!modal || event.key !== "Tab" || !panel.current) return;
194
+ const items = [...panel.current.querySelectorAll<HTMLElement>('button:not(:disabled),a[href],input:not(:disabled),textarea:not(:disabled),[tabindex="0"]')]
195
+ .filter(item => !item.closest("[hidden],[inert]") && getComputedStyle(item).display !== "none");
196
+ const first = items[0], last = items.at(-1);
197
+ if (!first || !last) return;
198
+ if (!panel.current.contains(document.activeElement) || (event.shiftKey && document.activeElement === first)) { event.preventDefault(); (event.shiftKey ? last : first).focus(); }
199
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
200
+ };
201
+ window.addEventListener("keydown", onKey);
202
+ return () => window.removeEventListener("keydown", onKey);
203
+ }, [modal, onClose]);
204
+ useLayoutEffect(() => { if (follow.current && scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; }, [rows, loading]);
205
+
206
+ return <>
207
+ {modal && <button className="subagent-chat-scrim" type="button" tabIndex={-1} aria-label="收起子代理详情" onClick={onClose} />}
208
+ <aside ref={panel} className="subagent-chat-panel" role="dialog" aria-modal={modal || undefined} aria-label={`子代理:${info.title}`}>
209
+ <div className="subagent-chat-header">
210
+ <div><span className="subagent-chat-caption">子代理</span><strong>{info.title}</strong></div>
211
+ <span className={`subagent-status ${info.status}`}>{STATUS[info.status] ?? STATUS.unknown}</span>
212
+ <button ref={closeButton} type="button" className="icon-btn" title="关闭子代理详情" aria-label="关闭子代理详情" onClick={onClose}>{Icon.x()}</button>
213
+ </div>
214
+ <div ref={scroll} className="subagent-chat-transcript transcript" onScroll={() => {
215
+ const element = scroll.current;
216
+ follow.current = !!element && element.scrollHeight - element.scrollTop - element.clientHeight < 60;
217
+ setFollowing(follow.current);
218
+ }}>
219
+ <div className="tw">
220
+ {loading && <div className="subagent-chat-note" role="status">正在读取子代理记录…</div>}
221
+ {error && <div className="subagent-chat-note error" role="alert">读取历史失败:{error} <button className="btn btn-ghost btn-sm" onClick={() => setAttempt(value => value + 1)}>重试</button></div>}
222
+ {!load && <div className="subagent-chat-note">当前接入未提供子代理历史接口,以下为已加载的记录。</div>}
223
+ {info.detailAvailability !== "full" && <div className="subagent-chat-note">{info.detailReason || (info.detailAvailability === "unavailable" ? "此智能体暂未提供子代理运行过程。" : "此智能体仅提供了部分运行记录。")}</div>}
224
+ {info.task && !promptAlreadyShown && renderMessage({ id: `prompt:${id}`, role: "user", content: info.task, generation: selection.generation }, info.title)}
225
+ {(rows.length > shownCount || (snapshot?.older ?? 0) > 0) && <div className="load-older"><button type="button" className="btn btn-ghost btn-sm" disabled={loadingOlder} onClick={() => { follow.current = false; setFollowing(false); void loadOlder(); }}>{loadingOlder ? "正在读取更早的记录…" : "加载更早的子代理记录"}</button></div>}
226
+ {shown.map(row => <div key={row.id}>{renderMessage(row, info.title)}</div>)}
227
+ {!loading && !error && !rows.length && <div className="subagent-chat-note">尚无可展示的运行记录。</div>}
228
+ </div>
229
+ </div>
230
+ {!following && <button type="button" className="btn btn-ghost btn-sm subagent-chat-latest" onClick={() => { follow.current = true; setFollowing(true); if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; }}>跳到最新</button>}
231
+ </aside>
232
+ </>;
233
+ }
@@ -0,0 +1,51 @@
1
+ import { memo, useState } from "react";
2
+ import { charCount, type TextBlock } from "../core/blocks";
3
+ import { fmtCount } from "./format";
4
+ import { Icon } from "./icons";
5
+
6
+ /** 展开后一次最多渲染这么多字;更长的再点「显示全部」,避免一次塞几十万字进 DOM。 */
7
+ const EXPANDED_STEP = 20_000;
8
+ const PREVIEW_LINES = 3;
9
+
10
+ /**
11
+ * 文本块卡片:输入框里待发送的文件 / 长粘贴,和转录里已发出的附件,用同一个样子。
12
+ * 默认只显示开头几行,点开才显示正文(分段渲染),长内容不会把页面卡住。
13
+ */
14
+ export const TextBlockCard = memo(function TextBlockCard({ block, onRemove }: { block: TextBlock; onRemove?: () => void }) {
15
+ const [open, setOpen] = useState(false);
16
+ const [limit, setLimit] = useState(EXPANDED_STEP);
17
+ const staged = /^\[文件原件:.*\]\(cli-upload:\/\//.test(block.text);
18
+ const original = staged || /^\[Attached (?:file|image): [^\r\n]+\]$/.test(block.text);
19
+ const chars = charCount(block.text);
20
+ const lines = block.text ? block.text.split("\n").length : 0;
21
+ const preview = block.text.split("\n", PREVIEW_LINES + 1).slice(0, PREVIEW_LINES).join("\n").slice(0, 400);
22
+ const shown = open ? block.text.slice(0, limit) : preview;
23
+ const rest = block.text.length - limit;
24
+ return (
25
+ <div className={`cli-block ${open ? "open" : ""}`}>
26
+ <div className="cli-block-h">
27
+ <span className="cli-block-ic">{block.kind === "file" ? Icon.doc() : block.kind === "context" || block.kind === "viewing" ? Icon.board() : block.kind === "background" ? Icon.flag() : Icon.clip()}</span>
28
+ <span className="cli-block-name" title={block.name}>{block.name}</span>
29
+ <span className="cli-block-meta">{original ? staged ? "原件已暂存" : "已保存到工作区" : `${fmtCount(chars)} 字 · ${fmtCount(lines)} 行`}</span>
30
+ <span className="cli-block-sp" />
31
+ {!original && <button type="button" className="cli-block-btn" onClick={() => { setOpen((o) => !o); setLimit(EXPANDED_STEP); }}>
32
+ {open ? "收起" : "展开"}
33
+ </button>}
34
+ {onRemove && <button type="button" className="cli-block-x" title="移除" aria-label={`移除 ${block.name}`} onClick={onRemove}>{Icon.x()}</button>}
35
+ </div>
36
+ {!original && <pre className="cli-block-body">{shown}{!open && block.text.length > preview.length ? "…" : ""}</pre>}
37
+ {open && rest > 0 && (
38
+ <button type="button" className="cli-block-more" onClick={() => setLimit((n) => n + EXPANDED_STEP * 5)}>
39
+ 还有 {fmtCount(rest)} 字,继续显示
40
+ </button>
41
+ )}
42
+ </div>
43
+ );
44
+ });
45
+
46
+ /** 很长的普通文字(没走附件格式、直接打进来的):折叠显示开头,点开看全部。 */
47
+ export const LONG_MESSAGE_CHARS = 3_000;
48
+ export const LONG_MESSAGE_LINES = 40;
49
+ export function isLongMessage(text: string) {
50
+ return text.length > LONG_MESSAGE_CHARS || text.split("\n", LONG_MESSAGE_LINES + 1).length > LONG_MESSAGE_LINES;
51
+ }