支持在历史会话中分叉出新会话
This commit is contained in:
@@ -41,6 +41,7 @@ interface ChatInputProps {
|
||||
isEditingQueueItem?: boolean
|
||||
onSaveQueueEdit?: (draft: PromptDraft) => void
|
||||
onCancelQueueEdit?: () => void
|
||||
onForkSend?: (draft: PromptDraft, modeId?: string | null) => void
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
@@ -71,6 +72,7 @@ export function ChatInput({
|
||||
isEditingQueueItem,
|
||||
onSaveQueueEdit,
|
||||
onCancelQueueEdit,
|
||||
onForkSend,
|
||||
}: ChatInputProps) {
|
||||
const t = useTranslations("Folder.chat.chatInput")
|
||||
const isConnected = status === "connected"
|
||||
@@ -116,6 +118,7 @@ export function ChatInput({
|
||||
isEditingQueueItem={isEditingQueueItem}
|
||||
onSaveQueueEdit={onSaveQueueEdit}
|
||||
onCancelQueueEdit={onCancelQueueEdit}
|
||||
onForkSend={onForkSend}
|
||||
placeholder={
|
||||
isConnecting
|
||||
? t("connecting")
|
||||
|
||||
@@ -51,6 +51,7 @@ interface ConversationShellProps {
|
||||
isEditingQueueItem?: boolean
|
||||
onSaveQueueEdit?: (draft: PromptDraft) => void
|
||||
onCancelQueueEdit?: () => void
|
||||
onForkSend?: (draft: PromptDraft, modeId?: string | null) => void
|
||||
}
|
||||
|
||||
export function ConversationShell({
|
||||
@@ -88,6 +89,7 @@ export function ConversationShell({
|
||||
isEditingQueueItem,
|
||||
onSaveQueueEdit,
|
||||
onCancelQueueEdit,
|
||||
onForkSend,
|
||||
}: ConversationShellProps) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
@@ -129,6 +131,7 @@ export function ConversationShell({
|
||||
isEditingQueueItem={isEditingQueueItem}
|
||||
onSaveQueueEdit={onSaveQueueEdit}
|
||||
onCancelQueueEdit={onCancelQueueEdit}
|
||||
onForkSend={onForkSend}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,14 +15,22 @@ import {
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Check,
|
||||
ChevronUp,
|
||||
Ellipsis,
|
||||
FileSearch,
|
||||
GitFork,
|
||||
ListPlus,
|
||||
Plus,
|
||||
Send,
|
||||
Square,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
|
||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||
@@ -76,6 +84,7 @@ interface MessageInputProps {
|
||||
isEditingQueueItem?: boolean
|
||||
onSaveQueueEdit?: (draft: PromptDraft) => void
|
||||
onCancelQueueEdit?: () => void
|
||||
onForkSend?: (draft: PromptDraft, modeId?: string | null) => void
|
||||
}
|
||||
|
||||
interface ResourceInputAttachment {
|
||||
@@ -280,6 +289,7 @@ export function MessageInput({
|
||||
isEditingQueueItem = false,
|
||||
onSaveQueueEdit,
|
||||
onCancelQueueEdit,
|
||||
onForkSend,
|
||||
}: MessageInputProps) {
|
||||
const t = useTranslations("Folder.chat.messageInput")
|
||||
const tQueue = useTranslations("Folder.chat.messageQueue")
|
||||
@@ -960,6 +970,24 @@ export function MessageInput({
|
||||
effectiveDraftStorageKey,
|
||||
])
|
||||
|
||||
const handleForkSendClick = useCallback(() => {
|
||||
if (!onForkSend) return
|
||||
const draft = buildDraft()
|
||||
if (!draft) return
|
||||
onForkSend(draft, showModeSelector ? effectiveModeId : null)
|
||||
if (effectiveDraftStorageKey) {
|
||||
clearMessageInputDraft(effectiveDraftStorageKey)
|
||||
}
|
||||
setText("")
|
||||
setAttachments([])
|
||||
}, [
|
||||
onForkSend,
|
||||
buildDraft,
|
||||
effectiveModeId,
|
||||
showModeSelector,
|
||||
effectiveDraftStorageKey,
|
||||
])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (
|
||||
@@ -1288,6 +1316,35 @@ export function MessageInput({
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : onForkSend ? (
|
||||
<div className="absolute right-2 bottom-2 flex items-center">
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={disabled || !hasSendableContent}
|
||||
size="icon"
|
||||
className="rounded-r-none"
|
||||
title={t("send")}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
disabled={disabled || !hasSendableContent}
|
||||
size="icon"
|
||||
className="rounded-l-none border-l border-primary-foreground/20 w-6"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="top">
|
||||
<DropdownMenuItem onSelect={handleForkSendClick}>
|
||||
<GitFork className="h-4 w-4" />
|
||||
{t("forkAndSend")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
|
||||
@@ -24,10 +24,12 @@ import { ConversationShell } from "@/components/chat/conversation-shell"
|
||||
import { AgentSelector } from "@/components/chat/agent-selector"
|
||||
import { ChatInput } from "@/components/chat/chat-input"
|
||||
import {
|
||||
acpFork,
|
||||
createConversation,
|
||||
openSettingsWindow,
|
||||
updateConversationExternalId,
|
||||
updateConversationStatus,
|
||||
updateConversationTitle,
|
||||
} from "@/lib/tauri"
|
||||
import { useConversationRuntime } from "@/contexts/conversation-runtime-context"
|
||||
import { useConversationDetail } from "@/hooks/use-conversation-detail"
|
||||
@@ -137,7 +139,8 @@ const ConversationTabView = memo(function ConversationTabView({
|
||||
const tWelcome = useTranslations("Folder.chat.welcomeInputPanel")
|
||||
const sharedT = useTranslations("Folder.chat.shared")
|
||||
const { folder, folderId, refreshConversations } = useFolderContext()
|
||||
const { bindConversationTab, setTabRuntimeConversationId } = useTabContext()
|
||||
const { tabs, bindConversationTab, setTabRuntimeConversationId } =
|
||||
useTabContext()
|
||||
const { setSessionStats } = useSessionStats()
|
||||
const {
|
||||
appendOptimisticTurn,
|
||||
@@ -608,6 +611,70 @@ const ConversationTabView = memo(function ConversationTabView({
|
||||
handleSendRef.current = handleSend
|
||||
}, [handleSend])
|
||||
|
||||
const handleForkSend = useCallback(
|
||||
async (draft: PromptDraft, selectedModeIdArg?: string | null) => {
|
||||
const connectionId = conn.connectionId
|
||||
if (!connectionId || connStatus !== "connected") return
|
||||
try {
|
||||
const { forkedSessionId, originalSessionId } = await acpFork(
|
||||
connectionId
|
||||
)
|
||||
const persistedId = dbConvIdRef.current
|
||||
if (persistedId != null) {
|
||||
const currentTab = tabs.find((tab) => tab.id === tabId)
|
||||
const currentTitle =
|
||||
currentTab?.title || detail?.summary.title || t("newConversation")
|
||||
// Point current conversation at S2 (forked) and add fork tag
|
||||
await updateConversationExternalId(persistedId, forkedSessionId)
|
||||
await updateConversationTitle(
|
||||
persistedId,
|
||||
`[Fork] ${currentTitle}`
|
||||
)
|
||||
// Save original S1 as a separate conversation with original title
|
||||
const s1ConvId = await createConversation(
|
||||
folderId,
|
||||
selectedAgent,
|
||||
currentTitle
|
||||
)
|
||||
await updateConversationExternalId(s1ConvId, originalSessionId)
|
||||
await updateConversationStatus(s1ConvId, "pending_review")
|
||||
}
|
||||
// Update runtime session id to S2
|
||||
sessionIdRef.current = forkedSessionId
|
||||
setExternalId(effectiveConversationId, forkedSessionId)
|
||||
|
||||
await refreshConversations()
|
||||
// Now send the message on the forked session (S2)
|
||||
handleSend(draft, selectedModeIdArg)
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("forkSessionFailed", {
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: typeof err === "object" && err !== null
|
||||
? JSON.stringify(err)
|
||||
: String(err),
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
[
|
||||
conn.connectionId,
|
||||
connStatus,
|
||||
detail?.summary.title,
|
||||
effectiveConversationId,
|
||||
folderId,
|
||||
handleSend,
|
||||
refreshConversations,
|
||||
selectedAgent,
|
||||
setExternalId,
|
||||
t,
|
||||
tabId,
|
||||
tabs,
|
||||
]
|
||||
)
|
||||
|
||||
const handleOpenAgentsSettings = useCallback(() => {
|
||||
openSettingsWindow("agents", { agentType: selectedAgent }).catch((err) => {
|
||||
console.error(
|
||||
@@ -757,6 +824,11 @@ const ConversationTabView = memo(function ConversationTabView({
|
||||
isEditingQueueItem={mqEditingItemId != null}
|
||||
onSaveQueueEdit={handleSaveQueueEdit}
|
||||
onCancelQueueEdit={handleQueueCancelEdit}
|
||||
onForkSend={
|
||||
connStatus === "connected" && hasPersistedConversation
|
||||
? handleForkSend
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isWelcomeMode ? (
|
||||
<div className="flex h-full min-h-0 flex-col items-center justify-center">
|
||||
|
||||
@@ -624,7 +624,10 @@
|
||||
"reloaded": "تمت إعادة تحميل المحادثة",
|
||||
"reload": "إعادة تحميل",
|
||||
"newConversation": "محادثة جديدة",
|
||||
"closeConversation": "إغلاق المحادثة"
|
||||
"closeConversation": "إغلاق المحادثة",
|
||||
"forkSession": "تفريع الجلسة",
|
||||
"forkSessionSuccess": "تم تفريع الجلسة بنجاح",
|
||||
"forkSessionFailed": "فشل في تفريع الجلسة: {error}"
|
||||
},
|
||||
"conversationCard": {
|
||||
"untitledConversation": "محادثة بدون عنوان",
|
||||
@@ -1197,7 +1200,8 @@
|
||||
"loadingSettings": "جارٍ تحميل الإعدادات...",
|
||||
"loadingMode": "جارٍ تحميل الوضع...",
|
||||
"cancel": "إلغاء",
|
||||
"send": "إرسال"
|
||||
"send": "إرسال",
|
||||
"forkAndSend": "تفريع وإرسال"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "إضافة للقائمة",
|
||||
|
||||
@@ -624,7 +624,10 @@
|
||||
"reloaded": "Konversation neu geladen",
|
||||
"reload": "Neu laden",
|
||||
"newConversation": "Neue Konversation",
|
||||
"closeConversation": "Konversation schließen"
|
||||
"closeConversation": "Konversation schließen",
|
||||
"forkSession": "Sitzung forken",
|
||||
"forkSessionSuccess": "Sitzung erfolgreich geforkt",
|
||||
"forkSessionFailed": "Sitzung konnte nicht geforkt werden: {error}"
|
||||
},
|
||||
"conversationCard": {
|
||||
"untitledConversation": "Unbenannte Konversation",
|
||||
@@ -1197,7 +1200,8 @@
|
||||
"loadingSettings": "Einstellungen werden geladen...",
|
||||
"loadingMode": "Modus wird geladen...",
|
||||
"cancel": "Abbrechen",
|
||||
"send": "Senden"
|
||||
"send": "Senden",
|
||||
"forkAndSend": "Fork & Senden"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "Zur Warteschlange",
|
||||
|
||||
@@ -624,7 +624,10 @@
|
||||
"reloaded": "Conversation reloaded",
|
||||
"reload": "Reload",
|
||||
"newConversation": "New Conversation",
|
||||
"closeConversation": "Close Conversation"
|
||||
"closeConversation": "Close Conversation",
|
||||
"forkSession": "Fork Session",
|
||||
"forkSessionSuccess": "Session forked successfully",
|
||||
"forkSessionFailed": "Failed to fork session: {error}"
|
||||
},
|
||||
"conversationCard": {
|
||||
"untitledConversation": "Untitled conversation",
|
||||
@@ -1197,7 +1200,8 @@
|
||||
"loadingSettings": "Loading settings...",
|
||||
"loadingMode": "Loading mode...",
|
||||
"cancel": "Cancel",
|
||||
"send": "Send"
|
||||
"send": "Send",
|
||||
"forkAndSend": "Fork & Send"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "Queue message",
|
||||
|
||||
@@ -624,7 +624,10 @@
|
||||
"reloaded": "Conversación recargada",
|
||||
"reload": "Recargar",
|
||||
"newConversation": "Nueva conversación",
|
||||
"closeConversation": "Cerrar conversación"
|
||||
"closeConversation": "Cerrar conversación",
|
||||
"forkSession": "Bifurcar sesión",
|
||||
"forkSessionSuccess": "Sesión bifurcada exitosamente",
|
||||
"forkSessionFailed": "Error al bifurcar la sesión: {error}"
|
||||
},
|
||||
"conversationCard": {
|
||||
"untitledConversation": "Conversación sin título",
|
||||
@@ -1197,7 +1200,8 @@
|
||||
"loadingSettings": "Cargando ajustes...",
|
||||
"loadingMode": "Cargando modo...",
|
||||
"cancel": "Cancelar",
|
||||
"send": "Enviar"
|
||||
"send": "Enviar",
|
||||
"forkAndSend": "Fork y Enviar"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "Agregar a la cola",
|
||||
|
||||
@@ -624,7 +624,10 @@
|
||||
"reloaded": "Conversation rechargée",
|
||||
"reload": "Recharger",
|
||||
"newConversation": "Nouvelle conversation",
|
||||
"closeConversation": "Fermer la conversation"
|
||||
"closeConversation": "Fermer la conversation",
|
||||
"forkSession": "Dupliquer la session",
|
||||
"forkSessionSuccess": "Session dupliquée avec succès",
|
||||
"forkSessionFailed": "Échec de la duplication de la session : {error}"
|
||||
},
|
||||
"conversationCard": {
|
||||
"untitledConversation": "Conversation sans titre",
|
||||
@@ -1197,7 +1200,8 @@
|
||||
"loadingSettings": "Chargement des paramètres...",
|
||||
"loadingMode": "Chargement du mode...",
|
||||
"cancel": "Annuler",
|
||||
"send": "Envoyer"
|
||||
"send": "Envoyer",
|
||||
"forkAndSend": "Fork & Envoyer"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "Mettre en file",
|
||||
|
||||
@@ -624,7 +624,10 @@
|
||||
"reloaded": "会話を再読み込みしました",
|
||||
"reload": "再読み込み",
|
||||
"newConversation": "新しい会話",
|
||||
"closeConversation": "会話を閉じる"
|
||||
"closeConversation": "会話を閉じる",
|
||||
"forkSession": "セッションをフォーク",
|
||||
"forkSessionSuccess": "セッションのフォークに成功しました",
|
||||
"forkSessionFailed": "セッションのフォークに失敗しました:{error}"
|
||||
},
|
||||
"conversationCard": {
|
||||
"untitledConversation": "無題の会話",
|
||||
@@ -1197,7 +1200,8 @@
|
||||
"loadingSettings": "設定を読み込み中...",
|
||||
"loadingMode": "モードを読み込み中...",
|
||||
"cancel": "キャンセル",
|
||||
"send": "送信"
|
||||
"send": "送信",
|
||||
"forkAndSend": "フォークして送信"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "キューに追加",
|
||||
|
||||
@@ -624,7 +624,10 @@
|
||||
"reloaded": "대화를 다시 불러왔습니다",
|
||||
"reload": "다시 불러오기",
|
||||
"newConversation": "새 대화",
|
||||
"closeConversation": "대화 닫기"
|
||||
"closeConversation": "대화 닫기",
|
||||
"forkSession": "세션 포크",
|
||||
"forkSessionSuccess": "세션 포크 성공",
|
||||
"forkSessionFailed": "세션 포크 실패: {error}"
|
||||
},
|
||||
"conversationCard": {
|
||||
"untitledConversation": "제목 없는 대화",
|
||||
@@ -1197,7 +1200,8 @@
|
||||
"loadingSettings": "설정 불러오는 중...",
|
||||
"loadingMode": "모드 불러오는 중...",
|
||||
"cancel": "취소",
|
||||
"send": "보내기"
|
||||
"send": "보내기",
|
||||
"forkAndSend": "포크 & 전송"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "대기열에 추가",
|
||||
|
||||
@@ -624,7 +624,10 @@
|
||||
"reloaded": "Conversa recarregada",
|
||||
"reload": "Recarregar",
|
||||
"newConversation": "Nova conversa",
|
||||
"closeConversation": "Fechar conversa"
|
||||
"closeConversation": "Fechar conversa",
|
||||
"forkSession": "Bifurcar sessão",
|
||||
"forkSessionSuccess": "Sessão bifurcada com sucesso",
|
||||
"forkSessionFailed": "Falha ao bifurcar a sessão: {error}"
|
||||
},
|
||||
"conversationCard": {
|
||||
"untitledConversation": "Conversa sem título",
|
||||
@@ -1197,7 +1200,8 @@
|
||||
"loadingSettings": "Carregando configurações...",
|
||||
"loadingMode": "Carregando modo...",
|
||||
"cancel": "Cancelar",
|
||||
"send": "Enviar"
|
||||
"send": "Enviar",
|
||||
"forkAndSend": "Fork & Enviar"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "Adicionar à fila",
|
||||
|
||||
@@ -624,7 +624,10 @@
|
||||
"reloaded": "当前会话已重新加载",
|
||||
"reload": "重新加载",
|
||||
"newConversation": "新建会话",
|
||||
"closeConversation": "关闭会话"
|
||||
"closeConversation": "关闭会话",
|
||||
"forkSession": "分叉会话",
|
||||
"forkSessionSuccess": "会话分叉成功",
|
||||
"forkSessionFailed": "会话分叉失败:{error}"
|
||||
},
|
||||
"conversationCard": {
|
||||
"untitledConversation": "未命名会话",
|
||||
@@ -1197,7 +1200,8 @@
|
||||
"loadingSettings": "正在加载设置...",
|
||||
"loadingMode": "正在加载模式...",
|
||||
"cancel": "取消",
|
||||
"send": "发送"
|
||||
"send": "发送",
|
||||
"forkAndSend": "分叉发送"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "加入队列",
|
||||
|
||||
@@ -624,7 +624,10 @@
|
||||
"reloaded": "目前會話已重新載入",
|
||||
"reload": "重新載入",
|
||||
"newConversation": "新增會話",
|
||||
"closeConversation": "關閉會話"
|
||||
"closeConversation": "關閉會話",
|
||||
"forkSession": "分叉會話",
|
||||
"forkSessionSuccess": "會話分叉成功",
|
||||
"forkSessionFailed": "會話分叉失敗:{error}"
|
||||
},
|
||||
"conversationCard": {
|
||||
"untitledConversation": "未命名會話",
|
||||
@@ -1197,7 +1200,8 @@
|
||||
"loadingSettings": "正在載入設定...",
|
||||
"loadingMode": "正在載入模式...",
|
||||
"cancel": "取消",
|
||||
"send": "傳送"
|
||||
"send": "傳送",
|
||||
"forkAndSend": "分叉發送"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "加入佇列",
|
||||
|
||||
@@ -119,6 +119,15 @@ export async function acpCancel(connectionId: string): Promise<void> {
|
||||
return invoke("acp_cancel", { connectionId })
|
||||
}
|
||||
|
||||
export interface ForkResult {
|
||||
forkedSessionId: string
|
||||
originalSessionId: string
|
||||
}
|
||||
|
||||
export async function acpFork(connectionId: string): Promise<ForkResult> {
|
||||
return invoke("acp_fork", { connectionId })
|
||||
}
|
||||
|
||||
export async function acpRespondPermission(
|
||||
connectionId: string,
|
||||
requestId: string,
|
||||
|
||||
Reference in New Issue
Block a user