Skip to content

Commit 8a4a104

Browse files
committed
fix(ai): 审查即修 FE 批——采纳回写/dock 实时/竞态与设置治理收口(v0.19.4/5)
1 parent 653e8b3 commit 8a4a104

16 files changed

Lines changed: 285 additions & 171 deletions

‎app/src/components/AiConversationDock.tsx‎

Lines changed: 72 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,26 @@
22
* AiConversationDock — 全局 AI 对话面板(REQ-274 v0.19.4,丙案 v1)。
33
*
44
* @ai-context: 应用级右侧停靠「AI 任务对话」跟随工作区——按需唤起 + 内容保活
5-
* (常驻挂载,open 仅切 display——选中态/已加载列表不销毁,关闭后
6-
* 任务在后台照跑,完成经 App 既有 ai:task-update toast 通知)。
7-
* @ai-context: 同源承诺:本面板不建第二套会话——数据全部来自既有命令
8-
* (chat_list_sessions/chat_list_messages/ai_task_history/
9-
* ai_task_conversation),线程内容在「💬 AI 对话」页天然可见可查;
10-
* 会话视图 v1 只读 + 「在对话页继续」跳转(规避双实例流控冲突),
11-
* 任务视图复用 TaskConversationView(含采纳/跳转/工作台深链)。
5+
* (常驻挂载,open 仅切 display——选中态/列表不销毁,后台照跑),
6+
* 完成经 App 既有 ai:task-update toast 通知。
7+
* @ai-context: 同源承诺:本面板不建第二套会话——数据来自既有命令(chat_list_*
8+
* /ai_task_*),线程内容在「💬 AI 对话」页天然可见可查;会话视图
9+
* v1 只读 + 「在对话页继续」跳转(规避双实例流控冲突),任务视图
10+
* 复用 TaskConversationView(含采纳/跳转/工作台深链)。
1211
* @ai-context: 唤起:导航栏 🤖 按钮 / Ctrl+Shift+A(本地 window 快捷键);
1312
* 段切换:💬 对话会话 / 🤖 AI 任务(与 ChatSidebar 口径一致)。
13+
* @ai-context: 0.19.4/5 审查即修:F1 采纳回写详情(onTaskChanged 双刷);F2
14+
* open 期间监听 ai:task-update 实时刷新(关闭零订阅);F9 懒加载
15+
* (挂载不拉,首次 open 才装载)+静态数据 10s 时效。
1416
*/
1517
import { useCallback, useEffect, useRef, useState } from "react";
1618
import { invoke } from "@tauri-apps/api/core";
17-
import type { AiTaskRecord, AiTurn, ChatMessage, ChatSession } from "../types";
19+
import { listen } from "@tauri-apps/api/event";
20+
import type { AiTaskRecord, AiTaskState, AiTurn, ChatMessage, ChatSession } from "../types";
1821
import TaskConversationView from "./TaskConversationView";
1922
import { refLabel } from "../utils/entityLabel";
2023

