Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 未配置或未运行,
保持现有格式,不得手动补跑。
122 changes: 122 additions & 0 deletions app/thread-chat/core/project-list-store.tsx
Original file line number Diff line number Diff line change
@@ -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<ProjectListItemDTO[]>
setTitle(projectId: string, title: string): void
restoreTitle(projectId: string, expected: string, title: string): void
remove(projectId: string): void
}

export type ProjectListStore = StoreApi<ProjectListState>

export function createProjectListStore(
client: Pick<ThreadChatClient, "listProjects">
): ProjectListStore {
let pending: Promise<ProjectListItemDTO[]> | null = null
let revision = 0

return createStore<ProjectListState>()((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<ProjectListStore | null>(null)

export function ProjectListStoreProvider({
children,
}: {
children: React.ReactNode
}) {
const [store] = useState(() =>
createProjectListStore(createThreadChatClient())
)

useEffect(() => {
void store.getState().refresh().catch(() => undefined)
}, [store])

return (
<ProjectListStoreContext.Provider value={store}>
{children}
</ProjectListStoreContext.Provider>
)
}

export function useProjectListStoreApi(): ProjectListStore {
const store = useContext(ProjectListStoreContext)
if (!store)
throw new Error("useProjectListStoreApi 必须在 ProjectListStoreProvider 内使用")
return store
}

export function useProjectListStore<T>(
selector: (state: ProjectListState) => T
): T {
return useStore(useProjectListStoreApi(), selector)
}
13 changes: 12 additions & 1 deletion app/thread-chat/gate-3-harness/mock-v1-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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() {
Expand Down
3 changes: 2 additions & 1 deletion app/thread-chat/layout.tsx
Original file line number Diff line number Diff line change
@@ -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],
Expand All @@ -15,5 +16,5 @@ export default async function ThreadChatLayout({
}) {
const session = await getSession()
if (!session) redirect(signInWithRedirect(ROUTES.flagship))
return <>{children}</>
return <ProjectListStoreProvider>{children}</ProjectListStoreProvider>
}
3 changes: 2 additions & 1 deletion app/thread-chat/net/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
MessageDTO,
ProjectBootstrapDTO,
ProjectDTO,
ProjectListItemDTO,
ProjectFileDTO,
ThreadTitleDTO,
ThreadDTO,
Expand Down Expand Up @@ -130,7 +131,7 @@ export function createThreadChatClient(options: ThreadChatClientOptions = {}) {

return {
listProjects(archived = false) {
return requestJson<ProjectDTO[]>(
return requestJson<ProjectListItemDTO[]>(
fetcher,
url(`/api/thread-chat/v1/projects?archived=${String(archived)}`)
)
Expand Down
4 changes: 2 additions & 2 deletions app/thread-chat/orchestration/navigation/tree-list-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,7 +21,7 @@ function relativeTime(iso: string): string {
}

export interface TreeListRowProps {
item: TreeListItem
item: ProjectListItemDTO
isCurrent: boolean
unsaved: boolean
editing: boolean
Expand Down
69 changes: 37 additions & 32 deletions app/thread-chat/orchestration/navigation/tree-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* orchestration/tree-list —— 会话列表弹层(⌘⇧K / 顶栏「对话列表」按钮)。
*
* 视觉沿用 ⌘K 切换器的 swx 弹层语言(tlx-* 类在 CSS 里复用同一套 token);
* 数据每次打开现拉(design D3:无缓存/无轮询,壳层以重挂方式打开保证归零)
* 数据由 thread-chat layout 级 Store 预取并缓存;每次打开先展示缓存,再后台刷新一次
* · 条目 = 展示标题(coalesce 双轨,服务端已做)+ 相对更新时间 + 分支数徽标;
* · 当前树高亮置顶——尚未入库(空树未保存)时以本地信息合成「未保存」条目;
* · 内联重命名(悬停铅笔 → 输入框,Enter 提交 / Esc 取消 / 失焦放弃):
Expand All @@ -28,24 +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
loadItems(): Promise<TreeListItem[]>
/** layout 级 Store 中的内存缓存;null 表示预取尚未成功。 */
items: ProjectListItemDTO[] | null
refreshing: boolean
loadFailed: boolean
/** 获取最新列表;页面预取尚未结束时会复用同一个请求。 */
refreshItems(): Promise<ProjectListItemDTO[]>
renameItem(projectId: string, title: string): Promise<void>
deleteItem(projectId: string): Promise<void>
/** 点击非当前树条目:壳层负责跳转(组件已先自关) */
Expand All @@ -67,7 +66,10 @@ export function TreeList({
currentTreeId,
currentTitle,
currentThreadCount,
loadItems,
items,
refreshing,
loadFailed,
refreshItems,
renameItem,
deleteItem,
onSwitch,
Expand All @@ -78,8 +80,6 @@ export function TreeList({
closing = false,
container,
}: TreeListProps) {
/** null = 拉取中 */
const [items, setItems] = useState<TreeListItem[] | null>(null)
/** 内联重命名中的树 id + 草稿 */
const [editingId, setEditingId] = useState<string | null>(null)
const [draft, setDraft] = useState("")
Expand All @@ -88,16 +88,10 @@ export function TreeList({
/** 删除请求进行中的树 id(防连点) */
const [deletingId, setDeletingId] = useState<string | null>(null)

// 打开现拉(组件每次打开重挂,天然只拉一次)
// 缓存已在首帧展示;组件每次打开重挂,并在后台刷新一次。
useEffect(() => {
let cancelled = false
void loadItems().then((trees) => {
if (!cancelled) setItems(trees)
})
return () => {
cancelled = true
}
}, [loadItems])
void refreshItems().catch(() => undefined)
}, [refreshItems])

// Esc:编辑态 / 确认态在捕获期先于壳层关闭链被消费
useEffect(() => {
Expand All @@ -119,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)
Expand All @@ -153,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("重命名失败,已恢复原名")
})
}
Expand All @@ -181,7 +173,6 @@ export function TreeList({
return
}
const remaining = (items ?? []).filter((t) => t.id !== id)
setItems(remaining)
setDeletingId(null)
if (id === currentTreeId) {
// 跳剩余最近一棵(列表本就按 updated_at 降序);一棵不剩开新树
Expand Down Expand Up @@ -218,9 +209,23 @@ export function TreeList({
{...THREAD_CHAT_SHORTCUTS.openTreeList}
className="ml-auto shrink-0"
/>
{refreshing && (
<div
className="tlx-refresh"
role="progressbar"
aria-label="正在更新对话列表"
>
<span />
</div>
)}
</div>
<div className="swx-list">
{items === null && <div className="swx-empty">加载中…</div>}
{items === null && !loadFailed && (
<div className="swx-empty">加载中…</div>
)}
{items === null && loadFailed && (
<div className="swx-empty">加载失败,请稍后重新打开</div>
)}
{items !== null &&
rows.map(({ item, isCurrent, unsaved }) => {
const editing = editingId === item.id
Expand Down
20 changes: 20 additions & 0 deletions app/thread-chat/styles/tree-list.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading