From d11cf07ba489fbb11c766b84df90dbab39f2cdb5 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Fri, 4 Sep 2026 00:43:41 +0800 Subject: [PATCH 1/2] feat(thread-chat): cache and refresh project list --- .../orchestration/navigation/tree-list.tsx | 51 ++++++++++++---- app/thread-chat/styles/tree-list.css | 20 +++++++ app/thread-chat/thread-chat-demo.tsx | 60 +++++++++++++++---- 3 files changed, 107 insertions(+), 24 deletions(-) diff --git a/app/thread-chat/orchestration/navigation/tree-list.tsx b/app/thread-chat/orchestration/navigation/tree-list.tsx index 661a96a1..e9f53aac 100644 --- a/app/thread-chat/orchestration/navigation/tree-list.tsx +++ b/app/thread-chat/orchestration/navigation/tree-list.tsx @@ -3,7 +3,7 @@ * orchestration/tree-list —— 会话列表弹层(⌘⇧K / 顶栏「对话列表」按钮)。 * * 视觉沿用 ⌘K 切换器的 swx 弹层语言(tlx-* 类在 CSS 里复用同一套 token); - * 数据每次打开现拉(design D3:无缓存/无轮询,壳层以重挂方式打开保证归零)。 + * 数据由页面壳层预取并缓存;每次打开先展示缓存,再后台刷新一次。 * · 条目 = 展示标题(coalesce 双轨,服务端已做)+ 相对更新时间 + 分支数徽标; * · 当前树高亮置顶——尚未入库(空树未保存)时以本地信息合成「未保存」条目; * · 内联重命名(悬停铅笔 → 输入框,Enter 提交 / Esc 取消 / 失焦放弃): @@ -45,7 +45,10 @@ export interface TreeListProps { /** 当前树的本地合成信息:未入库时用它拼「未保存」条目 */ currentTitle: string currentThreadCount: number - loadItems(): Promise + /** 页面打开后预取到的内存缓存;null 表示预取尚未成功。 */ + cachedItems: TreeListItem[] | null + /** 获取最新列表;页面预取尚未结束时会复用同一个请求。 */ + refreshItems(): Promise renameItem(projectId: string, title: string): Promise deleteItem(projectId: string): Promise /** 点击非当前树条目:壳层负责跳转(组件已先自关) */ @@ -67,7 +70,8 @@ export function TreeList({ currentTreeId, currentTitle, currentThreadCount, - loadItems, + cachedItems, + refreshItems, renameItem, deleteItem, onSwitch, @@ -78,8 +82,9 @@ export function TreeList({ closing = false, container, }: TreeListProps) { - /** null = 拉取中 */ - const [items, setItems] = useState(null) + const [items, setItems] = useState(cachedItems) + const [refreshing, setRefreshing] = useState(true) + const [loadFailed, setLoadFailed] = useState(false) /** 内联重命名中的树 id + 草稿 */ const [editingId, setEditingId] = useState(null) const [draft, setDraft] = useState("") @@ -88,16 +93,26 @@ export function TreeList({ /** 删除请求进行中的树 id(防连点) */ const [deletingId, setDeletingId] = useState(null) - // 打开现拉(组件每次打开重挂,天然只拉一次) + // 缓存已在首帧展示;组件每次打开重挂,并在后台刷新一次。 useEffect(() => { let cancelled = false - void loadItems().then((trees) => { - if (!cancelled) setItems(trees) - }) + void refreshItems().then( + (trees) => { + if (cancelled) return + setItems(trees) + setLoadFailed(false) + setRefreshing(false) + }, + () => { + if (cancelled) return + setLoadFailed(true) + setRefreshing(false) + } + ) return () => { cancelled = true } - }, [loadItems]) + }, [refreshItems]) // Esc:编辑态 / 确认态在捕获期先于壳层关闭链被消费 useEffect(() => { @@ -218,9 +233,23 @@ export function TreeList({ {...THREAD_CHAT_SHORTCUTS.openTreeList} className="ml-auto shrink-0" /> + {refreshing && ( +
+ +
+ )}
- {items === null &&
加载中…
} + {items === null && !loadFailed && ( +
加载中…
+ )} + {items === null && loadFailed && ( +
加载失败,请稍后重新打开
+ )} {items !== null && rows.map(({ item, isCurrent, unsaved }) => { const editing = editingId === item.id diff --git a/app/thread-chat/styles/tree-list.css b/app/thread-chat/styles/tree-list.css index c61e6fa2..1bfe0782 100644 --- a/app/thread-chat/styles/tree-list.css +++ b/app/thread-chat/styles/tree-list.css @@ -4,6 +4,26 @@ .tc .swx.tlx { width: min(460px, 94vw); } +.tc .tlx > .swx-title { + position: relative; +} +.tc .tlx-refresh { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 2px; + overflow: hidden; + pointer-events: none; +} +.tc .tlx-refresh span { + display: block; + width: 38%; + height: 100%; + background: var(--tc-content-muted); + opacity: 0.45; + animation: tc-progress-slide 1.4s ease-in-out infinite; +} .tc .tlx-row { gap: 8px; } diff --git a/app/thread-chat/thread-chat-demo.tsx b/app/thread-chat/thread-chat-demo.tsx index 2d2a24ff..7b4d1601 100644 --- a/app/thread-chat/thread-chat-demo.tsx +++ b/app/thread-chat/thread-chat-demo.tsx @@ -2,7 +2,7 @@ import dynamic from "next/dynamic" import { useRouter } from "next/navigation" -import React, { useCallback, useEffect, useMemo, useState } from "react" +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { DEFAULT_THREAD_CHAT_MODEL_ID } from "@/constants/model" import type { @@ -181,6 +181,10 @@ function NormalizedThreadChat({ const [draftModelId, setDraftModelId] = useState( DEFAULT_THREAD_CHAT_MODEL_ID ) + const [treeItemsCache, setTreeItemsCache] = useState( + null + ) + const treeItemsRequestRef = useRef | null>(null) const { toast, showToast, dismissToast } = useWorkspaceToast() const setThreadModel = useCallback( (threadId: string, modelId: string) => { @@ -519,23 +523,53 @@ function NormalizedThreadChat({ return { id: project.id, title: - project.customTitle ?? project.autoTitle ?? deriveProjectTitle(bootstrap), + project.customTitle ?? + project.autoTitle ?? + deriveProjectTitle(bootstrap), updatedAt: project.updatedAt, threadCount: bootstrap.threads.length, } }) ) }, [runtime.client]) + const refreshTreeItems = useCallback((): Promise => { + const pending = treeItemsRequestRef.current + if (pending) return pending + + const request = loadTreeItems() + .then((items) => { + setTreeItemsCache(items) + return items + }) + .finally(() => { + if (treeItemsRequestRef.current === request) + treeItemsRequestRef.current = null + }) + treeItemsRequestRef.current = request + return request + }, [loadTreeItems]) + + // 当前 Project 启动完成、工作台首屏渲染后预取一次;弹窗刷新会复用进行中的请求。 + useEffect(() => { + void refreshTreeItems().catch(() => undefined) + }, [refreshTreeItems]) + const renameTreeItem = useCallback( async (projectId: string, title: string) => { if (projectId === state.project?.id) { await runtime.commands.renameProject(projectId, title) - return + } else { + await runtime.client.renameProject(projectId, { + commandId: crypto.randomUUID(), + customTitle: title, + }) } - await runtime.client.renameProject(projectId, { - commandId: crypto.randomUUID(), - customTitle: title, - }) + setTreeItemsCache( + (items) => + items?.map((item) => + item.id === projectId ? { ...item, title } : item + ) ?? null + ) }, [runtime.client, runtime.commands, state.project?.id] ) @@ -548,6 +582,9 @@ function NormalizedThreadChat({ commandId: crypto.randomUUID(), }) removeWorkspaceState(window.localStorage, projectId) + setTreeItemsCache( + (items) => items?.filter((item) => item.id !== projectId) ?? null + ) }, [runtime.client, runtime.commands, state.project?.id] ) @@ -600,11 +637,7 @@ function NormalizedThreadChat({ } return ( -
+
{workspace.viewMode === "columns" ? ( @@ -702,7 +735,8 @@ function NormalizedThreadChat({ currentTreeId={treeId} currentTitle={mainSubtitle ?? SUBTITLE_FALLBACK} currentThreadCount={Object.keys(tree.threads).length} - loadItems={loadTreeItems} + cachedItems={treeItemsCache} + refreshItems={refreshTreeItems} renameItem={renameTreeItem} deleteItem={deleteTreeItem} closing={treeList.closing} From 83310de3a628cba85ef4f3c468f93ee6f34ef8fc Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Fri, 4 Sep 2026 01:51:30 +0800 Subject: [PATCH 2/2] refactor(thread-chat): centralize project list cache --- AGENTS.md | 6 + app/thread-chat/core/project-list-store.tsx | 122 ++++++++++++++ .../gate-3-harness/mock-v1-runtime.ts | 13 +- app/thread-chat/layout.tsx | 3 +- app/thread-chat/net/client.ts | 3 +- .../navigation/tree-list-row.tsx | 4 +- .../orchestration/navigation/tree-list.tsx | 60 +++---- app/thread-chat/thread-chat-demo.tsx | 151 ++++++------------ app/thread-chat/tree-redirect.tsx | 9 +- constants/project-workspace.ts | 1 + e2e/thread-chat/normalized-v1-api-db.test.mjs | 11 +- e2e/thread-chat/project-list-store.test.mjs | 47 ++++++ lib/thread-chat/application/queries.ts | 16 +- lib/thread-chat/contracts/dto.ts | 7 + .../persistence/project-repository.ts | 12 +- 15 files changed, 294 insertions(+), 171 deletions(-) create mode 100644 app/thread-chat/core/project-list-store.tsx create mode 100644 e2e/thread-chat/project-list-store.test.mjs diff --git a/AGENTS.md b/AGENTS.md index e4306bc5..e611eea6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,3 +15,9 @@ project-wide instructions. `CLAUDE.md` is the single source of truth for shared development commands, workflow rules, architecture, and implementation notes. If an instruction in this file conflicts with `CLAUDE.md`, follow this file. + +## Formatting + +- 不得直接执行 Prettier、`pnpm format` 或其他 format 命令。 +- 仅允许已配置的 git hook 调用 Prettier 或 format;若 hook 未配置或未运行, + 保持现有格式,不得手动补跑。 diff --git a/app/thread-chat/core/project-list-store.tsx b/app/thread-chat/core/project-list-store.tsx new file mode 100644 index 00000000..6d93284f --- /dev/null +++ b/app/thread-chat/core/project-list-store.tsx @@ -0,0 +1,122 @@ +"use client" + +import React, { createContext, useContext, useEffect, useState } from "react" +import { createStore, type StoreApi } from "zustand/vanilla" +import { useStore } from "zustand" + +import type { ProjectListItemDTO } from "@/lib/thread-chat/contracts/dto" +import { + createThreadChatClient, + type ThreadChatClient, +} from "../net/client" + +interface ProjectListState { + items: ProjectListItemDTO[] | null + refreshing: boolean + loadFailed: boolean + refresh(): Promise + setTitle(projectId: string, title: string): void + restoreTitle(projectId: string, expected: string, title: string): void + remove(projectId: string): void +} + +export type ProjectListStore = StoreApi + +export function createProjectListStore( + client: Pick +): ProjectListStore { + let pending: Promise | null = null + let revision = 0 + + return createStore()((set, get) => ({ + items: null, + refreshing: false, + loadFailed: false, + refresh() { + if (pending) return pending + const startedAtRevision = revision + set({ refreshing: true, loadFailed: false }) + pending = client + .listProjects(false) + .then((items) => { + if (revision === startedAtRevision) set({ items }) + return items + }) + .catch((error: unknown) => { + if (revision === startedAtRevision && get().items === null) + set({ loadFailed: true }) + throw error + }) + .finally(() => { + pending = null + set({ refreshing: false }) + }) + return pending + }, + setTitle(projectId, title) { + if (!get().items?.some((item) => item.id === projectId)) return + revision += 1 + set((state) => ({ + items: state.items!.map((item) => + item.id === projectId ? { ...item, title } : item + ), + })) + }, + restoreTitle(projectId, expected, title) { + if ( + !get().items?.some( + (item) => item.id === projectId && item.title === expected + ) + ) + return + revision += 1 + set((state) => ({ + items: state.items!.map((item) => + item.id === projectId ? { ...item, title } : item + ), + })) + }, + remove(projectId) { + if (!get().items?.some((item) => item.id === projectId)) return + revision += 1 + set((state) => ({ + items: state.items!.filter((item) => item.id !== projectId), + })) + }, + })) +} + +const ProjectListStoreContext = createContext(null) + +export function ProjectListStoreProvider({ + children, +}: { + children: React.ReactNode +}) { + const [store] = useState(() => + createProjectListStore(createThreadChatClient()) + ) + + useEffect(() => { + void store.getState().refresh().catch(() => undefined) + }, [store]) + + return ( + + {children} + + ) +} + +export function useProjectListStoreApi(): ProjectListStore { + const store = useContext(ProjectListStoreContext) + if (!store) + throw new Error("useProjectListStoreApi 必须在 ProjectListStoreProvider 内使用") + return store +} + +export function useProjectListStore( + selector: (state: ProjectListState) => T +): T { + return useStore(useProjectListStoreApi(), selector) +} diff --git a/app/thread-chat/gate-3-harness/mock-v1-runtime.ts b/app/thread-chat/gate-3-harness/mock-v1-runtime.ts index 99d2bb49..8ecc2c3d 100644 --- a/app/thread-chat/gate-3-harness/mock-v1-runtime.ts +++ b/app/thread-chat/gate-3-harness/mock-v1-runtime.ts @@ -6,6 +6,7 @@ import type { ProjectDTO, ThreadDTO, } from "@/lib/thread-chat/contracts/dto" +import { PROJECT_TITLE_FALLBACK } from "@/constants/project-workspace" import type { ThreadChatClient } from "../net/client" export type Gate3HarnessScenario = @@ -502,7 +503,17 @@ export function createGate3MockRuntime( const client: ThreadChatClient = { async listProjects(archived = false) { return project && Boolean(project.archivedAt) === archived - ? [clone(project)] + ? [ + { + id: project.id, + title: + project.customTitle ?? + project.autoTitle ?? + PROJECT_TITLE_FALLBACK, + updatedAt: project.updatedAt, + threadCount: threads.size, + }, + ] : [] }, async getProject() { diff --git a/app/thread-chat/layout.tsx b/app/thread-chat/layout.tsx index 808ff069..1a60a23f 100644 --- a/app/thread-chat/layout.tsx +++ b/app/thread-chat/layout.tsx @@ -1,6 +1,7 @@ import { redirect } from "next/navigation" import { getSession } from "@/lib/auth/server" import { ROUTES, signInWithRedirect } from "@/constants/routes" +import { ProjectListStoreProvider } from "./core/project-list-store" import "./thread-chat.css" // 旗舰访问门禁:一处服务端 layout 同时包住 /thread-chat 跳板与 /thread-chat/[treeId], @@ -15,5 +16,5 @@ export default async function ThreadChatLayout({ }) { const session = await getSession() if (!session) redirect(signInWithRedirect(ROUTES.flagship)) - return <>{children} + return {children} } diff --git a/app/thread-chat/net/client.ts b/app/thread-chat/net/client.ts index b9a50a36..62e790b7 100644 --- a/app/thread-chat/net/client.ts +++ b/app/thread-chat/net/client.ts @@ -20,6 +20,7 @@ import type { MessageDTO, ProjectBootstrapDTO, ProjectDTO, + ProjectListItemDTO, ProjectFileDTO, ThreadTitleDTO, ThreadDTO, @@ -130,7 +131,7 @@ export function createThreadChatClient(options: ThreadChatClientOptions = {}) { return { listProjects(archived = false) { - return requestJson( + return requestJson( fetcher, url(`/api/thread-chat/v1/projects?archived=${String(archived)}`) ) diff --git a/app/thread-chat/orchestration/navigation/tree-list-row.tsx b/app/thread-chat/orchestration/navigation/tree-list-row.tsx index d3f82144..591ba745 100644 --- a/app/thread-chat/orchestration/navigation/tree-list-row.tsx +++ b/app/thread-chat/orchestration/navigation/tree-list-row.tsx @@ -2,7 +2,7 @@ import { Check, Pencil, Trash2, X } from "lucide-react" import { CUSTOM_TITLE_MAX_LEN } from "@/constants/thread-chat" -import type { TreeListItem } from "./tree-list" +import type { ProjectListItemDTO } from "@/lib/thread-chat/contracts/dto" /** 相对时间:「刚刚 / N 分钟前 / N 小时前 / N 天前 / M月D日」 */ function relativeTime(iso: string): string { @@ -21,7 +21,7 @@ function relativeTime(iso: string): string { } export interface TreeListRowProps { - item: TreeListItem + item: ProjectListItemDTO isCurrent: boolean unsaved: boolean editing: boolean diff --git a/app/thread-chat/orchestration/navigation/tree-list.tsx b/app/thread-chat/orchestration/navigation/tree-list.tsx index e9f53aac..4788e84f 100644 --- a/app/thread-chat/orchestration/navigation/tree-list.tsx +++ b/app/thread-chat/orchestration/navigation/tree-list.tsx @@ -3,7 +3,7 @@ * orchestration/tree-list —— 会话列表弹层(⌘⇧K / 顶栏「对话列表」按钮)。 * * 视觉沿用 ⌘K 切换器的 swx 弹层语言(tlx-* 类在 CSS 里复用同一套 token); - * 数据由页面壳层预取并缓存;每次打开先展示缓存,再后台刷新一次。 + * 数据由 thread-chat layout 级 Store 预取并缓存;每次打开先展示缓存,再后台刷新一次。 * · 条目 = 展示标题(coalesce 双轨,服务端已做)+ 相对更新时间 + 分支数徽标; * · 当前树高亮置顶——尚未入库(空树未保存)时以本地信息合成「未保存」条目; * · 内联重命名(悬停铅笔 → 输入框,Enter 提交 / Esc 取消 / 失焦放弃): @@ -28,27 +28,23 @@ import { CUSTOM_TITLE_MAX_LEN, THREAD_CHAT_SHORTCUTS, } from "@/constants/thread-chat" +import type { ProjectListItemDTO } from "@/lib/thread-chat/contracts/dto" import { dialogCloseToShell } from "../overlays/dialog-close-to-shell" import { ShortcutHint } from "../overlays/shortcut-hint" import { TreeListRow } from "./tree-list-row" -export interface TreeListItem { - id: string - title: string - updatedAt: string - threadCount: number -} - export interface TreeListProps { /** 当前打开的树(用于高亮置顶与「未保存」合成) */ currentTreeId: string /** 当前树的本地合成信息:未入库时用它拼「未保存」条目 */ currentTitle: string currentThreadCount: number - /** 页面打开后预取到的内存缓存;null 表示预取尚未成功。 */ - cachedItems: TreeListItem[] | null + /** layout 级 Store 中的内存缓存;null 表示预取尚未成功。 */ + items: ProjectListItemDTO[] | null + refreshing: boolean + loadFailed: boolean /** 获取最新列表;页面预取尚未结束时会复用同一个请求。 */ - refreshItems(): Promise + refreshItems(): Promise renameItem(projectId: string, title: string): Promise deleteItem(projectId: string): Promise /** 点击非当前树条目:壳层负责跳转(组件已先自关) */ @@ -70,7 +66,9 @@ export function TreeList({ currentTreeId, currentTitle, currentThreadCount, - cachedItems, + items, + refreshing, + loadFailed, refreshItems, renameItem, deleteItem, @@ -82,9 +80,6 @@ export function TreeList({ closing = false, container, }: TreeListProps) { - const [items, setItems] = useState(cachedItems) - const [refreshing, setRefreshing] = useState(true) - const [loadFailed, setLoadFailed] = useState(false) /** 内联重命名中的树 id + 草稿 */ const [editingId, setEditingId] = useState(null) const [draft, setDraft] = useState("") @@ -95,23 +90,7 @@ export function TreeList({ // 缓存已在首帧展示;组件每次打开重挂,并在后台刷新一次。 useEffect(() => { - let cancelled = false - void refreshItems().then( - (trees) => { - if (cancelled) return - setItems(trees) - setLoadFailed(false) - setRefreshing(false) - }, - () => { - if (cancelled) return - setLoadFailed(true) - setRefreshing(false) - } - ) - return () => { - cancelled = true - } + void refreshItems().catch(() => undefined) }, [refreshItems]) // Esc:编辑态 / 确认态在捕获期先于壳层关闭链被消费 @@ -134,19 +113,23 @@ export function TreeList({ const saved = items ?? [] const currentSaved = saved.find((t) => t.id === currentTreeId) ?? null const rest = saved.filter((t) => t.id !== currentTreeId) - const currentRow: TreeListItem = currentSaved ?? { + const currentRow: ProjectListItemDTO = currentSaved ?? { id: currentTreeId, title: currentTitle, updatedAt: "", threadCount: currentThreadCount, } - const rows: { item: TreeListItem; isCurrent: boolean; unsaved: boolean }[] = [ + const rows: { + item: ProjectListItemDTO + isCurrent: boolean + unsaved: boolean + }[] = [ { item: currentRow, isCurrent: true, unsaved: currentSaved === null }, ...rest.map((item) => ({ item, isCurrent: false, unsaved: false })), ] /* ---------- 内联重命名:乐观更新 + 失败回滚(design D5) ---------- */ - function startEdit(item: TreeListItem) { + function startEdit(item: ProjectListItemDTO) { setConfirmId(null) setEditingId(item.id) setDraft(item.title) @@ -168,18 +151,12 @@ export function TreeList({ onToast("当前对话尚未保存,发出第一条消息后才能重命名") return } - setItems((list) => - (list ?? []).map((t) => (t.id === id ? { ...t, title: next } : t)) - ) renameItem(id, next) .then(() => { // 改的是当前树:通知壳层同步本地 customTitle(主线列头副标题即时更新) if (id === currentTreeId) onRenamedCurrent?.(next) }) .catch(() => { - setItems((list) => - (list ?? []).map((t) => (t.id === id ? { ...t, title: prev } : t)) - ) onToast("重命名失败,已恢复原名") }) } @@ -196,7 +173,6 @@ export function TreeList({ return } const remaining = (items ?? []).filter((t) => t.id !== id) - setItems(remaining) setDeletingId(null) if (id === currentTreeId) { // 跳剩余最近一棵(列表本就按 updated_at 降序);一棵不剩开新树 diff --git a/app/thread-chat/thread-chat-demo.tsx b/app/thread-chat/thread-chat-demo.tsx index 7b4d1601..74725ea0 100644 --- a/app/thread-chat/thread-chat-demo.tsx +++ b/app/thread-chat/thread-chat-demo.tsx @@ -2,13 +2,10 @@ import dynamic from "next/dynamic" import { useRouter } from "next/navigation" -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import React, { useCallback, useEffect, useMemo, useState } from "react" import { DEFAULT_THREAD_CHAT_MODEL_ID } from "@/constants/model" -import type { - MessageDTO, - ProjectBootstrapDTO, -} from "@/lib/thread-chat/contracts/dto" +import { PROJECT_TITLE_FALLBACK } from "@/constants/project-workspace" import { activePathArtifacts, threadTitle, @@ -20,6 +17,10 @@ import { projectConversationTree, } from "./core/projections" import { createProjectedConversationStore } from "./core/projected-store" +import { + useProjectListStore, + useProjectListStoreApi, +} from "./core/project-list-store" import { selectThreadBusy, selectVisibleMessages } from "./core/selectors" import type { Message, MessageFeedback } from "./core/types" import { BranchableChat } from "./branching/branchable-chat" @@ -49,10 +50,7 @@ import { ThreadSwitcher, type SwitcherMode, } from "./orchestration/navigation/thread-switcher" -import { - TreeList, - type TreeListItem, -} from "./orchestration/navigation/tree-list" +import { TreeList } from "./orchestration/navigation/tree-list" import { StoreBoundProjectPanel } from "./orchestration/artifacts/store-bound-project-panel" import type { CanvasChatActions } from "./orchestration/canvas/canvas-actions" import { HelpPanel, UsageHint } from "./orchestration/overlays/help-panel" @@ -75,7 +73,6 @@ const ThreadCanvas = dynamic( } ) -const SUBTITLE_FALLBACK = "新对话" const MAIN_SUBTITLE_MAX_LEN = 28 const EMPTY_SLOTS: [] = [] @@ -83,35 +80,6 @@ function compactTitle(text: string, maxLength: number): string { return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text } -function messageText(message: MessageDTO): string { - return message.parts - .filter( - (part): part is Extract => - part.type === "text" - ) - .map((part) => part.text) - .join("") - .trim() -} - -function deriveProjectTitle(bootstrap: ProjectBootstrapDTO): string { - const rootThreadId = bootstrap.project?.rootThreadId - if (!rootThreadId) return SUBTITLE_FALLBACK - const firstUserText = bootstrap.messages - .filter( - (message) => - message.threadId === rootThreadId && - message.role === "user" && - message.supersededAt === null - ) - .sort((left, right) => left.sequence - right.sequence) - .map(messageText) - .find(Boolean) - return firstUserText - ? compactTitle(firstUserText, MAIN_SUBTITLE_MAX_LEN) - : SUBTITLE_FALLBACK -} - function legacyFeedback(value: "up" | "down" | null): MessageFeedback | null { return value === "up" ? "positive" : value === "down" ? "negative" : null } @@ -181,10 +149,9 @@ function NormalizedThreadChat({ const [draftModelId, setDraftModelId] = useState( DEFAULT_THREAD_CHAT_MODEL_ID ) - const [treeItemsCache, setTreeItemsCache] = useState( - null - ) - const treeItemsRequestRef = useRef | null>(null) + const projectListStore = useProjectListStoreApi() + const projectList = useProjectListStore((value) => value) + const currentProjectId = state.project?.id const { toast, showToast, dismissToast } = useWorkspaceToast() const setThreadModel = useCallback( (threadId: string, modelId: string) => { @@ -515,78 +482,44 @@ function NormalizedThreadChat({ [messageCommands, send, stop] ) - const loadTreeItems = useCallback(async (): Promise => { - const projects = await runtime.client.listProjects(false) - return Promise.all( - projects.map(async (project) => { - const bootstrap = await runtime.client.getProject(project.id) - return { - id: project.id, - title: - project.customTitle ?? - project.autoTitle ?? - deriveProjectTitle(bootstrap), - updatedAt: project.updatedAt, - threadCount: bootstrap.threads.length, - } - }) - ) - }, [runtime.client]) - const refreshTreeItems = useCallback((): Promise => { - const pending = treeItemsRequestRef.current - if (pending) return pending - - const request = loadTreeItems() - .then((items) => { - setTreeItemsCache(items) - return items - }) - .finally(() => { - if (treeItemsRequestRef.current === request) - treeItemsRequestRef.current = null - }) - treeItemsRequestRef.current = request - return request - }, [loadTreeItems]) - - // 当前 Project 启动完成、工作台首屏渲染后预取一次;弹窗刷新会复用进行中的请求。 - useEffect(() => { - void refreshTreeItems().catch(() => undefined) - }, [refreshTreeItems]) - const renameTreeItem = useCallback( async (projectId: string, title: string) => { - if (projectId === state.project?.id) { - await runtime.commands.renameProject(projectId, title) - } else { - await runtime.client.renameProject(projectId, { - commandId: crypto.randomUUID(), - customTitle: title, - }) + const cache = projectListStore.getState() + const previousTitle = cache.items?.find( + (item) => item.id === projectId + )?.title + cache.setTitle(projectId, title) + try { + if (projectId === currentProjectId) { + await runtime.commands.renameProject(projectId, title) + } else { + await runtime.client.renameProject(projectId, { + commandId: crypto.randomUUID(), + customTitle: title, + }) + } + } catch (error) { + if (previousTitle !== undefined) + projectListStore + .getState() + .restoreTitle(projectId, title, previousTitle) + throw error } - setTreeItemsCache( - (items) => - items?.map((item) => - item.id === projectId ? { ...item, title } : item - ) ?? null - ) }, - [runtime.client, runtime.commands, state.project?.id] + [currentProjectId, projectListStore, runtime.client, runtime.commands] ) const deleteTreeItem = useCallback( async (projectId: string) => { - if (projectId === state.project?.id) + if (projectId === currentProjectId) await runtime.commands.deleteProject(projectId) else await runtime.client.deleteProject(projectId, { commandId: crypto.randomUUID(), }) removeWorkspaceState(window.localStorage, projectId) - setTreeItemsCache( - (items) => items?.filter((item) => item.id !== projectId) ?? null - ) + projectListStore.getState().remove(projectId) }, - [runtime.client, runtime.commands, state.project?.id] + [currentProjectId, projectListStore, runtime.client, runtime.commands] ) const mainHasMessage = (tree.threads.main?.messages.length ?? 0) > 0 @@ -595,7 +528,7 @@ function NormalizedThreadChat({ ?.text.trim() const derivedSubtitle = firstUserText ? compactTitle(firstUserText, MAIN_SUBTITLE_MAX_LEN) - : SUBTITLE_FALLBACK + : PROJECT_TITLE_FALLBACK const mainSubtitle = state.project?.customTitle ?? state.project?.autoTitle ?? derivedSubtitle const hintVisible = !hintDismissed && !mainHasMessage @@ -637,7 +570,11 @@ function NormalizedThreadChat({ } return ( -
+
{workspace.viewMode === "columns" ? ( @@ -733,10 +670,12 @@ function NormalizedThreadChat({ state.refresh) useEffect(() => { let cancelled = false - const client = createThreadChatClient() - void client - .listProjects(false) + void refreshProjects() .then((projects) => { if (!cancelled) router.replace( @@ -29,7 +28,7 @@ export function TreeRedirect() { return () => { cancelled = true } - }, [router]) + }, [refreshProjects, router]) return (
正在打开对话…
diff --git a/constants/project-workspace.ts b/constants/project-workspace.ts index 5fb480a2..278517bf 100644 --- a/constants/project-workspace.ts +++ b/constants/project-workspace.ts @@ -1,4 +1,5 @@ // Project Workspace 的服务端校验、上下文预算与用户文案单一来源。 +export const PROJECT_TITLE_FALLBACK = "新对话" export const PROJECT_TARGET_MAX_CHARS = 4_000 export const PROJECT_INSTRUCTIONS_MAX_CHARS = 20_000 diff --git a/e2e/thread-chat/normalized-v1-api-db.test.mjs b/e2e/thread-chat/normalized-v1-api-db.test.mjs index bd03a783..37a7e3a1 100644 --- a/e2e/thread-chat/normalized-v1-api-db.test.mjs +++ b/e2e/thread-chat/normalized-v1-api-db.test.mjs @@ -565,7 +565,16 @@ try { }) ) ) - assert(projectList.some((project) => project.id === projectId)) + const listedProject = projectList.find((project) => project.id === projectId) + assert(listedProject) + assert.deepEqual(Object.keys(listedProject).sort(), [ + "id", + "threadCount", + "title", + "updatedAt", + ]) + assert.equal(listedProject.title, "根线程") + assert.equal(listedProject.threadCount, 2) const [projectRow] = await db .select() diff --git a/e2e/thread-chat/project-list-store.test.mjs b/e2e/thread-chat/project-list-store.test.mjs new file mode 100644 index 00000000..f286d2dc --- /dev/null +++ b/e2e/thread-chat/project-list-store.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { createProjectListStore } from "../../app/thread-chat/core/project-list-store.tsx" + +const item = (title) => ({ + id: "project-1", + title, + updatedAt: "2026-09-04T00:00:00.000Z", + threadCount: 2, +}) + +function deferred() { + let resolve + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +await test("project list store coalesces requests and ignores stale refreshes", async () => { + const requests = [] + const store = createProjectListStore({ + listProjects() { + const request = deferred() + requests.push(request) + return request.promise + }, + }) + + const first = store.getState().refresh() + const duplicate = store.getState().refresh() + assert.equal(first, duplicate) + assert.equal(requests.length, 1) + + requests[0].resolve([item("旧标题")]) + await first + assert.equal(store.getState().items[0].title, "旧标题") + + const staleRefresh = store.getState().refresh() + store.getState().setTitle("project-1", "新标题") + requests[1].resolve([item("旧标题")]) + await staleRefresh + + assert.equal(store.getState().items[0].title, "新标题") + assert.equal(store.getState().refreshing, false) +}) diff --git a/lib/thread-chat/application/queries.ts b/lib/thread-chat/application/queries.ts index 7aa49d85..a09c48f6 100644 --- a/lib/thread-chat/application/queries.ts +++ b/lib/thread-chat/application/queries.ts @@ -3,7 +3,7 @@ import type { ArtifactDTO, MessageDTO, ProjectBootstrapDTO, - ProjectDTO, + ProjectListItemDTO, } from "@/lib/thread-chat/contracts/dto" import { findOwnedArtifact, @@ -23,7 +23,6 @@ import { import { listProjectFileRows } from "@/lib/thread-chat/persistence/project-file-repository" import { findOwnedProject, - findRootThreadId, listOwnedProjectRows, } from "@/lib/thread-chat/persistence/project-repository" import { listProjectThreadRows } from "@/lib/thread-chat/persistence/thread-repository" @@ -31,15 +30,12 @@ import { listProjectThreadRows } from "@/lib/thread-chat/persistence/thread-repo export async function listProjects( userId: string, archived = false -): Promise { +): Promise { const rows = await listOwnedProjectRows(db, userId, archived) - return Promise.all( - rows.map(async (row) => { - const rootThreadId = await findRootThreadId(db, row.id) - if (!rootThreadId) throw new Error("PROJECT_WITHOUT_ROOT_THREAD") - return toProjectDTO(row, rootThreadId) - }) - ) + return rows.map((row) => ({ + ...row, + updatedAt: row.updatedAt.toISOString(), + })) } export async function getProjectBootstrap( diff --git a/lib/thread-chat/contracts/dto.ts b/lib/thread-chat/contracts/dto.ts index 14cad114..680d4dc9 100644 --- a/lib/thread-chat/contracts/dto.ts +++ b/lib/thread-chat/contracts/dto.ts @@ -22,6 +22,13 @@ export interface ProjectDTO { updatedAt: string } +export interface ProjectListItemDTO { + id: string + title: string + updatedAt: string + threadCount: number +} + export interface ProjectFileDTO { projectId: string attachmentId: string diff --git a/lib/thread-chat/persistence/project-repository.ts b/lib/thread-chat/persistence/project-repository.ts index 8f0a94c8..7172b2f7 100644 --- a/lib/thread-chat/persistence/project-repository.ts +++ b/lib/thread-chat/persistence/project-repository.ts @@ -1,4 +1,5 @@ -import { and, desc, eq, isNotNull, isNull } from "drizzle-orm" +import { and, count, desc, eq, isNotNull, isNull, sql } from "drizzle-orm" +import { PROJECT_TITLE_FALLBACK } from "@/constants/project-workspace" import { projects, threads } from "@/lib/db/schema" import type { ConversationExecutor, @@ -38,14 +39,21 @@ export async function listOwnedProjectRows( archived: boolean ) { return executor - .select() + .select({ + id: projects.id, + title: sql`coalesce(${projects.customTitle}, ${projects.autoTitle}, ${PROJECT_TITLE_FALLBACK})`, + updatedAt: projects.updatedAt, + threadCount: count(threads.id).mapWith(Number), + }) .from(projects) + .innerJoin(threads, eq(threads.projectId, projects.id)) .where( and( eq(projects.userId, userId), archived ? isNotNull(projects.archivedAt) : isNull(projects.archivedAt) ) ) + .groupBy(projects.id) .orderBy(desc(projects.updatedAt)) }