2124
interface SessionRow { id: number; title: string }
22-
2325
interface Props {
2426
open: boolean;
2527
onClose: () => void;
@@ -31,17 +33,26 @@ interface Props {
3133
onOpenNote: (noteId: number) => void;
3234
onOpenRefineWorkbench: (sessionId: number, taskId: number) => void;
3335
}
34-
3536
const PANEL_W = 560;
37+
/** F9 静态数据时效(ms):open 翻转时距上次拉取 <10s 则跳过——列表不抖动 */
38+
const STATIC_TTL_MS = 10_000;
3639

3740
const itemBase: React.CSSProperties = {
3841
display: "flex", alignItems: "center", gap: 8, width: "100%", boxSizing: "border-box",
3942
padding: "7px 10px", cursor: "pointer", fontSize: 12.5, textAlign: "left",
4043
background: "none", border: "none", borderRadius: 8, color: "#374151",
4144
};
4245

43-
const OP_LABEL: Record<string, string> = { refine: "✨ 精修", enrich: "📚 补充" };
46+
/** 顶部分段胶囊(active 高亮)——复用一处免重复内联块 */
47+
const pill = (active: boolean): React.CSSProperties => ({
48+
fontSize: 12, cursor: "pointer", padding: "3px 10px", borderRadius: 999,
49+
border: active ? "1px solid #4f46e5" : "1px solid #d1d5db",
50+
background: active ? "#eef2ff" : "#fff", color: active ? "#3730a3" : "#374151",
51+
});
4452

53+
/** 详情头部返回/跳转文字钮基样式 */
54+
const textBtn: React.CSSProperties = { border: "none", background: "none", cursor: "pointer", padding: 0 };
55+
const OP_LABEL: Record<string, string> = { refine: "✨ 精修", enrich: "📚 补充" };
4556
export default function AiConversationDock({
4657
open, onClose, onOpenChat, onOpenTaskInChat, onOpenSessions, onOpenNote, onOpenRefineWorkbench,
4758
}: Props) {
@@ -55,16 +66,23 @@ export default function AiConversationDock({
5566
const [activeTaskId, setActiveTaskId] = useState<number | null>(null);
5667
const [taskDetail, setTaskDetail] = useState<{ task: AiTaskRecord; turns: AiTurn[] } | null>(null);
5768
const [notice, setNotice] = useState("");
69+
// F9:是否已首次 open 装载(此前挂载即拉——审查改懒加载)
5870
const loadedOnce = useRef(false);
71+
const staticAtRef = useRef(0);
72+
// F2:activeTaskId 经 ref 镜像——事件监听只注册一次,永远读最新选中任务
73+
const activeTaskIdRef = useRef<number | null>(null);
74+
activeTaskIdRef.current = activeTaskId;
5975

6076
const reloadTasks = useCallback(async () => {
6177
const refine = await invoke<AiTaskRecord[]>("ai_task_history", { opType: "refine", limit: 30 }).catch(() => [] as AiTaskRecord[]);
6278
const enrich = await invoke<AiTaskRecord[]>("ai_task_history", { opType: "enrich", limit: 30 }).catch(() => [] as AiTaskRecord[]);
63-
setTasks([...refine, ...enrich].sort((a, b) => b.createdAt - a.createdAt).slice(0, 40));
79+
// F4(审查对齐):展示条数与 ChatPage 一致(40→60——同源同口径)
80+
setTasks([...refine, ...enrich].sort((a, b) => b.createdAt - a.createdAt).slice(0, 60));
6481
}, []);
6582

66-
/** 会话/标题静态数据(与 ChatPage 同源同口径——打开面板时拉一次) */
83+
/** 会话/标题静态数据(与 ChatPage 同源同口径——按 F9 时效拉取) */
6784
const loadStatic = useCallback(async () => {
85+
staticAtRef.current = Date.now();
6886
const [sess, rows, notes] = await Promise.all([
6987
invoke<ChatSession[]>("chat_list_sessions").catch(() => [] as ChatSession[]),
7088
invoke<SessionRow[]>("list_sessions", { limit: 500 }).catch(() => [] as SessionRow[]),
@@ -75,21 +93,6 @@ export default function AiConversationDock({
7593
setNoteTitles(new Map(notes.map((n) => [n.id, n.title])));
7694
}, []);
7795

78-
useEffect(() => {
79-
if (loadedOnce.current) return;
80-
loadedOnce.current = true;
81-
void loadStatic();
82-
void reloadTasks();
83-
}, [loadStatic, reloadTasks]);
84-
85-
// open 变 true:刷新静态/任务(外部变化及时可见——防抖由调用频率天然承担)
86-
useEffect(() => {
87-
if (open) {
88-
void loadStatic();
89-
void reloadTasks();
90-
}
91-
}, [open, loadStatic, reloadTasks]);
92-
9396
const selectTask = useCallback(async (taskId: number) => {
9497
setActiveTaskId(taskId);
9598
setActiveChatId(null);
@@ -117,6 +120,37 @@ export default function AiConversationDock({
117120
: refLabel("note", noteTitles.get(t.refId)),
118121
[sessionTitles, noteTitles]);
119122

123+
// F9:懒加载 + open 翻转刷新——首次 open 装载;之后静态数据 10s 时效跳过,
124+
// 任务列表每次 open 仍刷(状态多变不设时效)
125+
useEffect(() => {
126+
if (!open) return;
127+
if (!loadedOnce.current) {
128+
loadedOnce.current = true;
129+
void loadStatic();
130+
} else if (Date.now() - staticAtRef.current >= STATIC_TTL_MS) {
131+
void loadStatic();
132+
}
133+
void reloadTasks();
134+
}, [open, loadStatic, reloadTasks]);
135+
136+
// F2:dock 打开期间订阅任务更新事件——列表/当前详情即时跟进(关面板即
137+
// 解绑零订阅;disposed 处理 listen 异步注册竞态,同 App.tsx 全局监听模式;
138+
// activeTaskId 经 ref 镜像读取——选中切换不触发重订阅)
139+
useEffect(() => {
140+
if (!open) return;
141+
let disposed = false;
142+
let unlisten: (() => void) | undefined;
143+
void listen<[number, AiTaskState]>("ai:task-update", () => {
144+
if (disposed) return;
145+
void reloadTasks();
146+
const tid = activeTaskIdRef.current;
147+
if (tid != null) void selectTask(tid);
148+
}).then((u) => {
149+
if (disposed) u(); else unlisten = u;
150+
});
151+
return () => { disposed = true; unlisten?.(); };
152+
}, [open, reloadTasks, selectTask]);
153+
120154
/** 列表段(每行数据复用 ChatSidebar 的形态——标题/模型/状态/时间) */
121155
const renderList = () => {
122156
if (tab === "task") {
@@ -194,30 +228,10 @@ export default function AiConversationDock({
194228
borderBottom: "1px solid #e5e7eb", background: "#fff", flexShrink: 0,
195229
}}>
196230
<span style={{ fontWeight: 700, fontSize: 13.5 }}>🤖 AI 任务对话</span>
197-
<button
198-
data-testid="dock-tab-task"
199-
onClick={() => setTab("task")}
200-
style={{
201-
marginLeft: 8, fontSize: 12, cursor: "pointer", padding: "3px 10px", borderRadius: 999,
202-
border: tab === "task" ? "1px solid #4f46e5" : "1px solid #d1d5db",
203-
background: tab === "task" ? "#eef2ff" : "#fff", color: tab === "task" ? "#3730a3" : "#374151",
204-
}}
205-
>🤖 AI 任务</button>
206-
<button
207-
data-testid="dock-tab-chat"
208-
onClick={() => setTab("chat")}
209-
style={{
210-
fontSize: 12, cursor: "pointer", padding: "3px 10px", borderRadius: 999,
211-
border: tab === "chat" ? "1px solid #4f46e5" : "1px solid #d1d5db",
212-
background: tab === "chat" ? "#eef2ff" : "#fff", color: tab === "chat" ? "#3730a3" : "#374151",
213-
}}
214-
>💬 对话</button>
231+
<button data-testid="dock-tab-task" onClick={() => setTab("task")} style={{ ...pill(tab === "task"), marginLeft: 8 }}>🤖 AI 任务</button>
232+
<button data-testid="dock-tab-chat" onClick={() => setTab("chat")} style={pill(tab === "chat")}>💬 对话</button>
215233
<span style={{ flex: 1 }} />
216-
<button
217-
data-testid="dock-close"
218-
onClick={onClose}
219-
style={{ border: "none", background: "transparent", cursor: "pointer", fontSize: 14, color: "#6b7280" }}
220-
>✕</button>
234+
<button data-testid="dock-close" onClick={onClose} style={{ ...textBtn, fontSize: 14, color: "#6b7280" }}>✕</button>
221235
</div>
222236
);
223237

@@ -243,13 +257,13 @@ export default function AiConversationDock({
243257
<div style={{ flexShrink: 0, display: "flex", gap: 6, padding: "4px 10px", borderBottom: "1px solid #f3f4f6", fontSize: 11 }}>
244258
<button
245259
onClick={() => { setActiveTaskId(null); setTaskDetail(null); }}
246-
style={{ border: "none", background: "none", cursor: "pointer", color: "#0f766e", padding: 0 }}
260+
style={{ ...textBtn, color: "#0f766e" }}
247261
>← 任务列表</button>
248262
<span style={{ flex: 1 }} />
249263
<button
250264
title="在 AI 对话页打开同源任务(可查轨迹/追问)"
251265
onClick={() => onOpenTaskInChat(taskDetail.task.taskId)}
252-
style={{ border: "none", background: "none", cursor: "pointer", color: "#0d9488", padding: 0 }}
266+
style={{ ...textBtn, color: "#0d9488" }}
253267
>💬 在对话页打开 →</button>
254268
</div>
255269
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
@@ -266,7 +280,9 @@ export default function AiConversationDock({
266280
busy={false}
267281
// 笔记级任务 → 查看笔记;会话级 → 工作台深链(同 ChatPage 口径)
268282
onOpenWorkbench={(t) => (t.targetKind === "note" ? onOpenNote(t.refId) : onOpenRefineWorkbench(t.refId, t.taskId))}
269-
onTaskChanged={() => void reloadTasks()}
283+
// F1:采纳成功 → 列表 + 当前详情双刷新(详情 adopted=true 才
284+
// 让「✅ 采纳到笔记」消失/「已采纳」徽标出现)
285+
onTaskChanged={() => { void reloadTasks(); if (activeTaskId != null) void selectTask(activeTaskId); }}
270286
/>
271287
</div>
272288
</>
@@ -276,13 +292,13 @@ export default function AiConversationDock({
276292
<div style={{ flexShrink: 0, display: "flex", gap: 6, padding: "4px 10px", borderBottom: "1px solid #f3f4f6", fontSize: 11 }}>
277293
<button
278294
onClick={() => { setActiveChatId(null); setMessages([]); }}
279-
style={{ border: "none", background: "none", cursor: "pointer", color: "#0f766e", padding: 0 }}
295+
style={{ ...textBtn, color: "#0f766e" }}
280296
>← 会话列表</button>
281297
<span style={{ flex: 1 }} />
282298
<button
283299
title="到 AI 对话页继续对话(本面板会话视图 v1 只读)"
284300
onClick={() => onOpenChat(activeChatId)}
285-
style={{ border: "none", background: "none", cursor: "pointer", color: "#0d9488", padding: 0 }}
301+
style={{ ...textBtn, color: "#0d9488" }}
286302
>💬 在对话页继续 →</button>
287303
</div>
288304
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 12 }}>{renderChatDetail()}</div>

‎app/src/components/AiTaskPanel.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import { useCallback, useEffect, useState } from "react";
1414
import { invoke } from "@tauri-apps/api/core";
1515
import type { AiEnrichResult, AiRefineResult, AiTaskRecord } from "../types";
16+
// 低5(审查):类别词经 entityLabel.kindWord 单一来源(不带 id 的语义词)
17+
import { kindWord } from "../utils/entityLabel";
1618

1719
const btn: React.CSSProperties = { padding: "3px 8px", fontSize: 11, borderRadius: 5, border: "1px solid #d1d5db", background: "#fff", cursor: "pointer" };
1820

@@ -149,7 +151,7 @@ export default function AiTaskPanel() {
149151
{badge.text}
150152
</span>
151153
<span style={{ color: "#374151", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
152-
{t.opType === "refine" ? "会话" : "笔记"}
154+
{kindWord(t.opType === "refine" ? "session" : "note")}
153155
{t.model && <span style={{ color: "#9ca3af" }}> · {t.model}</span>}
154156
</span>
155157
<span style={{ color: "#9ca3af", width: 52, textAlign: "right" }}>{fmtMs(t.elapsedMs)}</span>

‎app/src/components/ChatSaveNoteDialog.test.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ describe("ChatSaveNoteDialog", () => {
4545
new: { title: "梯度下降", content, source: "manual" },
4646
});
4747
});
48-
expect(screen.getByTestId("chat-note-saved").textContent).toContain("#99");
48+
// 信息9(审查):成功态去裸 `#id`——改标题语义回显(REQ-277 全站口径)
49+
expect(screen.getByTestId("chat-note-saved").textContent).toContain("已保存为笔记《梯度下降》");
50+
expect(screen.getByTestId("chat-note-saved").textContent).not.toContain("#");
4951
});
5052

5153
it("选目标组 → group_id 直入 NewNote", async () => {

‎app/src/components/ChatSaveNoteDialog.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ export default function ChatSaveNoteDialog({ initialTitle, content, groups, onOp
8585
{savedId != null ? (
8686
<>
8787
<div data-testid="chat-note-saved" style={{ color: "#047857", marginBottom: 10 }}>
88-
✓ 已保存为笔记 #{savedId}({title})
88+
{/* 信息9(审查裸号):去 `#id`——成功态以标题语义回显(REQ-277);
89+
title 恒非空(保存前置校验 + 默认值)——空时仅兜底不带书名号 */}
90+
✓ 已保存为笔记{title.trim() ? `《${title.trim()}》` : ""}
8991
</div>
9092
<div style={{ display: "flex", gap: 8 }}>
9193
<button

0 commit comments

Comments
 (0)