Skip to content
Closed
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
53 changes: 52 additions & 1 deletion apps/DocFlow/src/app/docs/_components/ChatPanel/chat-tab-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { useState, useRef, useEffect } from 'react';
import { X, Plus, Clock, Sparkles } from 'lucide-react';

import type { ChatTab } from '@/stores/chatStore';
Expand All @@ -23,6 +24,7 @@ interface ChatTabBarProps {
onNewTab: () => void;
onSwitchTab: (tabId: string) => void;
onCloseTab: (tabId: string) => void;
onRenameTab: (tabId: string, newTitle: string) => void;
onOpenSession: (session: SessionItem) => void;
onRefreshSessions: () => void;
onClosePanel: () => void;
Expand All @@ -35,17 +37,53 @@ export function ChatTabBar({
onNewTab,
onSwitchTab,
onCloseTab,
onRenameTab,
onOpenSession,
onRefreshSessions,
onClosePanel,
}: ChatTabBarProps) {
const [editingTabId, setEditingTabId] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
if (editingTabId && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editingTabId]);

const handleDoubleClick = (tabId: string, title: string) => {
setEditingTabId(tabId);
setEditValue(title);
};

const handleRenameSubmit = () => {
if (editingTabId && editValue.trim()) {
onRenameTab(editingTabId, editValue.trim());
}

setEditingTabId(null);
setEditValue('');
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleRenameSubmit();
} else if (e.key === 'Escape') {
setEditingTabId(null);
setEditValue('');
}
};
Comment on lines +45 to +77
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

当前重命名逻辑存在两个问题:

  1. Escape 键取消编辑时,会错误地保存当前输入的内容。
  2. Enter 键确认重命名时,可能会触发两次保存操作(一次来自 onKeyDown,一次来自 onBlur)。

这两个问题都源于 onBlur 事件在输入框失焦时(包括程序性失焦)总是会触发保存。建议重构事件处理逻辑,使用一个 ref 来防止重复操作和区分取消与保存操作,以确保行为的正确性。

  const [editingTabId, setEditingTabId] = useState<string | null>(null);
  const [editValue, setEditValue] = useState('');
  const inputRef = useRef<HTMLInputElement>(null);
  const isActionTaken = useRef(false);

  useEffect(() => {
    if (editingTabId && inputRef.current) {
      isActionTaken.current = false;
      inputRef.current.focus();
      inputRef.current.select();
    }
  }, [editingTabId]);

  const handleDoubleClick = (tabId: string, title: string) => {
    setEditingTabId(tabId);
    setEditValue(title);
  };

  const handleRenameSubmit = () => {
    if (isActionTaken.current) return;
    isActionTaken.current = true;

    if (editingTabId && editValue.trim()) {
      onRenameTab(editingTabId, editValue.trim());
    }

    setEditingTabId(null);
    setEditValue('');
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter') {
      handleRenameSubmit();
    } else if (e.key === 'Escape') {
      if (isActionTaken.current) return;
      isActionTaken.current = true;
      setEditingTabId(null);
      setEditValue('');
    }
  };


