diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e4ec260..3c50f32f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -74,15 +74,26 @@ export default function App() {
+ {/* 「在看哪个」属于 URL,不属于 store。 + 每页写成两条显式路由(带 id / 不带 id),而不是可选参数 `:id?`: + 「没带 id」是真要单独处理的一种情况(落到上次那个、或列表第一个), + 藏在一个问号里下次就没人记得它存在了。 + 导航高亮不用动——NavLink 默认前缀匹配,/chat/abc 照样点亮「问数据」。*/} {/* 默认落到对话页:多数人要的是答案,画布留给要自己编排的人 */} } /> } /> + } /> } /> + } /> } /> + } /> } /> + } /> } /> + } /> } /> + } />
diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index afac11fc..844b0c83 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -1,4 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' +import { useLocation, useNavigate, useParams } from 'react-router-dom' import type { ReactNode } from 'react' import { AlertCircle, CheckCircle2, Info, Loader2, X } from 'lucide-react' import clsx from 'clsx' @@ -101,6 +102,34 @@ export function Empty({ icon, title, hint, action }: { ) } +/** + * 把 Tabs 的选中项接到 URL 的最后一段上:/settings/datasources。 + * + * 和 Tabs 放在一起而不是单开一个 hooks 文件——tab 和它的地址是同一件事, + * 拆到两处迟早各改各的。三个 tab 页(知识 / 工具 / 设置)各接一行。 + * + * 用 replace 而不是 push:切 tab 不该在后退历史里堆一串,按后退是想离开 + * 这一页,不是想把四个标签倒着走一遍。 + * + * 认不出的 tab 名(链接过期、手输错了)回落到 fallback,并把地址一起纠正过来, + * 免得地址栏和眼前这一屏说的不是同一件事。 + */ +export function useTabRoute( + valid: readonly string[], fallback: string, +): [string, (key: string) => void] { + const { tab } = useParams() + const navigate = useNavigate() + const { pathname } = useLocation() + const base = '/' + pathname.split('/').filter(Boolean)[0] + const active = tab && valid.includes(tab) ? tab : fallback + + useEffect(() => { + if (tab !== active) navigate(`${base}/${active}`, { replace: true }) + }, [tab, active, base, navigate]) + + return [active, (key: string) => navigate(`${base}/${key}`, { replace: true })] +} + export function Tabs({ tabs, active, onChange }: { tabs: { key: string; label: string; badge?: number }[] active: string diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index 0c5a8fa9..168b0820 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' import { Database, ListTree, MessageSquare, Play, Send, Square } from 'lucide-react' import { api } from '../api/client' import { ApprovalCard } from '../run/RunPanel' @@ -6,7 +7,7 @@ import { AssistantStream, StreamEmpty, type StreamTurn } from '../run/AssistantS import { decodeCopilot, decodeRun } from '../run/decode' import { useCatalog } from '../store/catalog' import { useChat, type ChatTurn } from '../store/chat' -import { useConversations } from '../store/conversations' +import { lastVisited, useConversations } from '../store/conversations' import { useStudio } from '../store/studio' import { ConversationList } from './ConversationList' import { useToast } from '../components/ui' @@ -26,14 +27,48 @@ import { useToast } from '../components/ui' */ export function ChatPage() { const { busy, ask, stop, load, loadSteps, runNow, turnsOf } = useChat() + const { conversationId } = useParams() + const navigate = useNavigate() const currentId = useConversations((s) => s.currentId) const create = useConversations((s) => s.create) + const select = useConversations((s) => s.select) + const list = useConversations((s) => s.list) + const listLoading = useConversations((s) => s.loading) const turns = useChat((s) => (currentId ? s.byConversation[currentId] : undefined)) const [draft, setDraft] = useState('') const sources = useDataSources() const setGraph = useStudio((s) => s.setGraph) const toast = useToast() + // URL → store。这是 currentId 唯一的写入口,别处一律靠导航 + useEffect(() => { select(conversationId ?? null) }, [conversationId, select]) + + // /chat 不带 id:落到上次那个,没有就列表第一条。用 replace 而不是 push, + // 否则按后退会回到这个空壳地址、又被弹回来,人就卡在这儿出不去了 + useEffect(() => { + if (conversationId || listLoading || !list.length) return + const last = lastVisited() + const target = list.find((c) => c.id === last)?.id ?? list[0].id + navigate(`/chat/${target}`, { replace: true }) + }, [conversationId, listLoading, list, navigate]) + + // 地址指着一个不存在的对话(被删了、链接过期了)。不能白屏,也不能一声不响 + // 地跳走——点开一个链接却到了别的地方,得让人知道为什么。 + // + // 必须等列表加载完再判:列表是异步取的,在那之前"找不到"是假的, + // 照着它跳会让每次深链接进入都先闪一下错误提示 + const reported = useRef(null) + useEffect(() => { + if (!conversationId || listLoading || !list.length) return + if (list.some((c) => c.id === conversationId)) return + // 同一个坏地址只说一次。StrictMode 会把 effect 跑两遍,依赖里的 list + // 变一次又会再跑一遍——不记着的话同一句提示会连弹好几条 + if (reported.current === conversationId) return + reported.current = conversationId + toast('那个对话不在了,已经带你回到最近一个', 'info') + navigate(`/chat/${list[0].id}`, { replace: true }) + }, [conversationId, listLoading, list, navigate, toast]) + // 切到哪个会话就把哪个会话的内容取回来。内存里已经有的不会回源—— // 那一份带着这次跑出来的完整步骤,回源拿到的只有问题和答案 useEffect(() => { if (currentId) void load(currentId) }, [currentId, load]) @@ -43,6 +78,8 @@ export function ChatPage() { if (!q || busy) return // 没有会话就先开一个。第一次进来的人不该先被要求"新建对话" const id = currentId ?? (await create()) + // 地址要跟上,否则内容已经在新对话里、地址栏还停在 /chat + if (id !== currentId) navigate(`/chat/${id}`, { replace: true }) ask(id, q) setDraft('') } @@ -81,8 +118,11 @@ export function ChatPage() { /> )} onOpenGraph={(graph) => { + // 以前只把图放过去、让用户自己去点导航。有了路由就直接送过去—— + // "已放到画布,切到「编排」继续改"是在让人替系统完成一次跳转 setGraph(graph) - toast('已放到画布,切到「编排」继续改', 'ok') + navigate('/studio') + toast('已放到画布', 'ok') }} footer={
diff --git a/frontend/src/pages/ConversationList.tsx b/frontend/src/pages/ConversationList.tsx index 4b1fd102..d959ccea 100644 --- a/frontend/src/pages/ConversationList.tsx +++ b/frontend/src/pages/ConversationList.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' import { Check, MessageSquarePlus, PanelLeftClose, PanelLeftOpen, Pencil, Trash2, X } from 'lucide-react' import clsx from 'clsx' import { useChat } from '../store/chat' @@ -14,9 +15,26 @@ import { useConversations } from '../store/conversations' * "改图指令",和这里的"问题"不是一回事,混进同一个列表只会让两种都更难找。 */ export function ConversationList() { - const { list, currentId, loading, load, create, select, rename, remove } = useConversations() + const { list, currentId, loading, load, create, rename, remove } = useConversations() const forget = useChat((s) => s.forget) const busy = useChat((s) => s.busy) + const navigate = useNavigate() + + // 切会话 = 换地址,不是改 store。store 那个 currentId 是 URL 的派生缓存, + // 在这里直接 set 的话,地址栏不动、刷新就跳回旧的那个,前进后退也全失效 + const open = (id: string) => navigate(`/chat/${id}`) + + const startNew = async () => { + const id = await create() + navigate(`/chat/${id}`) + } + + const drop = async (id: string) => { + forget(id) + const next = await remove(id) + // 删的是当前这个才需要换地方;remove 会把该去哪告诉我们 + if (next !== currentId) navigate(next ? `/chat/${next}` : '/chat', { replace: true }) + } const [collapsed, setCollapsed] = useState(false) const [editing, setEditing] = useState(null) @@ -34,7 +52,7 @@ export function ConversationList() { onClick={() => setCollapsed(false)}> -
@@ -45,7 +63,7 @@ export function ConversationList() {