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 */
1517import { useCallback , useEffect , useRef , useState } from "react" ;
1618import { 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" ;
1821import TaskConversationView from "./TaskConversationView" ;
1922import { refLabel } from "../utils/entityLabel" ;
2023
2124interface SessionRow { id : number ; title : string }
22-
2325interface 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-
3536const PANEL_W = 560 ;
37+ /** F9 静态数据时效(ms):open 翻转时距上次拉取 <10s 则跳过——列表不抖动 */
38+ const STATIC_TTL_MS = 10_000 ;
3639
3740const 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 : "📚 补充" } ;
4556export 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 >
0 commit comments