return (
<div className="flex items-center bg-white border-b border-gray-100 min-h-[38px]">
<div className="flex-1 flex items-center overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onSwitchTab(tab.id)}
onDoubleClick={() => handleDoubleClick(tab.id, tab.title)}
className={cn(
'group relative flex items-center gap-1.5 px-3 py-2 text-xs font-medium whitespace-nowrap max-w-[200px] min-w-0 transition-colors shrink-0',
tab.id === activeTabId
Expand All @@ -54,7 +92,20 @@ export function ChatTabBar({
)}
>
<Sparkles className="h-3 w-3 shrink-0 text-blue-500" />
<span className="truncate">{tab.title}</span>
{editingTabId === tab.id ? (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleRenameSubmit}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
className="w-full bg-white border border-blue-500 rounded px-1 py-0.5 text-gray-800 outline-none"
/>
) : (
<span className="truncate">{tab.title}</span>
)}
{tabs.length > 1 && (
<span
role="button"
Expand Down
5 changes: 5 additions & 0 deletions apps/DocFlow/src/app/docs/_components/ChatPanel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,10 @@ export function ChatPanel({ className }: ChatPanelProps) {
removeTab(tabId);
};

const handleRenameTab = (tabId: string, newTitle: string) => {
updateTab(tabId, { title: newTitle });
};

const handleOpenSession = (session: { id: string; title: string }) => {
const existing = tabs.find((t) => t.conversationId === session.id);

Expand Down Expand Up @@ -477,6 +481,7 @@ export function ChatPanel({ className }: ChatPanelProps) {
onNewTab={handleNewTab}
onSwitchTab={handleSwitchTab}
onCloseTab={handleCloseTab}
onRenameTab={handleRenameTab}
onOpenSession={handleOpenSession}
onRefreshSessions={refreshSessions}
onClosePanel={() => setIsOpen(false)}
Expand Down
188 changes: 114 additions & 74 deletions apps/DocFlow/src/stores/chatStore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

export interface ChatTab {
id: string;
Expand Down Expand Up @@ -29,6 +30,7 @@ interface ChatState {
updateTab: (id: string, updates: Partial<Omit<ChatTab, 'id'>>) => void;
setDocumentReference: (reference: DocumentReference | null) => void;
setPresetMessage: (message: string | null) => void;
onRenameTab: (id: string, newTitle: string) => void;
}

let tabCounter = 0;
Expand All @@ -39,77 +41,115 @@ function createTabId(): string {
return `tab-${tabCounter}-${Date.now()}`;
}

export const useChatStore = create<ChatState>((set, get) => ({
isOpen: true,
tabs: [],
activeTabId: null,
documentReference: null,
presetMessage: null,

setIsOpen: (isOpen) => {
set({ isOpen });

if (isOpen && get().tabs.length === 0) {
get().addTab();
}
},

togglePanel: () => {
const next = !get().isOpen;
set({ isOpen: next });

if (next && get().tabs.length === 0) {
get().addTab();
}
},

addTab: (tab) => {
const id = createTabId();
const newTab: ChatTab = {
id,
title: tab?.title || '新对话',
conversationId: tab?.conversationId || null,
};

set((state) => ({
tabs: [...state.tabs, newTab],
activeTabId: id,
}));

return id;
},

removeTab: (id) => {
set((state) => {
const newTabs = state.tabs.filter((t) => t.id !== id);
let newActiveId = state.activeTabId;

if (state.activeTabId === id) {
const removedIndex = state.tabs.findIndex((t) => t.id === id);

if (newTabs.length > 0) {
newActiveId = newTabs[Math.min(removedIndex, newTabs.length - 1)].id;
} else {
newActiveId = null;
}
}

return {
tabs: newTabs,
activeTabId: newActiveId,
isOpen: newTabs.length > 0 ? state.isOpen : false,
};
});
},

setActiveTab: (id) => set({ activeTabId: id }),

updateTab: (id, updates) =>
set((state) => ({
tabs: state.tabs.map((t) => (t.id === id ? { ...t, ...updates } : t)),
})),

setDocumentReference: (reference) => set({ documentReference: reference }),

setPresetMessage: (message) => set({ presetMessage: message }),
}));
export const useChatStore = create<ChatState>()(
persist(
(set) => ({
isOpen: true,
tabs: [],
activeTabId: null,
documentReference: null,
presetMessage: null,

setIsOpen: (isOpen) => {
set((state) => {
if (isOpen && state.tabs.length === 0) {
const id = createTabId();
const newTab: ChatTab = {
id,
title: '新对话',
conversationId: null,
};

return {
isOpen,
tabs: [newTab],
activeTabId: id,
};
}

return { isOpen };
});
},

togglePanel: () => {
set((state) => {
const next = !state.isOpen;

if (next && state.tabs.length === 0) {
const id = createTabId();
const newTab: ChatTab = {
id,
title: '新对话',
conversationId: null,
};

return {
isOpen: next,
tabs: [newTab],
activeTabId: id,
};
}

return { isOpen: next };
});
},

addTab: (tab) => {
const id = createTabId();
const newTab: ChatTab = {
id,
title: tab?.title || '新对话',
conversationId: tab?.conversationId || null,
};

set((state) => ({
tabs: [...state.tabs, newTab],
activeTabId: id,
}));

return id;
},

removeTab: (id) => {
set((state) => {
const newTabs = state.tabs.filter((t) => t.id !== id);
let newActiveId = state.activeTabId;

if (state.activeTabId === id) {
const removedIndex = state.tabs.findIndex((t) => t.id === id);

if (newTabs.length > 0) {
newActiveId = newTabs[Math.min(removedIndex, newTabs.length - 1)].id;
} else {
newActiveId = null;
}
}

return {
tabs: newTabs,
activeTabId: newActiveId,
isOpen: newTabs.length > 0 ? state.isOpen : false,
};
});
},

setActiveTab: (id) => set({ activeTabId: id }),

updateTab: (id, updates) =>
set((state) => ({
tabs: state.tabs.map((t) => (t.id === id ? { ...t, ...updates } : t)),
})),
onRenameTab: (id, newTitle) =>
set((state) => ({
tabs: state.tabs.map((t) => (t.id === id ? { ...t, title: newTitle } : t)),
})),
Comment on lines +142 to +145
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

这个 onRenameTab 方法的实现是多余的。重命名功能已经通过 updateTab 方法在 ChatPanel/index.tsx 组件中实现。为了保持代码简洁并避免冗余,建议移除此方法。同时,也请从第 33 行的 ChatState 接口中移除其定义。


setDocumentReference: (reference) => set({ documentReference: reference }),

setPresetMessage: (message) => set({ presetMessage: message }),
}),
{
name: 'chat-storage',
},
Comment on lines +151 to +153
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

使用 persist 中间件时,默认会持久化整个 store 的状态。其中 documentReferencepresetMessage 似乎是临时状态,不应该在页面刷新后保留。这可能导致非预期的行为,例如在新的会话中出现旧的文档引用。

建议使用 persist 中间件的 partialize 选项来指定只持久化需要跨会话保留的状态,例如 tabs, activeTabIdisOpen

    {
      name: 'chat-storage',
      partialize: (state) => ({
        isOpen: state.isOpen,
        tabs: state.tabs,
        activeTabId: state.activeTabId,
      }),
    },

),
);
Loading