From 73a7175051563a0abf8bdee02c81356dc9c91c15 Mon Sep 17 00:00:00 2001
From: gongzhiyong
Date: Sun, 12 Apr 2026 15:44:58 +0800
Subject: [PATCH] feat: cancelling status, ThreadSidebar lastActive time,
MessageBubble smart summary
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 点5: cancelling 独立状态 UI,用轮询替换 300ms setTimeout,StreamStatusBar 加橙色视觉
- 点6: ThreadSidebar formatTime 支持毫秒时间戳,时间展示改用 lastActive
- 点7: MessageBubble 智能摘要(关键词优先/标题次优/跳开场白),折叠阈值提升至25行/800字,展开后可收起
Co-Authored-By: Claude Sonnet 4.6 (1M context)
---
langgraph/src/components/MessageBubble.tsx | 44 +++++++++++++++++++++-
langgraph/src/components/ThreadSidebar.tsx | 6 +--
langgraph/src/main.tsx | 44 +++++++++++++++-------
3 files changed, 75 insertions(+), 19 deletions(-)
diff --git a/langgraph/src/components/MessageBubble.tsx b/langgraph/src/components/MessageBubble.tsx
index 17b9e18..6d8fb65 100644
--- a/langgraph/src/components/MessageBubble.tsx
+++ b/langgraph/src/components/MessageBubble.tsx
@@ -148,15 +148,40 @@ function CodeBlock({
);
}
+const CONCLUSION_KEYWORDS = /总结|结论|建议|综上|总体|总的来说|核心|要点|小结/;
+const FILLER_PATTERN = /^(根据|针对|您好|你好|好的|感谢|当然|如您所述|以下是|下面是)/;
+
function extractSummary(text: string): string {
+ const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
+
+ // 1. 优先:含总结关键词的段落首句
+ for (const line of lines) {
+ if (CONCLUSION_KEYWORDS.test(line) && line.length > 8 && !line.startsWith("#")) {
+ return line.replace(/^[*_#>\s-]+/, "").slice(0, 120);
+ }
+ }
+
+ // 2. 次优先:Markdown 标题(## / ###)之后的第一个非空行
+ for (let i = 0; i < lines.length; i++) {
+ if (/^#{2,3}\s/.test(lines[i]) && lines[i + 1]) {
+ const next = lines[i + 1].replace(/^[*_>\s-]+/, "");
+ if (next.length > 8) return next.slice(0, 120);
+ }
+ }
+
+ // 3. Fallback:跳过开场白,取第一个有实质内容的句子
const sentences = text.split(/(?<=[.。!!??])\s+/);
+ const meaningful = sentences.find((s) => s.length > 10 && !FILLER_PATTERN.test(s.trim()));
+ if (meaningful) return meaningful.trim().slice(0, 120);
+
+ // 4. 最终 fallback
return sentences.slice(0, 2).join(" ").trim();
}
export default function MessageBubble({ content }: MessageBubbleProps) {
const [summaryExpanded, setSummaryExpanded] = useState(false);
const contentLines = content.split("\n").length;
- const isLong = contentLines > 20;
+ const isLong = contentLines > 25 || content.length > 800;
const summary = isLong ? extractSummary(content) : null;
return (
@@ -166,7 +191,7 @@ export default function MessageBubble({ content }: MessageBubbleProps) {
{summary}…
- {formatTime(t.created_at)}
+ {formatTime(getLastActive(t.thread_id, t.created_at))}
(null);
// ── Concurrent submit state ───────────────────────────────────────────────
- type ConcurrentStatus = "idle" | "generating" | "queued" | "stopping" | "auto-sending";
+ type ConcurrentStatus = "idle" | "generating" | "queued" | "stopping" | "cancelling" | "auto-sending";
// 完整 payload 快照类型
interface SubmitPayload {
@@ -147,6 +147,7 @@ function App() {
const [concurrentStatus, setConcurrentStatus] = useState("idle");
const pendingQueueRef = useRef void>>([]);
const isPollingRef = useRef(false);
+ const wasStoppedRef = useRef(false);
const [pendingQueueCount, setPendingQueueCount] = useState(0);
const thread = useStream<{ messages: Message[]; ui: UIMsgLocal[] }>({
@@ -175,8 +176,10 @@ function App() {
// Sync concurrent status with thread loading state
useEffect(() => {
- if (!thread.isLoading) setConcurrentStatus("idle");
- }, [thread.isLoading]);
+ if (!thread.isLoading && concurrentStatus !== "cancelling" && concurrentStatus !== "queued") {
+ setConcurrentStatus("idle");
+ }
+ }, [thread.isLoading, concurrentStatus]);
// Listen for retry-tool events from error-result cards
useEffect(() => {
@@ -431,17 +434,27 @@ function App() {
function handleConcurrentInterrupt() {
setShowConcurrentDialog(false);
setConcurrentStatus("stopping");
+ wasStoppedRef.current = true;
thread.stop();
setTimeout(() => {
- const next = pendingQueueRef.current.shift();
- if (next) {
- setPendingQueueCount(pendingQueueRef.current.length);
- setConcurrentStatus("generating");
- next();
- } else {
- setConcurrentStatus("idle");
- }
- }, 300);
+ setConcurrentStatus("cancelling");
+ const waitForStop = () => {
+ if (!thread.isLoading) {
+ wasStoppedRef.current = false;
+ const next = pendingQueueRef.current.shift();
+ if (next) {
+ setPendingQueueCount(pendingQueueRef.current.length);
+ setConcurrentStatus("generating");
+ next();
+ } else {
+ setConcurrentStatus("idle");
+ }
+ } else {
+ setTimeout(waitForStop, 200);
+ }
+ };
+ waitForStop();
+ }, 150);
}
function handleConcurrentQueue() {
@@ -1014,11 +1027,13 @@ function App() {
concurrentStatus === "generating" && "bg-blue-50/80 dark:bg-blue-950/30 text-blue-600 dark:text-blue-400",
concurrentStatus === "queued" && "bg-amber-50/80 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400",
concurrentStatus === "stopping" && "bg-red-50/80 dark:bg-red-950/30 text-red-600 dark:text-red-400",
+ concurrentStatus === "cancelling" && "bg-orange-50/80 dark:bg-orange-950/30 text-orange-600 dark:text-orange-400",
concurrentStatus === "auto-sending" && "bg-green-50/80 dark:bg-green-950/30 text-green-600 dark:text-green-400",
)}>
{(concurrentStatus === "generating" || concurrentStatus === "auto-sending") && }
- {concurrentStatus === "queued" && }
- {concurrentStatus === "stopping" && }
+ {concurrentStatus === "queued" && }
+ {concurrentStatus === "stopping" && }
+ {concurrentStatus === "cancelling" && }
{concurrentStatus === "queued" ? (
<>
@@ -1043,6 +1058,7 @@ function App() {
{concurrentStatus === "generating" && "正在生成回复..."}
{concurrentStatus === "stopping" && "正在停止上一轮..."}
+ {concurrentStatus === "cancelling" && "等待当前任务停止..."}
{concurrentStatus === "auto-sending" && "正在自动发送下一条..."}
)}