重构会话消息处理和显示逻辑
This commit is contained in:
@@ -10,6 +10,7 @@ import { FolderProvider } from "@/contexts/folder-context"
|
|||||||
import { TaskProvider } from "@/contexts/task-context"
|
import { TaskProvider } from "@/contexts/task-context"
|
||||||
import { AlertProvider } from "@/contexts/alert-context"
|
import { AlertProvider } from "@/contexts/alert-context"
|
||||||
import { AcpConnectionsProvider } from "@/contexts/acp-connections-context"
|
import { AcpConnectionsProvider } from "@/contexts/acp-connections-context"
|
||||||
|
import { ConversationRuntimeProvider } from "@/contexts/conversation-runtime-context"
|
||||||
import { TabProvider } from "@/contexts/tab-context"
|
import { TabProvider } from "@/contexts/tab-context"
|
||||||
import { SessionStatsProvider } from "@/contexts/session-stats-context"
|
import { SessionStatsProvider } from "@/contexts/session-stats-context"
|
||||||
import { SidebarProvider, useSidebarContext } from "@/contexts/sidebar-context"
|
import { SidebarProvider, useSidebarContext } from "@/contexts/sidebar-context"
|
||||||
@@ -643,6 +644,7 @@ function FolderLayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
<AlertProvider>
|
<AlertProvider>
|
||||||
<TaskProvider>
|
<TaskProvider>
|
||||||
<AcpConnectionsProvider>
|
<AcpConnectionsProvider>
|
||||||
|
<ConversationRuntimeProvider>
|
||||||
<WorkspaceProvider key={`workspace-${normalizedFolderId}`}>
|
<WorkspaceProvider key={`workspace-${normalizedFolderId}`}>
|
||||||
<TabProvider>
|
<TabProvider>
|
||||||
<SessionStatsProvider>
|
<SessionStatsProvider>
|
||||||
@@ -673,6 +675,7 @@ function FolderLayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
</SessionStatsProvider>
|
</SessionStatsProvider>
|
||||||
</TabProvider>
|
</TabProvider>
|
||||||
</WorkspaceProvider>
|
</WorkspaceProvider>
|
||||||
|
</ConversationRuntimeProvider>
|
||||||
</AcpConnectionsProvider>
|
</AcpConnectionsProvider>
|
||||||
</TaskProvider>
|
</TaskProvider>
|
||||||
</AlertProvider>
|
</AlertProvider>
|
||||||
|
|||||||
@@ -1,949 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { MessageInput } from "@/components/chat/message-input"
|
|
||||||
import type { AgentType, PromptDraft, SessionStats } from "@/lib/types"
|
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
|
||||||
import { useTabContext } from "@/contexts/tab-context"
|
|
||||||
import { useSessionStats } from "@/contexts/session-stats-context"
|
|
||||||
import {
|
|
||||||
useAcpActions,
|
|
||||||
type LiveMessage,
|
|
||||||
} from "@/contexts/acp-connections-context"
|
|
||||||
import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle"
|
|
||||||
import type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter"
|
|
||||||
import {
|
|
||||||
adaptLiveMessageFromAcp,
|
|
||||||
adaptMessageTurns,
|
|
||||||
} from "@/lib/adapters/ai-elements-adapter"
|
|
||||||
import {
|
|
||||||
buildUserMessageTextPartsFromDraft,
|
|
||||||
extractUserImagesFromDraft,
|
|
||||||
extractUserResourcesFromDraft,
|
|
||||||
getPromptDraftDisplayText,
|
|
||||||
} from "@/lib/prompt-draft"
|
|
||||||
import {
|
|
||||||
buildPlanKey,
|
|
||||||
extractLatestPlanEntriesFromMessages,
|
|
||||||
} from "@/lib/agent-plan"
|
|
||||||
import {
|
|
||||||
buildConversationDraftStorageKey,
|
|
||||||
buildNewConversationDraftStorageKey,
|
|
||||||
moveMessageInputDraft,
|
|
||||||
} from "@/lib/message-input-draft"
|
|
||||||
import {
|
|
||||||
createConversation,
|
|
||||||
getFolderConversation,
|
|
||||||
openSettingsWindow,
|
|
||||||
updateConversationStatus,
|
|
||||||
updateConversationExternalId,
|
|
||||||
} from "@/lib/tauri"
|
|
||||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
|
||||||
import { AgentSelector } from "@/components/chat/agent-selector"
|
|
||||||
import { LiveMessageBlock } from "@/components/chat/live-message-block"
|
|
||||||
import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
|
|
||||||
import { LiveTurnStats } from "@/components/message/live-turn-stats"
|
|
||||||
import { TurnStats } from "@/components/message/turn-stats"
|
|
||||||
import { UserResourceLinks } from "@/components/message/user-resource-links"
|
|
||||||
import { UserImageAttachments } from "@/components/message/user-image-attachments"
|
|
||||||
import { ConversationShell } from "@/components/chat/conversation-shell"
|
|
||||||
import { MessageThread } from "@/components/ai-elements/message-thread"
|
|
||||||
import { Message, MessageContent } from "@/components/ai-elements/message"
|
|
||||||
import { ContentPartsRenderer } from "@/components/message/content-parts-renderer"
|
|
||||||
import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread"
|
|
||||||
|
|
||||||
const ACP_AGENTS_UPDATED_EVENT = "app://acp-agents-updated"
|
|
||||||
|
|
||||||
interface WelcomeInputPanelProps {
|
|
||||||
defaultAgentType?: AgentType
|
|
||||||
workingDir?: string
|
|
||||||
tabId?: string
|
|
||||||
isActive?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AgentsUpdatedEventPayload {
|
|
||||||
reason?: string
|
|
||||||
agent_type?: AgentType | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeErrorMessage(error: unknown): string {
|
|
||||||
if (error instanceof Error) return error.message
|
|
||||||
return String(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExpectedAutoLinkError(error: unknown): boolean {
|
|
||||||
if (!error || typeof error !== "object") return false
|
|
||||||
return (error as { alerted?: unknown }).alerted === true
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildInlineAutoConnectErrorMessage(
|
|
||||||
raw: string,
|
|
||||||
options: {
|
|
||||||
fallback: string
|
|
||||||
append: (message: string) => string
|
|
||||||
alreadyContainsPath: (message: string) => boolean
|
|
||||||
}
|
|
||||||
): string {
|
|
||||||
const normalized = raw.trim().replace(/[。.!?,,;;::]+$/u, "")
|
|
||||||
if (!normalized) return options.fallback
|
|
||||||
if (options.alreadyContainsPath(normalized)) return normalized
|
|
||||||
return options.append(normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
type WelcomeThreadItem =
|
|
||||||
| { key: string; kind: "history"; message: AdaptedMessage }
|
|
||||||
| {
|
|
||||||
key: string
|
|
||||||
kind: "live"
|
|
||||||
message: LiveMessage
|
|
||||||
isStreaming: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const WelcomeHistoryMessage = memo(function WelcomeHistoryMessage({
|
|
||||||
message,
|
|
||||||
}: {
|
|
||||||
message: AdaptedMessage
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Message from={message.role === "tool" ? "assistant" : message.role}>
|
|
||||||
{message.role === "user" && message.userImages?.length ? (
|
|
||||||
<UserImageAttachments
|
|
||||||
images={message.userImages}
|
|
||||||
className="self-end"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<MessageContent>
|
|
||||||
<ContentPartsRenderer parts={message.content} role={message.role} />
|
|
||||||
</MessageContent>
|
|
||||||
{message.role === "user" && message.userResources?.length ? (
|
|
||||||
<UserResourceLinks
|
|
||||||
resources={message.userResources}
|
|
||||||
className="self-end"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Message>
|
|
||||||
{message.role === "assistant" && (
|
|
||||||
<TurnStats
|
|
||||||
usage={message.usage}
|
|
||||||
duration_ms={message.duration_ms}
|
|
||||||
model={message.model}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export function WelcomeInputPanel({
|
|
||||||
defaultAgentType,
|
|
||||||
workingDir,
|
|
||||||
tabId,
|
|
||||||
isActive = true,
|
|
||||||
}: WelcomeInputPanelProps) {
|
|
||||||
const t = useTranslations("Folder.chat.welcomeInputPanel")
|
|
||||||
const tabT = useTranslations("Folder.tabContext")
|
|
||||||
const sharedT = useTranslations("Folder.chat.shared")
|
|
||||||
const fallbackContextId = useMemo(() => crypto.randomUUID(), [])
|
|
||||||
const contextKey = tabId ?? `new-${fallbackContextId}`
|
|
||||||
|
|
||||||
const { folderId, refreshConversations } = useFolderContext()
|
|
||||||
const { promoteNewConversationTab, linkTabConversation } = useTabContext()
|
|
||||||
const { setSessionStats } = useSessionStats()
|
|
||||||
const { migrateContextKey } = useAcpActions()
|
|
||||||
const latestSessionStatsRef = useRef<SessionStats | null>(null)
|
|
||||||
const isActiveRef = useRef(isActive)
|
|
||||||
const statsRefreshSeqRef = useRef(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
isActiveRef.current = isActive
|
|
||||||
}, [isActive])
|
|
||||||
|
|
||||||
// Reset or restore token stats when tab becomes active
|
|
||||||
useEffect(() => {
|
|
||||||
if (isActive) {
|
|
||||||
setSessionStats(latestSessionStatsRef.current)
|
|
||||||
}
|
|
||||||
}, [isActive, setSessionStats])
|
|
||||||
|
|
||||||
const applySessionStats = useCallback(
|
|
||||||
(stats: SessionStats | null) => {
|
|
||||||
latestSessionStatsRef.current = stats
|
|
||||||
if (isActiveRef.current) {
|
|
||||||
setSessionStats(stats)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setSessionStats]
|
|
||||||
)
|
|
||||||
|
|
||||||
const hasTokenStats = useCallback((stats: SessionStats | null): boolean => {
|
|
||||||
if (!stats) return false
|
|
||||||
return (
|
|
||||||
stats.total_usage !== null ||
|
|
||||||
stats.total_tokens != null ||
|
|
||||||
stats.context_window_used_tokens != null ||
|
|
||||||
stats.context_window_max_tokens != null
|
|
||||||
)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const hasAssistantUsage = useCallback(
|
|
||||||
(messages: AdaptedMessage[]): boolean => {
|
|
||||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
||||||
const message = messages[i]
|
|
||||||
if (message.role !== "assistant") continue
|
|
||||||
return message.usage != null
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const refreshConversationFromDb = useCallback(
|
|
||||||
async (expectedTurnCount?: number) => {
|
|
||||||
const conversationId = dbConvIdRef.current
|
|
||||||
if (!conversationId) return
|
|
||||||
|
|
||||||
const refreshSeq = ++statsRefreshSeqRef.current
|
|
||||||
const maxAttempts = 10
|
|
||||||
const retryDelayMs = 400
|
|
||||||
let latestMessages: AdaptedMessage[] | null = null
|
|
||||||
let latestStats: SessionStats | null = null
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
||||||
if (refreshSeq !== statsRefreshSeqRef.current) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const detail = await getFolderConversation(conversationId)
|
|
||||||
if (refreshSeq !== statsRefreshSeqRef.current) return
|
|
||||||
|
|
||||||
const messages = adaptMessageTurns(detail.turns, {
|
|
||||||
attachedResources: sharedT("attachedResources"),
|
|
||||||
toolCallFailed: sharedT("toolCallFailed"),
|
|
||||||
})
|
|
||||||
const stats = detail.session_stats ?? null
|
|
||||||
latestMessages = messages
|
|
||||||
latestStats = stats
|
|
||||||
|
|
||||||
const hasExpectedTurns =
|
|
||||||
expectedTurnCount == null ||
|
|
||||||
detail.turns.length >= expectedTurnCount
|
|
||||||
const canShowTurnTokenStats = hasAssistantUsage(messages)
|
|
||||||
const canShowSessionTokenStats = hasTokenStats(stats)
|
|
||||||
if (
|
|
||||||
hasExpectedTurns &&
|
|
||||||
(canShowTurnTokenStats || canShowSessionTokenStats)
|
|
||||||
) {
|
|
||||||
setHistory(messages)
|
|
||||||
if (canShowSessionTokenStats) {
|
|
||||||
applySessionStats(stats)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore transient read failures while session file is syncing.
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt < maxAttempts - 1) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, retryDelayMs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (refreshSeq !== statsRefreshSeqRef.current) return
|
|
||||||
if (latestMessages) {
|
|
||||||
setHistory(latestMessages)
|
|
||||||
}
|
|
||||||
if (latestStats && hasTokenStats(latestStats)) {
|
|
||||||
applySessionStats(latestStats)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[applySessionStats, hasAssistantUsage, hasTokenStats, sharedT]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
statsRefreshSeqRef.current += 1
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const [phase, setPhase] = useState<"welcome" | "conversation">("welcome")
|
|
||||||
const [selectedAgent, setSelectedAgent] = useState<AgentType>(
|
|
||||||
defaultAgentType ?? "codex"
|
|
||||||
)
|
|
||||||
const [history, setHistory] = useState<AdaptedMessage[]>([])
|
|
||||||
const historyRef = useRef<AdaptedMessage[]>([])
|
|
||||||
useEffect(() => {
|
|
||||||
historyRef.current = history
|
|
||||||
}, [history])
|
|
||||||
const historicalPlanEntries = useMemo(
|
|
||||||
() => extractLatestPlanEntriesFromMessages(history),
|
|
||||||
[history]
|
|
||||||
)
|
|
||||||
const historicalPlanKey = useMemo(
|
|
||||||
() => buildPlanKey(historicalPlanEntries),
|
|
||||||
[historicalPlanEntries]
|
|
||||||
)
|
|
||||||
const [modeId, setModeId] = useState<string | null>(null)
|
|
||||||
const [dbConversationId, setDbConversationId] = useState<number | null>(null)
|
|
||||||
const [agentsLoaded, setAgentsLoaded] = useState(false)
|
|
||||||
const [usableAgentCount, setUsableAgentCount] = useState(0)
|
|
||||||
const [agentConnectError, setAgentConnectError] = useState<string | null>(
|
|
||||||
null
|
|
||||||
)
|
|
||||||
const canAutoConnect = agentsLoaded && usableAgentCount > 0
|
|
||||||
const pendingPromptRef = useRef<{
|
|
||||||
draft: PromptDraft
|
|
||||||
modeId: string | null
|
|
||||||
} | null>(null)
|
|
||||||
const newConversationDraftStorageKey = useMemo(
|
|
||||||
() =>
|
|
||||||
buildNewConversationDraftStorageKey({
|
|
||||||
folderId,
|
|
||||||
}),
|
|
||||||
[folderId]
|
|
||||||
)
|
|
||||||
const activeDraftStorageKey = useMemo(() => {
|
|
||||||
if (dbConversationId != null) {
|
|
||||||
return buildConversationDraftStorageKey(selectedAgent, dbConversationId)
|
|
||||||
}
|
|
||||||
return newConversationDraftStorageKey
|
|
||||||
}, [dbConversationId, newConversationDraftStorageKey, selectedAgent])
|
|
||||||
|
|
||||||
// DB persistence state
|
|
||||||
const dbConvIdRef = useRef<number | null>(null)
|
|
||||||
const statusUpdatedRef = useRef(false)
|
|
||||||
const tabPromotedRef = useRef(false)
|
|
||||||
const tabIdRef = useRef(tabId)
|
|
||||||
const selectedAgentRef = useRef(selectedAgent)
|
|
||||||
const convTitleRef = useRef<string | null>(null)
|
|
||||||
useEffect(() => {
|
|
||||||
tabIdRef.current = tabId
|
|
||||||
}, [tabId])
|
|
||||||
useEffect(() => {
|
|
||||||
selectedAgentRef.current = selectedAgent
|
|
||||||
}, [selectedAgent])
|
|
||||||
|
|
||||||
const {
|
|
||||||
conn,
|
|
||||||
modeLoading,
|
|
||||||
configOptionsLoading,
|
|
||||||
autoConnectError,
|
|
||||||
handleFocus,
|
|
||||||
handleSend: lifecycleSend,
|
|
||||||
handleSetConfigOption,
|
|
||||||
handleCancel,
|
|
||||||
handleRespondPermission,
|
|
||||||
} = useConnectionLifecycle({
|
|
||||||
contextKey,
|
|
||||||
agentType: selectedAgent,
|
|
||||||
isActive: isActive && canAutoConnect,
|
|
||||||
workingDir,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Destructure stable callback + volatile status separately.
|
|
||||||
// conn.connect is stable (depends only on actions + contextKey).
|
|
||||||
// conn.status changes on state transitions (~5/turn), NOT on every
|
|
||||||
// streaming delta (hundreds/sec) — much cheaper than depending on `conn`.
|
|
||||||
const {
|
|
||||||
status: connStatus,
|
|
||||||
connect: connConnect,
|
|
||||||
disconnect: connDisconnect,
|
|
||||||
sessionId: connSessionId,
|
|
||||||
} = conn
|
|
||||||
const isConnecting =
|
|
||||||
connStatus === "connecting" || connStatus === "downloading"
|
|
||||||
const connectionModes = useMemo(
|
|
||||||
() => conn.modes?.available_modes ?? [],
|
|
||||||
[conn.modes?.available_modes]
|
|
||||||
)
|
|
||||||
const connectionConfigOptions = useMemo(
|
|
||||||
() => conn.configOptions ?? [],
|
|
||||||
[conn.configOptions]
|
|
||||||
)
|
|
||||||
const connectionCommands = useMemo(
|
|
||||||
() => conn.availableCommands ?? [],
|
|
||||||
[conn.availableCommands]
|
|
||||||
)
|
|
||||||
const selectedModeId = useMemo(() => {
|
|
||||||
if (connectionModes.length === 0) return null
|
|
||||||
if (modeId && connectionModes.some((mode) => mode.id === modeId)) {
|
|
||||||
return modeId
|
|
||||||
}
|
|
||||||
return conn.modes?.current_mode_id ?? connectionModes[0]?.id ?? null
|
|
||||||
}, [conn.modes?.current_mode_id, connectionModes, modeId])
|
|
||||||
|
|
||||||
// Persist the agent-assigned session ID as external_id once both
|
|
||||||
// the DB conversation ID and the ACP session ID are available.
|
|
||||||
const externalIdSavedRef = useRef(false)
|
|
||||||
const sessionIdRef = useRef<string | null>(null)
|
|
||||||
const refreshingCurrentAgentRef = useRef(false)
|
|
||||||
const agentStatusRefreshTimerRef = useRef<ReturnType<
|
|
||||||
typeof setTimeout
|
|
||||||
> | null>(null)
|
|
||||||
const phaseRef = useRef(phase)
|
|
||||||
const workingDirRef = useRef(workingDir)
|
|
||||||
const connStatusRef = useRef(connStatus)
|
|
||||||
const isConnectingRef = useRef(false)
|
|
||||||
const connConnectRef = useRef(connConnect)
|
|
||||||
const connDisconnectRef = useRef(connDisconnect)
|
|
||||||
useEffect(() => {
|
|
||||||
if (connSessionId) {
|
|
||||||
sessionIdRef.current = connSessionId
|
|
||||||
}
|
|
||||||
}, [connSessionId])
|
|
||||||
useEffect(() => {
|
|
||||||
phaseRef.current = phase
|
|
||||||
}, [phase])
|
|
||||||
useEffect(() => {
|
|
||||||
workingDirRef.current = workingDir
|
|
||||||
}, [workingDir])
|
|
||||||
useEffect(() => {
|
|
||||||
connStatusRef.current = connStatus
|
|
||||||
}, [connStatus])
|
|
||||||
useEffect(() => {
|
|
||||||
isConnectingRef.current = isConnecting
|
|
||||||
}, [isConnecting])
|
|
||||||
useEffect(() => {
|
|
||||||
connConnectRef.current = connConnect
|
|
||||||
}, [connConnect])
|
|
||||||
useEffect(() => {
|
|
||||||
connDisconnectRef.current = connDisconnect
|
|
||||||
}, [connDisconnect])
|
|
||||||
|
|
||||||
const trySaveExternalId = useCallback(() => {
|
|
||||||
if (
|
|
||||||
externalIdSavedRef.current ||
|
|
||||||
!dbConvIdRef.current ||
|
|
||||||
!sessionIdRef.current
|
|
||||||
)
|
|
||||||
return
|
|
||||||
externalIdSavedRef.current = true
|
|
||||||
updateConversationExternalId(
|
|
||||||
dbConvIdRef.current,
|
|
||||||
sessionIdRef.current
|
|
||||||
).catch((e: unknown) =>
|
|
||||||
console.error("[WelcomePanel] update external_id:", e)
|
|
||||||
)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Trigger when session ID arrives from ACP
|
|
||||||
useEffect(() => {
|
|
||||||
if (connSessionId) trySaveExternalId()
|
|
||||||
}, [connSessionId, trySaveExternalId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
let unlisten: (() => void) | null = null
|
|
||||||
|
|
||||||
const syncCurrentAgentStatus = async () => {
|
|
||||||
if (cancelled) return
|
|
||||||
if (phaseRef.current !== "welcome") return
|
|
||||||
const currentWorkingDir = workingDirRef.current
|
|
||||||
if (!currentWorkingDir) return
|
|
||||||
if (refreshingCurrentAgentRef.current) return
|
|
||||||
const currentConnStatus = connStatusRef.current
|
|
||||||
if (currentConnStatus === "prompting" || isConnectingRef.current) return
|
|
||||||
|
|
||||||
refreshingCurrentAgentRef.current = true
|
|
||||||
try {
|
|
||||||
setAgentConnectError(null)
|
|
||||||
if (currentConnStatus === "connected") {
|
|
||||||
await connDisconnectRef.current()
|
|
||||||
}
|
|
||||||
await connConnectRef.current(
|
|
||||||
selectedAgentRef.current,
|
|
||||||
currentWorkingDir,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
source: "auto_link",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (!cancelled) {
|
|
||||||
setAgentConnectError(null)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setAgentConnectError(normalizeErrorMessage(error))
|
|
||||||
}
|
|
||||||
if (!isExpectedAutoLinkError(error)) {
|
|
||||||
console.error("[WelcomePanel] refresh current agent status:", error)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
refreshingCurrentAgentRef.current = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void import("@tauri-apps/api/event")
|
|
||||||
.then(({ listen }) =>
|
|
||||||
listen<AgentsUpdatedEventPayload>(ACP_AGENTS_UPDATED_EVENT, (event) => {
|
|
||||||
if (cancelled) return
|
|
||||||
if (event.payload?.reason === "agent_reordered") return
|
|
||||||
const changedAgentType = event.payload?.agent_type
|
|
||||||
if (
|
|
||||||
changedAgentType &&
|
|
||||||
changedAgentType !== selectedAgentRef.current
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (agentStatusRefreshTimerRef.current) {
|
|
||||||
clearTimeout(agentStatusRefreshTimerRef.current)
|
|
||||||
}
|
|
||||||
agentStatusRefreshTimerRef.current = setTimeout(() => {
|
|
||||||
void syncCurrentAgentStatus()
|
|
||||||
}, 120)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.then((dispose) => {
|
|
||||||
if (cancelled) {
|
|
||||||
disposeTauriListener(dispose, "WelcomeInputPanel.agentsUpdated")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
unlisten = dispose
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Ignore when non-tauri runtime.
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
if (agentStatusRefreshTimerRef.current) {
|
|
||||||
clearTimeout(agentStatusRefreshTimerRef.current)
|
|
||||||
agentStatusRefreshTimerRef.current = null
|
|
||||||
}
|
|
||||||
disposeTauriListener(unlisten, "WelcomeInputPanel.agentsUpdated")
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const prevStatusRef = useRef(connStatus)
|
|
||||||
|
|
||||||
// Accumulate history when prompting completes
|
|
||||||
useEffect(() => {
|
|
||||||
const prev = prevStatusRef.current
|
|
||||||
prevStatusRef.current = connStatus
|
|
||||||
|
|
||||||
if (prev === "prompting" && connStatus !== "prompting") {
|
|
||||||
if (conn.liveMessage && conn.liveMessage.content.length > 0) {
|
|
||||||
const adapted = adaptLiveMessageFromAcp(conn.liveMessage, {
|
|
||||||
isLiveStreaming: false,
|
|
||||||
toolCallFailedText: sharedT("toolCallFailed"),
|
|
||||||
planUpdatedText: sharedT("planUpdated"),
|
|
||||||
})
|
|
||||||
|
|
||||||
setHistory((h) => [...h, adapted])
|
|
||||||
}
|
|
||||||
// Agent turn ended — mark as pending_review unless it's a terminal state
|
|
||||||
if (
|
|
||||||
dbConvIdRef.current &&
|
|
||||||
connStatus !== "disconnected" &&
|
|
||||||
connStatus !== "error"
|
|
||||||
) {
|
|
||||||
updateConversationStatus(dbConvIdRef.current, "pending_review")
|
|
||||||
.then(() => refreshConversations())
|
|
||||||
.catch((e: unknown) =>
|
|
||||||
console.error("[WelcomePanel] update status:", e)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
void refreshConversationFromDb(
|
|
||||||
historyRef.current.length + (conn.liveMessage ? 1 : 0)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- conn.liveMessage, lifecycleSend intentionally omitted: effect only fires on status transitions
|
|
||||||
}, [connStatus, refreshConversations, refreshConversationFromDb, sharedT])
|
|
||||||
|
|
||||||
// When connection becomes "connected" and we have a pending prompt, send it
|
|
||||||
useEffect(() => {
|
|
||||||
if (connStatus === "connected" && pendingPromptRef.current) {
|
|
||||||
const pending = pendingPromptRef.current
|
|
||||||
pendingPromptRef.current = null
|
|
||||||
lifecycleSend(pending.draft, pending.modeId)
|
|
||||||
}
|
|
||||||
}, [connStatus, lifecycleSend])
|
|
||||||
|
|
||||||
// Promote tab helper — call once when conversation ends or component unmounts
|
|
||||||
const promoteTab = useCallback(() => {
|
|
||||||
if (tabPromotedRef.current || !dbConvIdRef.current) return
|
|
||||||
tabPromotedRef.current = true
|
|
||||||
const tid = tabIdRef.current
|
|
||||||
const convId = dbConvIdRef.current
|
|
||||||
const agent = selectedAgentRef.current
|
|
||||||
const title = convTitleRef.current || tabT("untitledConversation")
|
|
||||||
const canonicalContextKey = `conv-${agent}-${convId}`
|
|
||||||
|
|
||||||
// Keep in-flight stream/state attached when this new-conversation view
|
|
||||||
// is closed and later reopened as a canonical conversation tab.
|
|
||||||
migrateContextKey(contextKey, canonicalContextKey)
|
|
||||||
|
|
||||||
if (tid) {
|
|
||||||
promoteNewConversationTab(tid, convId, agent, title)
|
|
||||||
}
|
|
||||||
refreshConversations()
|
|
||||||
}, [
|
|
||||||
promoteNewConversationTab,
|
|
||||||
refreshConversations,
|
|
||||||
migrateContextKey,
|
|
||||||
contextKey,
|
|
||||||
tabT,
|
|
||||||
])
|
|
||||||
|
|
||||||
// Update conversation status on disconnect/error + promote tab
|
|
||||||
useEffect(() => {
|
|
||||||
if (!dbConvIdRef.current || statusUpdatedRef.current) return
|
|
||||||
if (connStatus === "disconnected") {
|
|
||||||
statusUpdatedRef.current = true
|
|
||||||
updateConversationStatus(dbConvIdRef.current, "completed").catch((e) =>
|
|
||||||
console.error("[WelcomePanel] update status:", e)
|
|
||||||
)
|
|
||||||
promoteTab()
|
|
||||||
} else if (connStatus === "error") {
|
|
||||||
statusUpdatedRef.current = true
|
|
||||||
updateConversationStatus(dbConvIdRef.current, "cancelled").catch((e) =>
|
|
||||||
console.error("[WelcomePanel] update status:", e)
|
|
||||||
)
|
|
||||||
promoteTab()
|
|
||||||
}
|
|
||||||
}, [connStatus, promoteTab])
|
|
||||||
|
|
||||||
// Promote tab on unmount if not yet promoted (e.g. user closes tab)
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
promoteTab()
|
|
||||||
}
|
|
||||||
}, [promoteTab])
|
|
||||||
|
|
||||||
const handleAgentSelect = useCallback(
|
|
||||||
(agentType: AgentType) => {
|
|
||||||
if (agentType === selectedAgent) return
|
|
||||||
setSelectedAgent(agentType)
|
|
||||||
setModeId(null)
|
|
||||||
setAgentConnectError(null)
|
|
||||||
connDisconnect()
|
|
||||||
.catch((e) => console.error("[WelcomePanel] disconnect old agent:", e))
|
|
||||||
.finally(() => {
|
|
||||||
connConnect(agentType, workingDir, undefined, {
|
|
||||||
source: "auto_link",
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
setAgentConnectError(null)
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
setAgentConnectError(normalizeErrorMessage(e))
|
|
||||||
if (!isExpectedAutoLinkError(e)) {
|
|
||||||
console.error("[WelcomePanel] switch agent:", e)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[selectedAgent, connConnect, connDisconnect, workingDir]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Welcome phase: submit first message.
|
|
||||||
const handleWelcomeSend = useCallback(
|
|
||||||
(draft: PromptDraft, selectedModeId?: string | null) => {
|
|
||||||
const displayText = getPromptDraftDisplayText(
|
|
||||||
draft,
|
|
||||||
sharedT("attachedResources")
|
|
||||||
)
|
|
||||||
const userMsg: AdaptedMessage = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
role: "user",
|
|
||||||
content: buildUserMessageTextPartsFromDraft(
|
|
||||||
draft,
|
|
||||||
sharedT("attachedResources")
|
|
||||||
),
|
|
||||||
userImages: extractUserImagesFromDraft(draft),
|
|
||||||
userResources: extractUserResourcesFromDraft(draft),
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
setHistory([userMsg])
|
|
||||||
setPhase("conversation")
|
|
||||||
applySessionStats(null)
|
|
||||||
statsRefreshSeqRef.current += 1
|
|
||||||
|
|
||||||
// If already connected, send directly; otherwise queue for when connected
|
|
||||||
if (connStatus === "connected") {
|
|
||||||
lifecycleSend(draft, selectedModeId)
|
|
||||||
} else {
|
|
||||||
pendingPromptRef.current = {
|
|
||||||
draft,
|
|
||||||
modeId: selectedModeId ?? null,
|
|
||||||
}
|
|
||||||
// Ensure connection is being established
|
|
||||||
if (
|
|
||||||
!connStatus ||
|
|
||||||
connStatus === "disconnected" ||
|
|
||||||
connStatus === "error"
|
|
||||||
) {
|
|
||||||
connConnect(selectedAgent, workingDir, undefined, {
|
|
||||||
source: "auto_link",
|
|
||||||
}).catch((e) => {
|
|
||||||
setAgentConnectError(normalizeErrorMessage(e))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DB persistence: create conversation
|
|
||||||
const title = displayText.slice(0, 80)
|
|
||||||
convTitleRef.current = title
|
|
||||||
createConversation(folderId, selectedAgent, title)
|
|
||||||
.then((convId) => {
|
|
||||||
dbConvIdRef.current = convId
|
|
||||||
setDbConversationId(convId)
|
|
||||||
moveMessageInputDraft(
|
|
||||||
newConversationDraftStorageKey,
|
|
||||||
buildConversationDraftStorageKey(selectedAgent, convId)
|
|
||||||
)
|
|
||||||
// Link tab to DB conversation so status dot updates and tab is persisted
|
|
||||||
if (tabIdRef.current) {
|
|
||||||
linkTabConversation(tabIdRef.current, convId, selectedAgent, title)
|
|
||||||
}
|
|
||||||
// If ACP session ID already arrived, save external_id now
|
|
||||||
trySaveExternalId()
|
|
||||||
refreshConversations()
|
|
||||||
})
|
|
||||||
.catch((e: unknown) =>
|
|
||||||
console.error("[WelcomePanel] create conversation:", e)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[
|
|
||||||
selectedAgent,
|
|
||||||
workingDir,
|
|
||||||
connStatus,
|
|
||||||
connConnect,
|
|
||||||
lifecycleSend,
|
|
||||||
folderId,
|
|
||||||
refreshConversations,
|
|
||||||
linkTabConversation,
|
|
||||||
trySaveExternalId,
|
|
||||||
applySessionStats,
|
|
||||||
newConversationDraftStorageKey,
|
|
||||||
sharedT,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Conversation phase: prepend user message to history before sending
|
|
||||||
const handleSendWithHistory = useCallback(
|
|
||||||
(draft: PromptDraft, selectedModeId?: string | null) => {
|
|
||||||
const userMsg: AdaptedMessage = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
role: "user",
|
|
||||||
content: buildUserMessageTextPartsFromDraft(
|
|
||||||
draft,
|
|
||||||
sharedT("attachedResources")
|
|
||||||
),
|
|
||||||
userImages: extractUserImagesFromDraft(draft),
|
|
||||||
userResources: extractUserResourcesFromDraft(draft),
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
setHistory((h) => [...h, userMsg])
|
|
||||||
lifecycleSend(draft, selectedModeId)
|
|
||||||
|
|
||||||
// Update status
|
|
||||||
if (dbConvIdRef.current) {
|
|
||||||
updateConversationStatus(dbConvIdRef.current, "in_progress")
|
|
||||||
.then(() => refreshConversations())
|
|
||||||
.catch((e: unknown) =>
|
|
||||||
console.error("[WelcomePanel] update status:", e)
|
|
||||||
)
|
|
||||||
statusUpdatedRef.current = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[lifecycleSend, refreshConversations, sharedT]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleOpenAgentsSettings = useCallback(() => {
|
|
||||||
openSettingsWindow("agents", { agentType: selectedAgent }).catch((err) => {
|
|
||||||
console.error("[WelcomePanel] failed to open settings window:", err)
|
|
||||||
})
|
|
||||||
}, [selectedAgent])
|
|
||||||
|
|
||||||
const buildAutoConnectErrorMessage = useCallback(
|
|
||||||
(raw: string) =>
|
|
||||||
buildInlineAutoConnectErrorMessage(raw, {
|
|
||||||
fallback: t("autoConnectFallback"),
|
|
||||||
append: (message) =>
|
|
||||||
t("autoConnectAppend", {
|
|
||||||
message,
|
|
||||||
path: t("agentsSettingsPath"),
|
|
||||||
}),
|
|
||||||
alreadyContainsPath: (message) =>
|
|
||||||
[t("agentsSettingsPath"), "Settings > Agents"].some((path) =>
|
|
||||||
message.includes(path)
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
[t]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Track live message visibility across turn completion.
|
|
||||||
// Hooks must be called before any conditional returns.
|
|
||||||
const prevConnStatusForLiveRef = useRef(connStatus)
|
|
||||||
const showLiveTransitionRef = useRef(false)
|
|
||||||
const prevHistoryLenRef = useRef(history.length)
|
|
||||||
|
|
||||||
if (connStatus === "prompting") {
|
|
||||||
showLiveTransitionRef.current = false
|
|
||||||
} else if (prevConnStatusForLiveRef.current === "prompting") {
|
|
||||||
showLiveTransitionRef.current = true
|
|
||||||
}
|
|
||||||
prevConnStatusForLiveRef.current = connStatus
|
|
||||||
|
|
||||||
// Once the effect adds the adapted message to history, hide the live block.
|
|
||||||
if (
|
|
||||||
history.length > prevHistoryLenRef.current &&
|
|
||||||
showLiveTransitionRef.current
|
|
||||||
) {
|
|
||||||
showLiveTransitionRef.current = false
|
|
||||||
}
|
|
||||||
prevHistoryLenRef.current = history.length
|
|
||||||
|
|
||||||
const showLive = Boolean(
|
|
||||||
conn.liveMessage &&
|
|
||||||
(connStatus === "prompting" ||
|
|
||||||
(conn.liveMessage.content.length > 0 && showLiveTransitionRef.current))
|
|
||||||
)
|
|
||||||
|
|
||||||
const threadItems = useMemo<WelcomeThreadItem[]>(() => {
|
|
||||||
const items: WelcomeThreadItem[] = history.map((message) => ({
|
|
||||||
key: `history-${message.id}`,
|
|
||||||
kind: "history",
|
|
||||||
message,
|
|
||||||
}))
|
|
||||||
if (showLive && conn.liveMessage) {
|
|
||||||
items.push({
|
|
||||||
key: `live-${conn.liveMessage.id}`,
|
|
||||||
kind: "live",
|
|
||||||
message: conn.liveMessage,
|
|
||||||
isStreaming: connStatus === "prompting",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}, [history, showLive, conn.liveMessage, connStatus])
|
|
||||||
|
|
||||||
const renderThreadItem = useCallback((item: WelcomeThreadItem) => {
|
|
||||||
if (item.kind === "live") {
|
|
||||||
return (
|
|
||||||
<LiveMessageBlock
|
|
||||||
message={item.message}
|
|
||||||
isStreaming={item.isStreaming}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return <WelcomeHistoryMessage message={item.message} />
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// ── Welcome phase ──
|
|
||||||
if (phase === "welcome") {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full px-4">
|
|
||||||
<div className="w-full max-w-2xl space-y-6">
|
|
||||||
<AgentSelector
|
|
||||||
defaultAgentType={selectedAgent}
|
|
||||||
onSelect={handleAgentSelect}
|
|
||||||
onAgentsLoaded={(agents) => {
|
|
||||||
setAgentsLoaded(true)
|
|
||||||
setUsableAgentCount(
|
|
||||||
agents.filter((agent) => agent.enabled && agent.available)
|
|
||||||
.length
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
onOpenAgentsSettings={handleOpenAgentsSettings}
|
|
||||||
disabled={isConnecting}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{autoConnectError || agentConnectError ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleOpenAgentsSettings}
|
|
||||||
className="w-full cursor-pointer rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-center text-xs text-destructive transition-colors hover:bg-destructive/10"
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
const inlineMessage = buildAutoConnectErrorMessage(
|
|
||||||
autoConnectError ?? agentConnectError ?? ""
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="overflow-hidden text-ellipsis whitespace-nowrap text-center"
|
|
||||||
title={inlineMessage}
|
|
||||||
>
|
|
||||||
{inlineMessage}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<MessageInput
|
|
||||||
key={newConversationDraftStorageKey}
|
|
||||||
onSend={handleWelcomeSend}
|
|
||||||
promptCapabilities={conn.promptCapabilities}
|
|
||||||
defaultPath={workingDir}
|
|
||||||
placeholder={
|
|
||||||
agentsLoaded && usableAgentCount === 0
|
|
||||||
? t("enableAgentFirstPlaceholder")
|
|
||||||
: t("askAnythingPlaceholder")
|
|
||||||
}
|
|
||||||
autoFocus
|
|
||||||
attachmentTabId={tabId ?? null}
|
|
||||||
modes={connectionModes}
|
|
||||||
configOptions={connectionConfigOptions}
|
|
||||||
modeLoading={modeLoading}
|
|
||||||
configOptionsLoading={configOptionsLoading}
|
|
||||||
selectedModeId={selectedModeId}
|
|
||||||
onModeChange={setModeId}
|
|
||||||
onConfigOptionChange={handleSetConfigOption}
|
|
||||||
availableCommands={connectionCommands}
|
|
||||||
disabled={!canAutoConnect || isConnecting}
|
|
||||||
className="min-h-28 max-h-60"
|
|
||||||
draftStorageKey={newConversationDraftStorageKey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConversationShell
|
|
||||||
status={connStatus}
|
|
||||||
promptCapabilities={conn.promptCapabilities}
|
|
||||||
defaultPath={workingDir}
|
|
||||||
error={conn.error}
|
|
||||||
pendingPermission={conn.pendingPermission}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onSend={handleSendWithHistory}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
onRespondPermission={handleRespondPermission}
|
|
||||||
modes={connectionModes}
|
|
||||||
configOptions={connectionConfigOptions}
|
|
||||||
modeLoading={modeLoading}
|
|
||||||
configOptionsLoading={configOptionsLoading}
|
|
||||||
selectedModeId={selectedModeId}
|
|
||||||
onModeChange={setModeId}
|
|
||||||
onConfigOptionChange={handleSetConfigOption}
|
|
||||||
availableCommands={connectionCommands}
|
|
||||||
attachmentTabId={tabId ?? null}
|
|
||||||
draftStorageKey={activeDraftStorageKey}
|
|
||||||
>
|
|
||||||
<div className="relative flex flex-col h-full">
|
|
||||||
<MessageThread className="flex-1 min-h-0">
|
|
||||||
<VirtualizedMessageThread
|
|
||||||
items={threadItems}
|
|
||||||
getItemKey={(item) => item.key}
|
|
||||||
renderItem={renderThreadItem}
|
|
||||||
estimateSize={180}
|
|
||||||
overscan={10}
|
|
||||||
/>
|
|
||||||
</MessageThread>
|
|
||||||
{showLive && connStatus === "prompting" && (
|
|
||||||
<LiveTurnStats
|
|
||||||
message={conn.liveMessage!}
|
|
||||||
isStreaming={connStatus === "prompting"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<AgentPlanOverlay
|
|
||||||
message={conn.liveMessage}
|
|
||||||
entries={historicalPlanEntries}
|
|
||||||
planKey={historicalPlanKey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ConversationShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -10,20 +10,36 @@ import { useTabContext } from "@/contexts/tab-context"
|
|||||||
import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle"
|
import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle"
|
||||||
import { MessageListView } from "@/components/message/message-list-view"
|
import { MessageListView } from "@/components/message/message-list-view"
|
||||||
import { ConversationShell } from "@/components/chat/conversation-shell"
|
import { ConversationShell } from "@/components/chat/conversation-shell"
|
||||||
import { WelcomeInputPanel } from "@/components/chat/welcome-input-panel"
|
import { AgentSelector } from "@/components/chat/agent-selector"
|
||||||
import { updateConversationStatus } from "@/lib/tauri"
|
|
||||||
import {
|
import {
|
||||||
|
createConversation,
|
||||||
|
openSettingsWindow,
|
||||||
|
updateConversationExternalId,
|
||||||
|
updateConversationStatus,
|
||||||
|
} from "@/lib/tauri"
|
||||||
|
import { useConversationRuntime } from "@/contexts/conversation-runtime-context"
|
||||||
|
import {
|
||||||
|
invalidateDetailCache,
|
||||||
|
refreshDetailCache,
|
||||||
useDbMessageDetail,
|
useDbMessageDetail,
|
||||||
warmupDetailCache,
|
|
||||||
} from "@/hooks/use-db-message-detail"
|
} from "@/hooks/use-db-message-detail"
|
||||||
import type { AcpEvent, AgentType, PromptDraft } from "@/lib/types"
|
|
||||||
import type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter"
|
|
||||||
import {
|
import {
|
||||||
buildUserMessageTextPartsFromDraft,
|
|
||||||
extractUserImagesFromDraft,
|
extractUserImagesFromDraft,
|
||||||
extractUserResourcesFromDraft,
|
extractUserResourcesFromDraft,
|
||||||
|
getPromptDraftDisplayText,
|
||||||
} from "@/lib/prompt-draft"
|
} from "@/lib/prompt-draft"
|
||||||
import { buildConversationDraftStorageKey } from "@/lib/message-input-draft"
|
import type {
|
||||||
|
AcpEvent,
|
||||||
|
AgentType,
|
||||||
|
ContentBlock,
|
||||||
|
MessageTurn,
|
||||||
|
PromptDraft,
|
||||||
|
} from "@/lib/types"
|
||||||
|
import {
|
||||||
|
buildConversationDraftStorageKey,
|
||||||
|
buildNewConversationDraftStorageKey,
|
||||||
|
moveMessageInputDraft,
|
||||||
|
} from "@/lib/message-input-draft"
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@@ -32,62 +48,184 @@ import {
|
|||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu"
|
} from "@/components/ui/context-menu"
|
||||||
|
|
||||||
interface ExistingConversationViewProps {
|
interface ConversationTabViewProps {
|
||||||
tabId: string
|
tabId: string
|
||||||
conversationId: number
|
conversationId: number | null
|
||||||
agentType: AgentType
|
agentType: AgentType
|
||||||
|
workingDir?: string
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
reloadSignal: number
|
reloadSignal: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const ExistingConversationView = memo(function ExistingConversationView({
|
function buildOptimisticUserTurnFromDraft(
|
||||||
|
draft: PromptDraft,
|
||||||
|
attachedResourcesFallback: string
|
||||||
|
): MessageTurn {
|
||||||
|
const displayText = getPromptDraftDisplayText(
|
||||||
|
draft,
|
||||||
|
attachedResourcesFallback
|
||||||
|
)
|
||||||
|
const resources = extractUserResourcesFromDraft(draft)
|
||||||
|
const resourceLines = resources.map((resource) => {
|
||||||
|
const label = resource.uri.toLowerCase().startsWith("file://")
|
||||||
|
? resource.name
|
||||||
|
: `@${resource.name}`
|
||||||
|
return `[${label}](${resource.uri})`
|
||||||
|
})
|
||||||
|
const text = [displayText, ...resourceLines].join("\n").trim()
|
||||||
|
|
||||||
|
const blocks: ContentBlock[] = []
|
||||||
|
for (const image of extractUserImagesFromDraft(draft)) {
|
||||||
|
blocks.push({
|
||||||
|
type: "image",
|
||||||
|
data: image.data,
|
||||||
|
mime_type: image.mime_type,
|
||||||
|
uri: image.uri ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
blocks.push({ type: "text", text })
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `optimistic-${crypto.randomUUID()}`,
|
||||||
|
role: "user",
|
||||||
|
blocks,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) return error.message
|
||||||
|
return String(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpectedAutoLinkError(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== "object") return false
|
||||||
|
return (error as { alerted?: unknown }).alerted === true
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVirtualConversationId(seed: string): number {
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < seed.length; i += 1) {
|
||||||
|
hash = (hash * 31 + seed.charCodeAt(i)) | 0
|
||||||
|
}
|
||||||
|
const normalized = Math.abs(hash) + 1
|
||||||
|
return -normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConversationTabView = memo(function ConversationTabView({
|
||||||
tabId,
|
tabId,
|
||||||
conversationId,
|
conversationId,
|
||||||
agentType,
|
agentType,
|
||||||
|
workingDir,
|
||||||
isActive,
|
isActive,
|
||||||
reloadSignal,
|
reloadSignal,
|
||||||
}: ExistingConversationViewProps) {
|
}: ConversationTabViewProps) {
|
||||||
const t = useTranslations("Folder.conversation")
|
const t = useTranslations("Folder.conversation")
|
||||||
|
const tWelcome = useTranslations("Folder.chat.welcomeInputPanel")
|
||||||
const sharedT = useTranslations("Folder.chat.shared")
|
const sharedT = useTranslations("Folder.chat.shared")
|
||||||
const { refreshConversations, folder } = useFolderContext()
|
const { folder, folderId, refreshConversations } = useFolderContext()
|
||||||
const contextKey = `conv-${agentType}-${conversationId}`
|
const { bindConversationTab } = useTabContext()
|
||||||
|
|
||||||
// Get external_id to resume existing agent session via LoadSessionRequest.
|
|
||||||
// Gate workingDir on loading so auto-connect waits for sessionId to resolve.
|
|
||||||
const {
|
const {
|
||||||
detail,
|
acknowledgePersistedDetail,
|
||||||
loading: detailLoading,
|
appendOptimisticTurn,
|
||||||
error: detailError,
|
migrateConversation,
|
||||||
refetch: refetchConversationDetail,
|
setExternalId,
|
||||||
} = useDbMessageDetail(conversationId)
|
setLiveMessage,
|
||||||
const externalId = detail?.summary.external_id ?? undefined
|
setSyncState,
|
||||||
|
} = useConversationRuntime()
|
||||||
|
|
||||||
|
const temporaryConversationId = useMemo(
|
||||||
|
() => buildVirtualConversationId(`draft-${tabId}`),
|
||||||
|
[tabId]
|
||||||
|
)
|
||||||
|
const [createdConversationId, setCreatedConversationId] = useState<
|
||||||
|
number | null
|
||||||
|
>(null)
|
||||||
|
const dbConversationId = conversationId ?? createdConversationId
|
||||||
|
const [draftAgentType, setDraftAgentType] = useState<AgentType>(agentType)
|
||||||
|
const selectedAgent = conversationId != null ? agentType : draftAgentType
|
||||||
|
const [modeId, setModeId] = useState<string | null>(null)
|
||||||
|
const [agentsLoaded, setAgentsLoaded] = useState(false)
|
||||||
|
const [usableAgentCount, setUsableAgentCount] = useState(0)
|
||||||
|
const [agentConnectError, setAgentConnectError] = useState<string | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasPersistedConversation = dbConversationId != null
|
||||||
|
const canAutoConnect =
|
||||||
|
hasPersistedConversation || (agentsLoaded && usableAgentCount > 0)
|
||||||
|
const effectiveConversationId = dbConversationId ?? temporaryConversationId
|
||||||
|
|
||||||
const latestReloadSignal = useRef(reloadSignal)
|
const latestReloadSignal = useRef(reloadSignal)
|
||||||
const pendingReloadState = useRef<{
|
const pendingReloadState = useRef<{
|
||||||
signal: number
|
signal: number
|
||||||
sawLoading: boolean
|
sawLoading: boolean
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
const dbConvIdRef = useRef<number | null>(conversationId)
|
||||||
|
const statusUpdatedRef = useRef(false)
|
||||||
|
const selectedAgentRef = useRef(selectedAgent)
|
||||||
|
const pendingPromptRef = useRef<{
|
||||||
|
draft: PromptDraft
|
||||||
|
modeId: string | null
|
||||||
|
} | null>(null)
|
||||||
|
const createConversationPendingRef = useRef(false)
|
||||||
|
const reconcileTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const externalIdSavedRef = useRef(false)
|
||||||
|
const sessionIdRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dbConvIdRef.current = dbConversationId
|
||||||
|
}, [dbConversationId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedAgentRef.current = selectedAgent
|
||||||
|
}, [selectedAgent])
|
||||||
|
|
||||||
|
const {
|
||||||
|
detail,
|
||||||
|
loading: detailLoading,
|
||||||
|
error: detailError,
|
||||||
|
refetch: refetchConversationDetail,
|
||||||
|
} = useDbMessageDetail(effectiveConversationId)
|
||||||
|
const externalId = detail?.summary.external_id ?? undefined
|
||||||
|
const draftStorageKey = useMemo(() => {
|
||||||
|
if (dbConversationId != null) {
|
||||||
|
return buildConversationDraftStorageKey(selectedAgent, dbConversationId)
|
||||||
|
}
|
||||||
|
return buildNewConversationDraftStorageKey({ folderId })
|
||||||
|
}, [dbConversationId, folderId, selectedAgent])
|
||||||
|
const workingDirForConnection = useMemo(() => {
|
||||||
|
if (dbConversationId != null) {
|
||||||
|
return detailLoading ? undefined : folder?.path
|
||||||
|
}
|
||||||
|
return workingDir ?? folder?.path
|
||||||
|
}, [dbConversationId, detailLoading, folder?.path, workingDir])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
conn,
|
conn,
|
||||||
modeLoading,
|
modeLoading,
|
||||||
configOptionsLoading,
|
configOptionsLoading,
|
||||||
|
autoConnectError,
|
||||||
handleFocus,
|
handleFocus,
|
||||||
handleSend,
|
handleSend: lifecycleSend,
|
||||||
handleSetConfigOption,
|
handleSetConfigOption,
|
||||||
handleCancel,
|
handleCancel,
|
||||||
handleRespondPermission,
|
handleRespondPermission,
|
||||||
} = useConnectionLifecycle({
|
} = useConnectionLifecycle({
|
||||||
contextKey,
|
contextKey: tabId,
|
||||||
agentType,
|
agentType: selectedAgent,
|
||||||
isActive,
|
isActive: isActive && canAutoConnect,
|
||||||
workingDir: detailLoading ? undefined : folder?.path,
|
workingDir: workingDirForConnection,
|
||||||
sessionId: externalId,
|
sessionId: dbConversationId != null ? externalId : undefined,
|
||||||
})
|
})
|
||||||
|
const {
|
||||||
const [pendingMessages, setPendingMessages] = useState<AdaptedMessage[]>([])
|
status: connStatus,
|
||||||
const [modeId, setModeId] = useState<string | null>(null)
|
connect: connConnect,
|
||||||
const clearPending = useCallback(() => setPendingMessages([]), [])
|
disconnect: connDisconnect,
|
||||||
|
sessionId: connSessionId,
|
||||||
|
} = conn
|
||||||
|
const isConnecting =
|
||||||
|
connStatus === "connecting" || connStatus === "downloading"
|
||||||
const connectionModes = useMemo(
|
const connectionModes = useMemo(
|
||||||
() => conn.modes?.available_modes ?? [],
|
() => conn.modes?.available_modes ?? [],
|
||||||
[conn.modes?.available_modes]
|
[conn.modes?.available_modes]
|
||||||
@@ -108,79 +246,145 @@ const ExistingConversationView = memo(function ExistingConversationView({
|
|||||||
return conn.modes?.current_mode_id ?? connectionModes[0]?.id ?? null
|
return conn.modes?.current_mode_id ?? connectionModes[0]?.id ?? null
|
||||||
}, [conn.modes?.current_mode_id, connectionModes, modeId])
|
}, [conn.modes?.current_mode_id, connectionModes, modeId])
|
||||||
|
|
||||||
// Track status transitions for updating conversation metadata
|
const clearReconcileTimer = useCallback(() => {
|
||||||
const prevStatusRef = useRef(conn.status)
|
if (!reconcileTimerRef.current) return
|
||||||
const statusUpdatedRef = useRef(false)
|
clearTimeout(reconcileTimerRef.current)
|
||||||
|
reconcileTimerRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Wrap handleSend to update status
|
const refreshFromDb = useCallback(
|
||||||
const handleSendWithPersist = useCallback(
|
async (refreshConversationId: number) => {
|
||||||
(draft: PromptDraft, selectedModeId?: string | null) => {
|
try {
|
||||||
setPendingMessages([
|
const refreshed = await refreshDetailCache(refreshConversationId)
|
||||||
{
|
acknowledgePersistedDetail(refreshConversationId, refreshed)
|
||||||
id: `pending-${Date.now()}`,
|
} catch (error) {
|
||||||
role: "user",
|
setSyncState(refreshConversationId, "failed")
|
||||||
content: buildUserMessageTextPartsFromDraft(
|
console.error(
|
||||||
draft,
|
"[ConversationTabView] refresh detail cache failed:",
|
||||||
sharedT("attachedResources")
|
error
|
||||||
),
|
)
|
||||||
userImages: extractUserImagesFromDraft(draft),
|
}
|
||||||
userResources: extractUserResourcesFromDraft(draft),
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
},
|
||||||
])
|
[acknowledgePersistedDetail, setSyncState]
|
||||||
updateConversationStatus(conversationId, "in_progress")
|
|
||||||
.then(() => refreshConversations())
|
|
||||||
.catch((e) => console.error("[ExistingConv] update status:", e))
|
|
||||||
statusUpdatedRef.current = false
|
|
||||||
handleSend(draft, selectedModeId)
|
|
||||||
},
|
|
||||||
[conversationId, handleSend, refreshConversations, sharedT]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update status on turn complete
|
useEffect(() => {
|
||||||
|
if (connSessionId) {
|
||||||
|
sessionIdRef.current = connSessionId
|
||||||
|
}
|
||||||
|
}, [connSessionId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLiveMessage(effectiveConversationId, conn.liveMessage ?? null)
|
||||||
|
return () => {
|
||||||
|
setLiveMessage(effectiveConversationId, null)
|
||||||
|
}
|
||||||
|
}, [conn.liveMessage, effectiveConversationId, setLiveMessage])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dbConversationId) return
|
||||||
|
setExternalId(dbConversationId, detail?.summary.external_id ?? null)
|
||||||
|
}, [dbConversationId, detail?.summary.external_id, setExternalId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dbConversationId) return
|
||||||
|
if (!connSessionId) return
|
||||||
|
setExternalId(dbConversationId, connSessionId)
|
||||||
|
}, [connSessionId, dbConversationId, setExternalId])
|
||||||
|
|
||||||
|
const trySaveExternalId = useCallback(() => {
|
||||||
|
if (
|
||||||
|
externalIdSavedRef.current ||
|
||||||
|
!dbConvIdRef.current ||
|
||||||
|
!sessionIdRef.current
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
externalIdSavedRef.current = true
|
||||||
|
updateConversationExternalId(
|
||||||
|
dbConvIdRef.current,
|
||||||
|
sessionIdRef.current
|
||||||
|
).catch((e: unknown) =>
|
||||||
|
console.error("[ConversationTabView] update external_id:", e)
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (connSessionId) {
|
||||||
|
trySaveExternalId()
|
||||||
|
}
|
||||||
|
}, [connSessionId, trySaveExternalId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dbConversationId) return
|
||||||
|
if (!detail) return
|
||||||
|
if (connStatus === "prompting") return
|
||||||
|
acknowledgePersistedDetail(dbConversationId, detail)
|
||||||
|
}, [acknowledgePersistedDetail, connStatus, dbConversationId, detail])
|
||||||
|
|
||||||
|
const prevStatusRef = useRef(connStatus)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prev = prevStatusRef.current
|
const prev = prevStatusRef.current
|
||||||
prevStatusRef.current = conn.status
|
prevStatusRef.current = connStatus
|
||||||
|
if (prev !== "prompting" || connStatus === "prompting") return
|
||||||
|
|
||||||
if (prev === "prompting" && conn.status !== "prompting") {
|
setSyncState(effectiveConversationId, "reconciling")
|
||||||
// Mark as pending_review unless it's a terminal state
|
const persistedId = dbConvIdRef.current
|
||||||
if (conn.status !== "disconnected" && conn.status !== "error") {
|
if (!persistedId) return
|
||||||
updateConversationStatus(conversationId, "pending_review")
|
|
||||||
|
invalidateDetailCache(persistedId)
|
||||||
|
clearReconcileTimer()
|
||||||
|
reconcileTimerRef.current = setTimeout(() => {
|
||||||
|
void refreshFromDb(persistedId)
|
||||||
|
}, 1200)
|
||||||
|
|
||||||
|
if (connStatus !== "disconnected" && connStatus !== "error") {
|
||||||
|
updateConversationStatus(persistedId, "pending_review")
|
||||||
.then(() => refreshConversations())
|
.then(() => refreshConversations())
|
||||||
.catch((e: unknown) =>
|
.catch((e: unknown) =>
|
||||||
console.error("[ExistingConv] update status:", e)
|
console.error("[ConversationTabView] update status:", e)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}, [
|
||||||
}, [conn.status, conversationId, refreshConversations])
|
clearReconcileTimer,
|
||||||
|
connStatus,
|
||||||
|
effectiveConversationId,
|
||||||
|
refreshConversations,
|
||||||
|
refreshFromDb,
|
||||||
|
setSyncState,
|
||||||
|
])
|
||||||
|
|
||||||
// Update status on disconnect/error
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (conn.status === "connected" || conn.status === "prompting") {
|
if (connStatus === "connected" && pendingPromptRef.current) {
|
||||||
|
const pending = pendingPromptRef.current
|
||||||
|
pendingPromptRef.current = null
|
||||||
|
lifecycleSend(pending.draft, pending.modeId)
|
||||||
|
}
|
||||||
|
}, [connStatus, lifecycleSend])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (connStatus === "connected" || connStatus === "prompting") {
|
||||||
statusUpdatedRef.current = false
|
statusUpdatedRef.current = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (statusUpdatedRef.current) return
|
if (statusUpdatedRef.current) return
|
||||||
if (conn.status === "disconnected") {
|
const persistedId = dbConvIdRef.current
|
||||||
|
if (!persistedId) return
|
||||||
|
if (connStatus === "disconnected") {
|
||||||
statusUpdatedRef.current = true
|
statusUpdatedRef.current = true
|
||||||
updateConversationStatus(conversationId, "completed")
|
updateConversationStatus(persistedId, "completed")
|
||||||
.then(() => {
|
.then(() => refreshConversations())
|
||||||
setPendingMessages([])
|
.catch((e) => console.error("[ConversationTabView] update status:", e))
|
||||||
refreshConversations()
|
} else if (connStatus === "error") {
|
||||||
})
|
|
||||||
.catch((e) => console.error("[ExistingConv] update status:", e))
|
|
||||||
} else if (conn.status === "error") {
|
|
||||||
statusUpdatedRef.current = true
|
statusUpdatedRef.current = true
|
||||||
updateConversationStatus(conversationId, "cancelled")
|
updateConversationStatus(persistedId, "cancelled")
|
||||||
.then(() => {
|
.then(() => refreshConversations())
|
||||||
setPendingMessages([])
|
.catch((e) => console.error("[ConversationTabView] update status:", e))
|
||||||
refreshConversations()
|
|
||||||
})
|
|
||||||
.catch((e) => console.error("[ExistingConv] update status:", e))
|
|
||||||
}
|
}
|
||||||
}, [conn.status, conversationId, refreshConversations])
|
}, [connStatus, refreshConversations])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (dbConversationId == null) return
|
||||||
if (reloadSignal === latestReloadSignal.current) return
|
if (reloadSignal === latestReloadSignal.current) return
|
||||||
latestReloadSignal.current = reloadSignal
|
latestReloadSignal.current = reloadSignal
|
||||||
pendingReloadState.current = {
|
pendingReloadState.current = {
|
||||||
@@ -188,7 +392,7 @@ const ExistingConversationView = memo(function ExistingConversationView({
|
|||||||
sawLoading: false,
|
sawLoading: false,
|
||||||
}
|
}
|
||||||
refetchConversationDetail()
|
refetchConversationDetail()
|
||||||
}, [reloadSignal, refetchConversationDetail])
|
}, [dbConversationId, reloadSignal, refetchConversationDetail])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pending = pendingReloadState.current
|
const pending = pendingReloadState.current
|
||||||
@@ -211,15 +415,182 @@ const ExistingConversationView = memo(function ExistingConversationView({
|
|||||||
toast.success(t("reloaded"))
|
toast.success(t("reloaded"))
|
||||||
}, [detailLoading, detailError, t])
|
}, [detailLoading, detailError, t])
|
||||||
|
|
||||||
|
useEffect(() => clearReconcileTimer, [clearReconcileTimer])
|
||||||
|
|
||||||
|
const handleSend = useCallback(
|
||||||
|
(draft: PromptDraft, selectedModeIdArg?: string | null) => {
|
||||||
|
if (!hasPersistedConversation && !canAutoConnect) {
|
||||||
|
setAgentConnectError(tWelcome("enableAgentFirstPlaceholder"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const optimisticTurn = buildOptimisticUserTurnFromDraft(
|
||||||
|
draft,
|
||||||
|
sharedT("attachedResources")
|
||||||
|
)
|
||||||
|
appendOptimisticTurn(
|
||||||
|
effectiveConversationId,
|
||||||
|
optimisticTurn,
|
||||||
|
optimisticTurn.id
|
||||||
|
)
|
||||||
|
setSyncState(effectiveConversationId, "awaiting_persist")
|
||||||
|
|
||||||
|
if (connStatus === "connected") {
|
||||||
|
lifecycleSend(draft, selectedModeIdArg)
|
||||||
|
} else {
|
||||||
|
pendingPromptRef.current = {
|
||||||
|
draft,
|
||||||
|
modeId: selectedModeIdArg ?? null,
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(!connStatus ||
|
||||||
|
connStatus === "disconnected" ||
|
||||||
|
connStatus === "error") &&
|
||||||
|
workingDirForConnection
|
||||||
|
) {
|
||||||
|
connConnect(
|
||||||
|
selectedAgent,
|
||||||
|
workingDirForConnection,
|
||||||
|
dbConversationId != null ? externalId : undefined,
|
||||||
|
{
|
||||||
|
source: "auto_link",
|
||||||
|
}
|
||||||
|
).catch((e) => {
|
||||||
|
setAgentConnectError(normalizeErrorMessage(e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistedId = dbConvIdRef.current
|
||||||
|
if (persistedId) {
|
||||||
|
updateConversationStatus(persistedId, "in_progress")
|
||||||
|
.then(() => refreshConversations())
|
||||||
|
.catch((e: unknown) =>
|
||||||
|
console.error("[ConversationTabView] update status:", e)
|
||||||
|
)
|
||||||
|
statusUpdatedRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createConversationPendingRef.current) return
|
||||||
|
createConversationPendingRef.current = true
|
||||||
|
const title = getPromptDraftDisplayText(
|
||||||
|
draft,
|
||||||
|
sharedT("attachedResources")
|
||||||
|
).slice(0, 80)
|
||||||
|
createConversation(folderId, selectedAgent, title)
|
||||||
|
.then((newConversationId) => {
|
||||||
|
dbConvIdRef.current = newConversationId
|
||||||
|
setCreatedConversationId(newConversationId)
|
||||||
|
migrateConversation(temporaryConversationId, newConversationId)
|
||||||
|
setExternalId(newConversationId, sessionIdRef.current ?? null)
|
||||||
|
bindConversationTab(tabId, newConversationId, selectedAgent, title)
|
||||||
|
moveMessageInputDraft(
|
||||||
|
buildNewConversationDraftStorageKey({ folderId }),
|
||||||
|
buildConversationDraftStorageKey(selectedAgent, newConversationId)
|
||||||
|
)
|
||||||
|
trySaveExternalId()
|
||||||
|
statusUpdatedRef.current = false
|
||||||
|
updateConversationStatus(newConversationId, "in_progress")
|
||||||
|
.then(() => refreshConversations())
|
||||||
|
.catch((e: unknown) =>
|
||||||
|
console.error("[ConversationTabView] update status:", e)
|
||||||
|
)
|
||||||
|
void refreshFromDb(newConversationId)
|
||||||
|
})
|
||||||
|
.catch((e: unknown) =>
|
||||||
|
console.error("[ConversationTabView] create conversation:", e)
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
createConversationPendingRef.current = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[
|
||||||
|
appendOptimisticTurn,
|
||||||
|
bindConversationTab,
|
||||||
|
canAutoConnect,
|
||||||
|
connConnect,
|
||||||
|
connStatus,
|
||||||
|
dbConversationId,
|
||||||
|
effectiveConversationId,
|
||||||
|
externalId,
|
||||||
|
folderId,
|
||||||
|
hasPersistedConversation,
|
||||||
|
lifecycleSend,
|
||||||
|
migrateConversation,
|
||||||
|
refreshConversations,
|
||||||
|
refreshFromDb,
|
||||||
|
selectedAgent,
|
||||||
|
setExternalId,
|
||||||
|
setSyncState,
|
||||||
|
sharedT,
|
||||||
|
tWelcome,
|
||||||
|
tabId,
|
||||||
|
temporaryConversationId,
|
||||||
|
trySaveExternalId,
|
||||||
|
workingDirForConnection,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleOpenAgentsSettings = useCallback(() => {
|
||||||
|
openSettingsWindow("agents", { agentType: selectedAgent }).catch((err) => {
|
||||||
|
console.error(
|
||||||
|
"[ConversationTabView] failed to open settings window:",
|
||||||
|
err
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [selectedAgent])
|
||||||
|
|
||||||
|
const handleAgentSelect = useCallback(
|
||||||
|
(nextAgentType: AgentType) => {
|
||||||
|
if (nextAgentType === selectedAgentRef.current) return
|
||||||
|
if (dbConvIdRef.current) return
|
||||||
|
|
||||||
|
setDraftAgentType(nextAgentType)
|
||||||
|
setModeId(null)
|
||||||
|
setAgentConnectError(null)
|
||||||
|
connDisconnect()
|
||||||
|
.catch((e) =>
|
||||||
|
console.error("[ConversationTabView] disconnect old agent:", e)
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
if (!workingDirForConnection) return
|
||||||
|
connConnect(nextAgentType, workingDirForConnection, undefined, {
|
||||||
|
source: "auto_link",
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
setAgentConnectError(null)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setAgentConnectError(normalizeErrorMessage(e))
|
||||||
|
if (!isExpectedAutoLinkError(e)) {
|
||||||
|
console.error("[ConversationTabView] switch agent:", e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[connConnect, connDisconnect, workingDirForConnection]
|
||||||
|
)
|
||||||
|
|
||||||
|
const messageListNode = (
|
||||||
|
<MessageListView
|
||||||
|
conversationId={effectiveConversationId}
|
||||||
|
connStatus={connStatus}
|
||||||
|
isActive={isActive}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const showDraftHeader = !hasPersistedConversation
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConversationShell
|
<ConversationShell
|
||||||
status={conn.status}
|
status={connStatus}
|
||||||
promptCapabilities={conn.promptCapabilities}
|
promptCapabilities={conn.promptCapabilities}
|
||||||
defaultPath={folder?.path}
|
defaultPath={workingDirForConnection}
|
||||||
error={conn.error}
|
error={conn.error}
|
||||||
pendingPermission={conn.pendingPermission}
|
pendingPermission={conn.pendingPermission}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onSend={handleSendWithPersist}
|
onSend={handleSend}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onRespondPermission={handleRespondPermission}
|
onRespondPermission={handleRespondPermission}
|
||||||
modes={connectionModes}
|
modes={connectionModes}
|
||||||
@@ -231,41 +602,66 @@ const ExistingConversationView = memo(function ExistingConversationView({
|
|||||||
onConfigOptionChange={handleSetConfigOption}
|
onConfigOptionChange={handleSetConfigOption}
|
||||||
availableCommands={connectionCommands}
|
availableCommands={connectionCommands}
|
||||||
attachmentTabId={tabId}
|
attachmentTabId={tabId}
|
||||||
draftStorageKey={buildConversationDraftStorageKey(
|
draftStorageKey={draftStorageKey}
|
||||||
agentType,
|
|
||||||
conversationId
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<MessageListView
|
{showDraftHeader ? (
|
||||||
conversationId={conversationId}
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
liveMessage={conn.liveMessage}
|
<div className="px-4 pt-3 pb-2">
|
||||||
connStatus={conn.status}
|
<AgentSelector
|
||||||
pendingMessages={pendingMessages}
|
defaultAgentType={selectedAgent}
|
||||||
onPendingClear={clearPending}
|
onSelect={handleAgentSelect}
|
||||||
isActive={isActive}
|
onAgentsLoaded={(agents) => {
|
||||||
|
setAgentsLoaded(true)
|
||||||
|
setUsableAgentCount(
|
||||||
|
agents.filter((agent) => agent.enabled && agent.available)
|
||||||
|
.length
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
onOpenAgentsSettings={handleOpenAgentsSettings}
|
||||||
|
disabled={isConnecting || dbConversationId != null}
|
||||||
/>
|
/>
|
||||||
|
{autoConnectError || agentConnectError ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenAgentsSettings}
|
||||||
|
className="mt-2 w-full cursor-pointer rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-center text-xs text-destructive transition-colors hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="overflow-hidden text-ellipsis whitespace-nowrap text-center"
|
||||||
|
title={autoConnectError ?? agentConnectError ?? ""}
|
||||||
|
>
|
||||||
|
{autoConnectError ?? agentConnectError}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1">{messageListNode}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messageListNode
|
||||||
|
)}
|
||||||
</ConversationShell>
|
</ConversationShell>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export function ConversationDetailPanel() {
|
export function ConversationDetailPanel() {
|
||||||
const t = useTranslations("Folder.conversation")
|
const t = useTranslations("Folder.conversation")
|
||||||
|
const {
|
||||||
|
acknowledgePersistedDetail,
|
||||||
|
getConversationIdByExternalId,
|
||||||
|
setSyncState,
|
||||||
|
} = useConversationRuntime()
|
||||||
const { folder, newConversation, conversations, refreshConversations } =
|
const { folder, newConversation, conversations, refreshConversations } =
|
||||||
useFolderContext()
|
useFolderContext()
|
||||||
const { tabs, activeTabId, openNewConversationTab, closeTab } =
|
const { tabs, activeTabId, openNewConversationTab, closeTab } =
|
||||||
useTabContext()
|
useTabContext()
|
||||||
const [reloadByTabId, setReloadByTabId] = useState<Record<string, number>>({})
|
const [reloadByTabId, setReloadByTabId] = useState<Record<string, number>>({})
|
||||||
const tabsRef = useRef(tabs)
|
|
||||||
const conversationsRef = useRef(conversations)
|
const conversationsRef = useRef(conversations)
|
||||||
const pendingClosedConversationIdsRef = useRef<Set<number>>(new Set())
|
const pendingClosedConversationIdsRef = useRef<Set<number>>(new Set())
|
||||||
const pendingRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const pendingRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
tabsRef.current = tabs
|
|
||||||
}, [tabs])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
conversationsRef.current = conversations
|
conversationsRef.current = conversations
|
||||||
}, [conversations])
|
}, [conversations])
|
||||||
@@ -294,8 +690,10 @@ export function ConversationDetailPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await warmupDetailCache(conversationId)
|
const detail = await refreshDetailCache(conversationId)
|
||||||
|
acknowledgePersistedDetail(conversationId, detail)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setSyncState(conversationId, "failed")
|
||||||
console.error(
|
console.error(
|
||||||
"[ConversationDetailPanel] background detail cache refresh failed:",
|
"[ConversationDetailPanel] background detail cache refresh failed:",
|
||||||
error
|
error
|
||||||
@@ -306,7 +704,7 @@ export function ConversationDetailPanel() {
|
|||||||
|
|
||||||
refreshConversations()
|
refreshConversations()
|
||||||
})()
|
})()
|
||||||
}, [refreshConversations])
|
}, [acknowledgePersistedDetail, refreshConversations, setSyncState])
|
||||||
|
|
||||||
const scheduleClosedConversationRefresh = useCallback(
|
const scheduleClosedConversationRefresh = useCallback(
|
||||||
(conversationId: number) => {
|
(conversationId: number) => {
|
||||||
@@ -333,17 +731,20 @@ export function ConversationDetailPanel() {
|
|||||||
const payload = event.payload
|
const payload = event.payload
|
||||||
if (payload.type !== "turn_complete") return
|
if (payload.type !== "turn_complete") return
|
||||||
|
|
||||||
|
const runtimeConversationId = getConversationIdByExternalId(
|
||||||
|
payload.session_id
|
||||||
|
)
|
||||||
const summary = conversationsRef.current.find(
|
const summary = conversationsRef.current.find(
|
||||||
(item) => item.external_id === payload.session_id
|
(item) => item.external_id === payload.session_id
|
||||||
)
|
)
|
||||||
if (!summary) return
|
const matchedConversationId =
|
||||||
|
runtimeConversationId ?? summary?.id ?? null
|
||||||
|
if (!matchedConversationId) return
|
||||||
|
|
||||||
const isOpenInTabs = tabsRef.current.some(
|
invalidateDetailCache(matchedConversationId)
|
||||||
(tab) => tab.conversationId === summary.id
|
setSyncState(matchedConversationId, "reconciling")
|
||||||
)
|
|
||||||
if (isOpenInTabs) return
|
|
||||||
|
|
||||||
scheduleClosedConversationRefresh(summary.id)
|
scheduleClosedConversationRefresh(matchedConversationId)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.then((dispose) => {
|
.then((dispose) => {
|
||||||
@@ -372,27 +773,18 @@ export function ConversationDetailPanel() {
|
|||||||
"ConversationDetailPanel.backgroundRefresh"
|
"ConversationDetailPanel.backgroundRefresh"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, [scheduleClosedConversationRefresh])
|
}, [
|
||||||
|
getConversationIdByExternalId,
|
||||||
|
acknowledgePersistedDetail,
|
||||||
|
scheduleClosedConversationRefresh,
|
||||||
|
setSyncState,
|
||||||
|
])
|
||||||
|
|
||||||
const conversationTabs = useMemo(
|
const hasNoTabs = tabs.length === 0 && !activeTabId
|
||||||
() =>
|
|
||||||
tabs.filter((t) => t.kind === "conversation" && t.conversationId != null),
|
|
||||||
[tabs]
|
|
||||||
)
|
|
||||||
|
|
||||||
const newConvTabs = useMemo(
|
|
||||||
() => tabs.filter((t) => t.kind === "new_conversation"),
|
|
||||||
[tabs]
|
|
||||||
)
|
|
||||||
const hasNoTabs =
|
|
||||||
conversationTabs.length === 0 && newConvTabs.length === 0 && !activeTabId
|
|
||||||
const activeConversationTab = useMemo(
|
const activeConversationTab = useMemo(
|
||||||
() =>
|
() =>
|
||||||
tabs.find(
|
tabs.find(
|
||||||
(tab) =>
|
(tab) => tab.id === activeTabId && tab.conversationId != null
|
||||||
tab.id === activeTabId &&
|
|
||||||
tab.kind === "conversation" &&
|
|
||||||
tab.conversationId != null
|
|
||||||
) ?? null,
|
) ?? null,
|
||||||
[tabs, activeTabId]
|
[tabs, activeTabId]
|
||||||
)
|
)
|
||||||
@@ -435,19 +827,14 @@ export function ConversationDetailPanel() {
|
|||||||
|
|
||||||
// Empty state: no tabs at all — show full-screen welcome
|
// Empty state: no tabs at all — show full-screen welcome
|
||||||
if (hasNoTabs) {
|
if (hasNoTabs) {
|
||||||
return (
|
return null
|
||||||
<WelcomeInputPanel
|
|
||||||
defaultAgentType={newConversation?.agentType ?? "codex"}
|
|
||||||
workingDir={newConversation?.workingDir ?? folder?.path}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div className="relative h-full min-h-0 overflow-hidden">
|
<div className="relative h-full min-h-0 overflow-hidden">
|
||||||
{conversationTabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const active = tab.id === activeTabId
|
const active = tab.id === activeTabId
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -458,36 +845,17 @@ export function ConversationDetailPanel() {
|
|||||||
: "absolute inset-0 invisible pointer-events-none"
|
: "absolute inset-0 invisible pointer-events-none"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ExistingConversationView
|
<ConversationTabView
|
||||||
tabId={tab.id}
|
tabId={tab.id}
|
||||||
conversationId={tab.conversationId!}
|
conversationId={tab.conversationId}
|
||||||
agentType={tab.agentType}
|
agentType={tab.agentType}
|
||||||
|
workingDir={tab.workingDir ?? folder?.path}
|
||||||
isActive={active}
|
isActive={active}
|
||||||
reloadSignal={reloadByTabId[tab.id] ?? 0}
|
reloadSignal={reloadByTabId[tab.id] ?? 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{newConvTabs.map((tab) => {
|
|
||||||
const active = tab.id === activeTabId
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tab.id}
|
|
||||||
className={
|
|
||||||
active
|
|
||||||
? "h-full"
|
|
||||||
: "absolute inset-0 invisible pointer-events-none"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<WelcomeInputPanel
|
|
||||||
defaultAgentType={tab.agentType ?? "codex"}
|
|
||||||
workingDir={tab.workingDir ?? folder?.path}
|
|
||||||
tabId={tab.id}
|
|
||||||
isActive={active}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ export function SidebarConversationList({
|
|||||||
refreshConversations,
|
refreshConversations,
|
||||||
} = useFolderContext()
|
} = useFolderContext()
|
||||||
|
|
||||||
const { openTab, closeTab, openNewConversationTab } = useTabContext()
|
const { openTab, closeConversationTab, openNewConversationTab } =
|
||||||
|
useTabContext()
|
||||||
const { addTask, updateTask } = useTaskContext()
|
const { addTask, updateTask } = useTaskContext()
|
||||||
|
|
||||||
const [importing, setImporting] = useState(false)
|
const [importing, setImporting] = useState(false)
|
||||||
@@ -206,10 +207,10 @@ export function SidebarConversationList({
|
|||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
async (id: number, agentType: string) => {
|
async (id: number, agentType: string) => {
|
||||||
await deleteConversation(id)
|
await deleteConversation(id)
|
||||||
closeTab(`conv-${agentType}-${id}`)
|
closeConversationTab(id, agentType as Parameters<typeof openTab>[1])
|
||||||
refreshConversations()
|
refreshConversations()
|
||||||
},
|
},
|
||||||
[closeTab, refreshConversations]
|
[closeConversationTab, refreshConversations]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleStatusChange = useCallback(
|
const handleStatusChange = useCallback(
|
||||||
|
|||||||
@@ -842,10 +842,7 @@ export function FileTreeTab() {
|
|||||||
const activeSessionTabId = useMemo(() => {
|
const activeSessionTabId = useMemo(() => {
|
||||||
const activeTab = tabs.find((tab) => tab.id === activeTabId)
|
const activeTab = tabs.find((tab) => tab.id === activeTabId)
|
||||||
if (!activeTab) return null
|
if (!activeTab) return null
|
||||||
if (
|
if (activeTab.kind !== "conversation") {
|
||||||
activeTab.kind !== "conversation" &&
|
|
||||||
activeTab.kind !== "new_conversation"
|
|
||||||
) {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return activeTab.id
|
return activeTab.id
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { ChevronRight, FileIcon } from "lucide-react"
|
import { ChevronRight, FileIcon } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { useTabContext } from "@/contexts/tab-context"
|
import { useTabContext } from "@/contexts/tab-context"
|
||||||
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
import { useConversationRuntime } from "@/contexts/conversation-runtime-context"
|
||||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||||
import { useConnection } from "@/hooks/use-connection"
|
|
||||||
import { useDbMessageDetail } from "@/hooks/use-db-message-detail"
|
import { useDbMessageDetail } from "@/hooks/use-db-message-detail"
|
||||||
import { extractSessionFilesGrouped } from "@/lib/session-files"
|
import { extractSessionFilesGrouped } from "@/lib/session-files"
|
||||||
import { getPendingPromptText } from "@/lib/pending-prompt-text"
|
|
||||||
import {
|
|
||||||
inferLiveToolName,
|
|
||||||
normalizeToolName,
|
|
||||||
} from "@/lib/tool-call-normalization"
|
|
||||||
import type { ConnectionStatus, MessageTurn } from "@/lib/types"
|
|
||||||
import {
|
import {
|
||||||
CommitFileAdditions,
|
CommitFileAdditions,
|
||||||
CommitFileDeletions,
|
CommitFileDeletions,
|
||||||
@@ -27,8 +20,6 @@ import {
|
|||||||
} from "@/components/ui/collapsible"
|
} from "@/components/ui/collapsible"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const LIVE_FILE_WRITE_OPS = new Set(["edit", "write", "apply_patch"])
|
|
||||||
|
|
||||||
function isRemovedFileDiff(diff: string | null): boolean {
|
function isRemovedFileDiff(diff: string | null): boolean {
|
||||||
if (!diff) return false
|
if (!diff) return false
|
||||||
|
|
||||||
@@ -61,123 +52,17 @@ function toFolderRelativePath(filePath: string, folderPath?: string): string {
|
|||||||
return normalizedFilePath
|
return normalizedFilePath
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractTurnText(turn: MessageTurn | null): string | null {
|
function SessionFilesContent({ conversationId }: { conversationId: number }) {
|
||||||
if (!turn || turn.role !== "user") return null
|
|
||||||
|
|
||||||
for (const block of turn.blocks) {
|
|
||||||
if (block.type !== "text") continue
|
|
||||||
const text = block.text.trim()
|
|
||||||
if (text) return text
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeLiveTurns(params: {
|
|
||||||
turns: MessageTurn[]
|
|
||||||
liveMessage: LiveMessage | null
|
|
||||||
connStatus: ConnectionStatus | null
|
|
||||||
pendingPromptText: string | null
|
|
||||||
fallbackPromptText: string
|
|
||||||
}): MessageTurn[] {
|
|
||||||
const {
|
|
||||||
turns,
|
|
||||||
liveMessage,
|
|
||||||
connStatus,
|
|
||||||
pendingPromptText,
|
|
||||||
fallbackPromptText,
|
|
||||||
} = params
|
|
||||||
if (!liveMessage || connStatus !== "prompting") return turns
|
|
||||||
|
|
||||||
const liveBlocks = liveMessage.content.flatMap((block) => {
|
|
||||||
if (block.type !== "tool_call") return []
|
|
||||||
|
|
||||||
const toolName = inferLiveToolName({
|
|
||||||
title: block.info.title,
|
|
||||||
kind: block.info.kind,
|
|
||||||
rawInput: block.info.raw_input,
|
|
||||||
})
|
|
||||||
const normalizedToolName = normalizeToolName(toolName)
|
|
||||||
if (!LIVE_FILE_WRITE_OPS.has(normalizedToolName)) return []
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: "tool_use" as const,
|
|
||||||
tool_use_id: block.info.tool_call_id,
|
|
||||||
tool_name: toolName,
|
|
||||||
input_preview: block.info.raw_input,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
if (liveBlocks.length === 0) return turns
|
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
const mergedTurns = [...turns]
|
|
||||||
const lastTurn = mergedTurns[mergedTurns.length - 1]
|
|
||||||
const lastUserTurn =
|
|
||||||
[...mergedTurns].reverse().find((turn) => turn.role === "user") ?? null
|
|
||||||
const pendingText = pendingPromptText?.trim() ?? ""
|
|
||||||
const shouldReuseExistingUserTurn =
|
|
||||||
pendingText.length > 0 && extractTurnText(lastUserTurn) === pendingText
|
|
||||||
|
|
||||||
if ((!lastTurn || lastTurn.role !== "user") && !shouldReuseExistingUserTurn) {
|
|
||||||
mergedTurns.push({
|
|
||||||
id: `live-user-${liveMessage.id}`,
|
|
||||||
role: "user",
|
|
||||||
blocks: [
|
|
||||||
{ type: "text", text: pendingPromptText?.trim() || fallbackPromptText },
|
|
||||||
],
|
|
||||||
timestamp: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
mergedTurns.push({
|
|
||||||
id: `live-assistant-${liveMessage.id}`,
|
|
||||||
role: "assistant",
|
|
||||||
blocks: liveBlocks,
|
|
||||||
timestamp: now,
|
|
||||||
})
|
|
||||||
|
|
||||||
return mergedTurns
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionFilesContent({
|
|
||||||
conversationId,
|
|
||||||
liveMessage,
|
|
||||||
connStatus,
|
|
||||||
pendingPromptText,
|
|
||||||
}: {
|
|
||||||
conversationId: number
|
|
||||||
liveMessage: LiveMessage | null
|
|
||||||
connStatus: ConnectionStatus | null
|
|
||||||
pendingPromptText: string | null
|
|
||||||
}) {
|
|
||||||
const t = useTranslations("Folder.sessionFiles")
|
const t = useTranslations("Folder.sessionFiles")
|
||||||
const { detail, loading, refetch } = useDbMessageDetail(conversationId)
|
const { loading } = useDbMessageDetail(conversationId)
|
||||||
|
const { getTimelineTurns } = useConversationRuntime()
|
||||||
const { openSessionFileDiff } = useWorkspaceContext()
|
const { openSessionFileDiff } = useWorkspaceContext()
|
||||||
const { folder } = useFolderContext()
|
const { folder } = useFolderContext()
|
||||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
|
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
|
||||||
const prevStatusRef = useRef(connStatus)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const prev = prevStatusRef.current
|
|
||||||
prevStatusRef.current = connStatus
|
|
||||||
if (prev === "prompting" && connStatus && connStatus !== "prompting") {
|
|
||||||
refetch()
|
|
||||||
}
|
|
||||||
}, [connStatus, refetch])
|
|
||||||
|
|
||||||
const turns = useMemo(
|
const turns = useMemo(
|
||||||
() =>
|
() => getTimelineTurns(conversationId).map((item) => item.turn),
|
||||||
mergeLiveTurns({
|
[conversationId, getTimelineTurns]
|
||||||
turns: detail?.turns ?? [],
|
|
||||||
liveMessage,
|
|
||||||
connStatus,
|
|
||||||
pendingPromptText,
|
|
||||||
fallbackPromptText: t("currentResponse"),
|
|
||||||
}),
|
|
||||||
[detail?.turns, liveMessage, connStatus, pendingPromptText, t]
|
|
||||||
)
|
)
|
||||||
const groups = useMemo(
|
const groups = useMemo(
|
||||||
() => (turns.length > 0 ? extractSessionFilesGrouped(turns) : []),
|
() => (turns.length > 0 ? extractSessionFilesGrouped(turns) : []),
|
||||||
@@ -197,7 +82,7 @@ function SessionFilesContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading && groups.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full p-4">
|
<div className="flex items-center justify-center h-full p-4">
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
@@ -381,9 +266,6 @@ export function SessionFilesTab() {
|
|||||||
|
|
||||||
const activeTab = tabs.find((t) => t.id === activeTabId)
|
const activeTab = tabs.find((t) => t.id === activeTabId)
|
||||||
const conversationId = activeTab?.conversationId
|
const conversationId = activeTab?.conversationId
|
||||||
const contextKey = activeTab?.id ?? "__session-files-tab__"
|
|
||||||
const conn = useConnection(contextKey)
|
|
||||||
const pendingPromptText = getPendingPromptText(contextKey)
|
|
||||||
|
|
||||||
if (!activeTab) {
|
if (!activeTab) {
|
||||||
return (
|
return (
|
||||||
@@ -408,12 +290,7 @@ export function SessionFilesTab() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
<SessionFilesContent
|
<SessionFilesContent conversationId={conversationId} />
|
||||||
conversationId={conversationId}
|
|
||||||
liveMessage={conn.liveMessage}
|
|
||||||
connStatus={conn.status}
|
|
||||||
pendingPromptText={pendingPromptText}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef } from "react"
|
import { memo, useCallback, useEffect, useMemo } from "react"
|
||||||
import { useDbMessageDetail } from "@/hooks/use-db-message-detail"
|
import { useDbMessageDetail } from "@/hooks/use-db-message-detail"
|
||||||
|
import { useConversationRuntime } from "@/contexts/conversation-runtime-context"
|
||||||
import { ContentPartsRenderer } from "./content-parts-renderer"
|
import { ContentPartsRenderer } from "./content-parts-renderer"
|
||||||
import {
|
import {
|
||||||
adaptMessageTurns,
|
adaptMessageTurns,
|
||||||
type AdaptedMessage,
|
|
||||||
type AdaptedContentPart,
|
type AdaptedContentPart,
|
||||||
type MessageGroup,
|
type MessageGroup,
|
||||||
type UserImageDisplay,
|
type UserImageDisplay,
|
||||||
@@ -18,9 +18,7 @@ import { LiveTurnStats } from "./live-turn-stats"
|
|||||||
import { UserResourceLinks } from "./user-resource-links"
|
import { UserResourceLinks } from "./user-resource-links"
|
||||||
import { UserImageAttachments } from "./user-image-attachments"
|
import { UserImageAttachments } from "./user-image-attachments"
|
||||||
import { useSessionStats } from "@/contexts/session-stats-context"
|
import { useSessionStats } from "@/contexts/session-stats-context"
|
||||||
import { LiveMessageBlock } from "@/components/chat/live-message-block"
|
|
||||||
import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
|
import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
|
||||||
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
|
||||||
import { MessageThread } from "@/components/ai-elements/message-thread"
|
import { MessageThread } from "@/components/ai-elements/message-thread"
|
||||||
import { Message, MessageContent } from "@/components/ai-elements/message"
|
import { Message, MessageContent } from "@/components/ai-elements/message"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
@@ -35,9 +33,6 @@ import { VirtualizedMessageThread } from "@/components/message/virtualized-messa
|
|||||||
interface MessageListViewProps {
|
interface MessageListViewProps {
|
||||||
conversationId: number
|
conversationId: number
|
||||||
connStatus?: ConnectionStatus | null
|
connStatus?: ConnectionStatus | null
|
||||||
liveMessage?: LiveMessage | null
|
|
||||||
pendingMessages?: AdaptedMessage[]
|
|
||||||
onPendingClear?: () => void
|
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,24 +45,14 @@ interface ResolvedMessageGroup extends MessageGroup {
|
|||||||
type ThreadRenderItem =
|
type ThreadRenderItem =
|
||||||
| {
|
| {
|
||||||
key: string
|
key: string
|
||||||
kind: "historical"
|
kind: "turn"
|
||||||
group: ResolvedMessageGroup
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
key: string
|
|
||||||
kind: "pending"
|
|
||||||
group: ResolvedMessageGroup
|
group: ResolvedMessageGroup
|
||||||
|
phase: "persisted" | "optimistic" | "streaming"
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
key: string
|
key: string
|
||||||
kind: "typing"
|
kind: "typing"
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
key: string
|
|
||||||
kind: "live"
|
|
||||||
message: LiveMessage
|
|
||||||
isStreaming: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function fallbackExtractUserResources(
|
function fallbackExtractUserResources(
|
||||||
group: MessageGroup,
|
group: MessageGroup,
|
||||||
@@ -140,11 +125,13 @@ function resolveMessageGroup(
|
|||||||
|
|
||||||
const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
|
const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
|
||||||
group,
|
group,
|
||||||
|
dimmed = false,
|
||||||
}: {
|
}: {
|
||||||
group: ResolvedMessageGroup
|
group: ResolvedMessageGroup
|
||||||
|
dimmed?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={dimmed ? "opacity-70" : undefined}>
|
||||||
<Message from={group.role}>
|
<Message from={group.role}>
|
||||||
{group.role === "user" && group.images.length > 0 ? (
|
{group.role === "user" && group.images.length > 0 ? (
|
||||||
<UserImageAttachments images={group.images} className="self-end" />
|
<UserImageAttachments images={group.images} className="self-end" />
|
||||||
@@ -168,28 +155,6 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const PendingMessageGroup = memo(function PendingMessageGroup({
|
|
||||||
group,
|
|
||||||
}: {
|
|
||||||
group: ResolvedMessageGroup
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="opacity-70">
|
|
||||||
<Message from={group.role}>
|
|
||||||
{group.role === "user" && group.images.length > 0 ? (
|
|
||||||
<UserImageAttachments images={group.images} className="self-end" />
|
|
||||||
) : null}
|
|
||||||
<MessageContent>
|
|
||||||
<ContentPartsRenderer parts={group.parts} role={group.role} />
|
|
||||||
</MessageContent>
|
|
||||||
{group.role === "user" && group.resources.length > 0 ? (
|
|
||||||
<UserResourceLinks resources={group.resources} className="self-end" />
|
|
||||||
) : null}
|
|
||||||
</Message>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const PendingTypingIndicator = memo(function PendingTypingIndicator() {
|
const PendingTypingIndicator = memo(function PendingTypingIndicator() {
|
||||||
return (
|
return (
|
||||||
<Message from="assistant">
|
<Message from="assistant">
|
||||||
@@ -207,33 +172,15 @@ const PendingTypingIndicator = memo(function PendingTypingIndicator() {
|
|||||||
export function MessageListView({
|
export function MessageListView({
|
||||||
conversationId,
|
conversationId,
|
||||||
connStatus,
|
connStatus,
|
||||||
liveMessage,
|
|
||||||
pendingMessages,
|
|
||||||
onPendingClear,
|
|
||||||
isActive = true,
|
isActive = true,
|
||||||
}: MessageListViewProps) {
|
}: MessageListViewProps) {
|
||||||
const t = useTranslations("Folder.chat.messageList")
|
const t = useTranslations("Folder.chat.messageList")
|
||||||
const sharedT = useTranslations("Folder.chat.shared")
|
const sharedT = useTranslations("Folder.chat.shared")
|
||||||
const { detail, loading, error } = useDbMessageDetail(conversationId)
|
const { detail, loading, error } = useDbMessageDetail(conversationId)
|
||||||
const turnCount = detail?.turns.length ?? 0
|
const { getSession, getTimelineTurns } = useConversationRuntime()
|
||||||
|
const session = getSession(conversationId)
|
||||||
// 移除了 prompting 结束后的立即刷新
|
const liveMessage = session?.liveMessage ?? null
|
||||||
// 原因:后端自动持久化可能有延迟,立即刷新会读到不完整数据
|
const timelineTurns = getTimelineTurns(conversationId)
|
||||||
// 现在通过清空 pending 来避免累积问题,等用户切换会话或手动刷新时再加载
|
|
||||||
|
|
||||||
const prevTurnCountRef = useRef(turnCount)
|
|
||||||
const prevConvIdRef = useRef(conversationId)
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevConvIdRef.current !== conversationId) {
|
|
||||||
prevConvIdRef.current = conversationId
|
|
||||||
prevTurnCountRef.current = turnCount
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (turnCount > prevTurnCountRef.current && onPendingClear) {
|
|
||||||
onPendingClear()
|
|
||||||
}
|
|
||||||
prevTurnCountRef.current = turnCount
|
|
||||||
}, [turnCount, onPendingClear, conversationId])
|
|
||||||
|
|
||||||
const { setSessionStats } = useSessionStats()
|
const { setSessionStats } = useSessionStats()
|
||||||
const sessionStats = detail?.session_stats ?? null
|
const sessionStats = detail?.session_stats ?? null
|
||||||
@@ -244,106 +191,105 @@ export function MessageListView({
|
|||||||
}
|
}
|
||||||
}, [isActive, sessionStats, setSessionStats])
|
}, [isActive, sessionStats, setSessionStats])
|
||||||
|
|
||||||
const shouldUseSmoothResize = !(isActive && !loading && detail)
|
const shouldUseSmoothResize = !(isActive && !loading && timelineTurns.length)
|
||||||
|
const attachedResourcesText = sharedT("attachedResources")
|
||||||
|
|
||||||
const messages = useMemo(
|
const groupedTimeline = useMemo(
|
||||||
() =>
|
() =>
|
||||||
detail
|
timelineTurns.reduce<
|
||||||
? adaptMessageTurns(detail.turns, {
|
Array<{
|
||||||
attachedResources: sharedT("attachedResources"),
|
phase: "persisted" | "optimistic" | "streaming"
|
||||||
toolCallFailed: sharedT("toolCallFailed"),
|
turns: typeof timelineTurns
|
||||||
|
}>
|
||||||
|
>((acc, item) => {
|
||||||
|
const current = acc[acc.length - 1]
|
||||||
|
if (current && current.phase === item.phase) {
|
||||||
|
current.turns.push(item)
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
acc.push({
|
||||||
|
phase: item.phase,
|
||||||
|
turns: [item],
|
||||||
})
|
})
|
||||||
: [],
|
return acc
|
||||||
[detail, sharedT]
|
}, []),
|
||||||
|
[timelineTurns]
|
||||||
)
|
)
|
||||||
|
|
||||||
const groups = useMemo(() => groupAdaptedMessages(messages), [messages])
|
const threadItems = useMemo<ThreadRenderItem[]>(() => {
|
||||||
|
const items: ThreadRenderItem[] = []
|
||||||
|
for (
|
||||||
|
let chunkIndex = 0;
|
||||||
|
chunkIndex < groupedTimeline.length;
|
||||||
|
chunkIndex++
|
||||||
|
) {
|
||||||
|
const chunk = groupedTimeline[chunkIndex]
|
||||||
|
const adapted = adaptMessageTurns(
|
||||||
|
chunk.turns.map((item) => item.turn),
|
||||||
|
{
|
||||||
|
attachedResources: sharedT("attachedResources"),
|
||||||
|
toolCallFailed: sharedT("toolCallFailed"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const groups = groupAdaptedMessages(adapted).map((group) =>
|
||||||
|
resolveMessageGroup(group, attachedResourcesText)
|
||||||
|
)
|
||||||
|
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
|
||||||
|
const group = groups[groupIndex]
|
||||||
|
items.push({
|
||||||
|
key: `${chunk.phase}-${chunkIndex}-${group.id}-${groupIndex}`,
|
||||||
|
kind: "turn",
|
||||||
|
group,
|
||||||
|
phase: chunk.phase,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lastPhase = timelineTurns[timelineTurns.length - 1]?.phase ?? null
|
||||||
|
if (connStatus === "prompting" && lastPhase === "optimistic") {
|
||||||
|
items.push({ key: "pending-typing", kind: "typing" })
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}, [
|
||||||
|
attachedResourcesText,
|
||||||
|
connStatus,
|
||||||
|
groupedTimeline,
|
||||||
|
sharedT,
|
||||||
|
timelineTurns,
|
||||||
|
])
|
||||||
|
|
||||||
|
const historicalMessages = useMemo(
|
||||||
|
() =>
|
||||||
|
adaptMessageTurns(
|
||||||
|
timelineTurns
|
||||||
|
.filter((item) => item.phase !== "streaming")
|
||||||
|
.map((item) => item.turn),
|
||||||
|
{
|
||||||
|
attachedResources: sharedT("attachedResources"),
|
||||||
|
toolCallFailed: sharedT("toolCallFailed"),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
[sharedT, timelineTurns]
|
||||||
|
)
|
||||||
const historicalPlanEntries = useMemo(
|
const historicalPlanEntries = useMemo(
|
||||||
() => extractLatestPlanEntriesFromMessages(messages),
|
() => extractLatestPlanEntriesFromMessages(historicalMessages),
|
||||||
[messages]
|
[historicalMessages]
|
||||||
)
|
)
|
||||||
const historicalPlanKey = useMemo(
|
const historicalPlanKey = useMemo(
|
||||||
() => buildPlanKey(historicalPlanEntries),
|
() => buildPlanKey(historicalPlanEntries),
|
||||||
[historicalPlanEntries]
|
[historicalPlanEntries]
|
||||||
)
|
)
|
||||||
|
|
||||||
const pendingGroups = useMemo(
|
|
||||||
() =>
|
|
||||||
pendingMessages?.length ? groupAdaptedMessages(pendingMessages) : [],
|
|
||||||
[pendingMessages]
|
|
||||||
)
|
|
||||||
const attachedResourcesText = sharedT("attachedResources")
|
|
||||||
|
|
||||||
const resolvedGroups = useMemo(
|
|
||||||
() =>
|
|
||||||
groups.map((group) => resolveMessageGroup(group, attachedResourcesText)),
|
|
||||||
[groups, attachedResourcesText]
|
|
||||||
)
|
|
||||||
const resolvedPendingGroups = useMemo(
|
|
||||||
() =>
|
|
||||||
pendingGroups.map((group) =>
|
|
||||||
resolveMessageGroup(group, attachedResourcesText)
|
|
||||||
),
|
|
||||||
[pendingGroups, attachedResourcesText]
|
|
||||||
)
|
|
||||||
|
|
||||||
const showLiveMessage = Boolean(
|
|
||||||
liveMessage &&
|
|
||||||
(connStatus === "prompting" ||
|
|
||||||
(liveMessage.content.length > 0 && resolvedPendingGroups.length > 0))
|
|
||||||
)
|
|
||||||
|
|
||||||
const threadItems = useMemo<ThreadRenderItem[]>(() => {
|
|
||||||
const items: ThreadRenderItem[] = [
|
|
||||||
...resolvedGroups.map((group) => ({
|
|
||||||
key: `history-${group.id}`,
|
|
||||||
kind: "historical" as const,
|
|
||||||
group,
|
|
||||||
})),
|
|
||||||
...resolvedPendingGroups.map((group) => ({
|
|
||||||
key: `pending-${group.id}`,
|
|
||||||
kind: "pending" as const,
|
|
||||||
group,
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
|
|
||||||
if (resolvedPendingGroups.length > 0 && !showLiveMessage) {
|
|
||||||
items.push({ key: "pending-typing", kind: "typing" })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showLiveMessage && liveMessage) {
|
|
||||||
items.push({
|
|
||||||
key: `live-${liveMessage.id}`,
|
|
||||||
kind: "live",
|
|
||||||
message: liveMessage,
|
|
||||||
isStreaming: connStatus === "prompting",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
}, [
|
|
||||||
resolvedGroups,
|
|
||||||
resolvedPendingGroups,
|
|
||||||
showLiveMessage,
|
|
||||||
liveMessage,
|
|
||||||
connStatus,
|
|
||||||
])
|
|
||||||
|
|
||||||
const renderThreadItem = useCallback((item: ThreadRenderItem) => {
|
const renderThreadItem = useCallback((item: ThreadRenderItem) => {
|
||||||
switch (item.kind) {
|
switch (item.kind) {
|
||||||
case "historical":
|
case "turn":
|
||||||
return <HistoricalMessageGroup group={item.group} />
|
|
||||||
case "pending":
|
|
||||||
return <PendingMessageGroup group={item.group} />
|
|
||||||
case "typing":
|
|
||||||
return <PendingTypingIndicator />
|
|
||||||
case "live":
|
|
||||||
return (
|
return (
|
||||||
<LiveMessageBlock
|
<HistoricalMessageGroup
|
||||||
message={item.message}
|
group={item.group}
|
||||||
isStreaming={item.isStreaming}
|
dimmed={item.phase === "optimistic"}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
case "typing":
|
||||||
|
return <PendingTypingIndicator />
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -362,7 +308,9 @@ export function MessageListView({
|
|||||||
|
|
||||||
const agentPlanOverlayKey = liveMessage?.id ?? `history-${conversationId}`
|
const agentPlanOverlayKey = liveMessage?.id ?? `history-${conversationId}`
|
||||||
|
|
||||||
if (loading && !detail) {
|
const hasRenderableContent = threadItems.length > 0 || Boolean(liveMessage)
|
||||||
|
|
||||||
|
if (loading && !hasRenderableContent) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
@@ -373,7 +321,7 @@ export function MessageListView({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error && !hasRenderableContent) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
@@ -385,8 +333,6 @@ export function MessageListView({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!detail) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full min-h-0 flex-col">
|
<div className="relative flex h-full min-h-0 flex-col">
|
||||||
<MessageThread
|
<MessageThread
|
||||||
@@ -402,7 +348,7 @@ export function MessageListView({
|
|||||||
overscan={10}
|
overscan={10}
|
||||||
/>
|
/>
|
||||||
</MessageThread>
|
</MessageThread>
|
||||||
{showLiveMessage && liveMessage && connStatus === "prompting" && (
|
{liveMessage && connStatus === "prompting" && (
|
||||||
<LiveTurnStats
|
<LiveTurnStats
|
||||||
message={liveMessage}
|
message={liveMessage}
|
||||||
isStreaming={connStatus === "prompting"}
|
isStreaming={connStatus === "prompting"}
|
||||||
@@ -413,7 +359,7 @@ export function MessageListView({
|
|||||||
message={liveMessage ?? null}
|
message={liveMessage ?? null}
|
||||||
entries={historicalPlanEntries}
|
entries={historicalPlanEntries}
|
||||||
planKey={historicalPlanKey}
|
planKey={historicalPlanKey}
|
||||||
defaultExpanded={showLiveMessage}
|
defaultExpanded={connStatus === "prompting"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -974,7 +974,6 @@ export interface AcpActionsValue {
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
optionId: string
|
optionId: string
|
||||||
): Promise<void>
|
): Promise<void>
|
||||||
migrateContextKey(fromKey: string, toKey: string): void
|
|
||||||
setActiveKey(key: string | null): void
|
setActiveKey(key: string | null): void
|
||||||
touchActivity(contextKey: string): void
|
touchActivity(contextKey: string): void
|
||||||
}
|
}
|
||||||
@@ -1754,56 +1753,6 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
dispatch({ type: "REMOVE_ALL" })
|
dispatch({ type: "REMOVE_ALL" })
|
||||||
}, [dispatch])
|
}, [dispatch])
|
||||||
|
|
||||||
const migrateContextKey = useCallback(
|
|
||||||
(fromKey: string, toKey: string) => {
|
|
||||||
if (!fromKey || !toKey || fromKey === toKey) return
|
|
||||||
|
|
||||||
const current = storeRef.current.connections
|
|
||||||
const conn = current.get(fromKey)
|
|
||||||
if (!conn) return
|
|
||||||
|
|
||||||
const targetConn = current.get(toKey)
|
|
||||||
const migratedConn = targetConn
|
|
||||||
? {
|
|
||||||
...conn,
|
|
||||||
// Preserve the most recent error from the target, if any.
|
|
||||||
error: targetConn.error ?? conn.error,
|
|
||||||
contextKey: toKey,
|
|
||||||
}
|
|
||||||
: { ...conn, contextKey: toKey }
|
|
||||||
|
|
||||||
const next = new Map(current)
|
|
||||||
next.delete(fromKey)
|
|
||||||
next.set(toKey, migratedConn)
|
|
||||||
storeRef.current.connections = next
|
|
||||||
|
|
||||||
for (const [connectionId, mappedKey] of reverseMapRef.current) {
|
|
||||||
if (mappedKey === fromKey) {
|
|
||||||
reverseMapRef.current.set(connectionId, toKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastActive = lastActivityRef.current.get(fromKey)
|
|
||||||
if (lastActive != null) {
|
|
||||||
lastActivityRef.current.set(toKey, lastActive)
|
|
||||||
lastActivityRef.current.delete(fromKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connectingKeysRef.current.delete(fromKey)) {
|
|
||||||
connectingKeysRef.current.add(toKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (storeRef.current.activeKey === fromKey) {
|
|
||||||
storeRef.current.activeKey = toKey
|
|
||||||
notifyActiveKeyListeners()
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyKeyListeners(fromKey)
|
|
||||||
notifyKeyListeners(toKey)
|
|
||||||
},
|
|
||||||
[notifyActiveKeyListeners, notifyKeyListeners]
|
|
||||||
)
|
|
||||||
|
|
||||||
const sendPrompt = useCallback(
|
const sendPrompt = useCallback(
|
||||||
async (contextKey: string, blocks: PromptInputBlock[]) => {
|
async (contextKey: string, blocks: PromptInputBlock[]) => {
|
||||||
const conn = storeRef.current.connections.get(contextKey)
|
const conn = storeRef.current.connections.get(contextKey)
|
||||||
@@ -1869,7 +1818,6 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
setConfigOption,
|
setConfigOption,
|
||||||
cancel,
|
cancel,
|
||||||
respondPermission,
|
respondPermission,
|
||||||
migrateContextKey,
|
|
||||||
setActiveKey,
|
setActiveKey,
|
||||||
touchActivity,
|
touchActivity,
|
||||||
}),
|
}),
|
||||||
@@ -1882,7 +1830,6 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
setConfigOption,
|
setConfigOption,
|
||||||
cancel,
|
cancel,
|
||||||
respondPermission,
|
respondPermission,
|
||||||
migrateContextKey,
|
|
||||||
setActiveKey,
|
setActiveKey,
|
||||||
touchActivity,
|
touchActivity,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,651 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useReducer,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react"
|
||||||
|
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
||||||
|
import type { DbConversationDetail, MessageTurn } from "@/lib/types"
|
||||||
|
import { inferLiveToolName } from "@/lib/tool-call-normalization"
|
||||||
|
|
||||||
|
export type ConversationSyncState =
|
||||||
|
| "idle"
|
||||||
|
| "awaiting_persist"
|
||||||
|
| "reconciling"
|
||||||
|
| "failed"
|
||||||
|
|
||||||
|
export type ConversationTimelinePhase = "persisted" | "optimistic" | "streaming"
|
||||||
|
|
||||||
|
export interface ConversationTimelineTurn {
|
||||||
|
key: string
|
||||||
|
turn: MessageTurn
|
||||||
|
phase: ConversationTimelinePhase
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationRuntimeSession {
|
||||||
|
conversationId: number
|
||||||
|
externalId: string | null
|
||||||
|
persistedTurns: MessageTurn[]
|
||||||
|
optimisticTurns: MessageTurn[]
|
||||||
|
liveMessage: LiveMessage | null
|
||||||
|
syncState: ConversationSyncState
|
||||||
|
activeTurnToken: string | null
|
||||||
|
lastHydratedAt: number | null
|
||||||
|
lastPersistedAt: number | null
|
||||||
|
persistedUpdatedAt: string | null
|
||||||
|
persistedMessageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConversationRuntimeState {
|
||||||
|
byConversationId: Map<number, ConversationRuntimeSession>
|
||||||
|
conversationIdByExternalId: Map<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ConversationRuntimeState = {
|
||||||
|
byConversationId: new Map(),
|
||||||
|
conversationIdByExternalId: new Map(),
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "HYDRATE_FROM_DETAIL"; detail: DbConversationDetail }
|
||||||
|
| {
|
||||||
|
type: "APPEND_OPTIMISTIC_TURN"
|
||||||
|
conversationId: number
|
||||||
|
turn: MessageTurn
|
||||||
|
turnToken: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "SET_LIVE_MESSAGE"
|
||||||
|
conversationId: number
|
||||||
|
liveMessage: LiveMessage | null
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "ACK_PERSISTED_DETAIL"
|
||||||
|
conversationId: number
|
||||||
|
detail: DbConversationDetail
|
||||||
|
turnToken?: string | null
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "SET_EXTERNAL_ID"
|
||||||
|
conversationId: number
|
||||||
|
externalId: string | null
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "SET_SYNC_STATE"
|
||||||
|
conversationId: number
|
||||||
|
syncState: ConversationSyncState
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "MIGRATE_CONVERSATION"
|
||||||
|
fromConversationId: number
|
||||||
|
toConversationId: number
|
||||||
|
}
|
||||||
|
| { type: "REMOVE_CONVERSATION"; conversationId: number }
|
||||||
|
| { type: "RESET" }
|
||||||
|
|
||||||
|
function createEmptySession(
|
||||||
|
conversationId: number
|
||||||
|
): ConversationRuntimeSession {
|
||||||
|
return {
|
||||||
|
conversationId,
|
||||||
|
externalId: null,
|
||||||
|
persistedTurns: [],
|
||||||
|
optimisticTurns: [],
|
||||||
|
liveMessage: null,
|
||||||
|
syncState: "idle",
|
||||||
|
activeTurnToken: null,
|
||||||
|
lastHydratedAt: null,
|
||||||
|
lastPersistedAt: null,
|
||||||
|
persistedUpdatedAt: null,
|
||||||
|
persistedMessageCount: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLivePlanEntries(
|
||||||
|
entries: Array<{ content: string; priority: string; status: string }>
|
||||||
|
): string {
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return "Plan updated."
|
||||||
|
}
|
||||||
|
const lines = entries.map(
|
||||||
|
(entry) => `- [${entry.status}] ${entry.content} (${entry.priority})`
|
||||||
|
)
|
||||||
|
return `Plan updated:\n${lines.join("\n")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStreamingTurnFromLiveMessage(
|
||||||
|
conversationId: number,
|
||||||
|
liveMessage: LiveMessage
|
||||||
|
): MessageTurn | null {
|
||||||
|
const blocks: MessageTurn["blocks"] = []
|
||||||
|
|
||||||
|
for (const block of liveMessage.content) {
|
||||||
|
switch (block.type) {
|
||||||
|
case "text":
|
||||||
|
if (block.text.length > 0) {
|
||||||
|
blocks.push({ type: "text", text: block.text })
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case "thinking":
|
||||||
|
if (block.text.length > 0) {
|
||||||
|
blocks.push({ type: "thinking", text: block.text })
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case "plan": {
|
||||||
|
blocks.push({
|
||||||
|
type: "thinking",
|
||||||
|
text: formatLivePlanEntries(block.entries),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "tool_call": {
|
||||||
|
const toolName = inferLiveToolName({
|
||||||
|
title: block.info.title,
|
||||||
|
kind: block.info.kind,
|
||||||
|
rawInput: block.info.raw_input,
|
||||||
|
})
|
||||||
|
blocks.push({
|
||||||
|
type: "tool_use",
|
||||||
|
tool_use_id: block.info.tool_call_id,
|
||||||
|
tool_name: toolName,
|
||||||
|
input_preview: block.info.raw_input,
|
||||||
|
})
|
||||||
|
const isFinalState =
|
||||||
|
block.info.status === "completed" || block.info.status === "failed"
|
||||||
|
if (isFinalState) {
|
||||||
|
blocks.push({
|
||||||
|
type: "tool_result",
|
||||||
|
tool_use_id: block.info.tool_call_id,
|
||||||
|
output_preview: block.info.raw_output ?? block.info.content,
|
||||||
|
is_error: block.info.status === "failed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blocks.length === 0) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `live-${conversationId}-${liveMessage.id}`,
|
||||||
|
role: "assistant",
|
||||||
|
blocks,
|
||||||
|
timestamp: new Date(liveMessage.startedAt).toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldAcceptPersistedSnapshot(
|
||||||
|
current: ConversationRuntimeSession | undefined,
|
||||||
|
detail: DbConversationDetail
|
||||||
|
): boolean {
|
||||||
|
if (!current) return true
|
||||||
|
|
||||||
|
const nextUpdatedAt = detail.summary.updated_at ?? null
|
||||||
|
const nextMessageCount = detail.summary.message_count
|
||||||
|
const nextTurnCount = detail.turns.length
|
||||||
|
|
||||||
|
if (nextMessageCount < current.persistedMessageCount) return false
|
||||||
|
if (nextTurnCount < current.persistedTurns.length) return false
|
||||||
|
if (!current.persistedUpdatedAt || !nextUpdatedAt) return true
|
||||||
|
if (nextUpdatedAt < current.persistedUpdatedAt) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertExternalIdIndex(
|
||||||
|
index: Map<string, number>,
|
||||||
|
previousExternalId: string | null,
|
||||||
|
nextExternalId: string | null,
|
||||||
|
conversationId: number
|
||||||
|
): Map<string, number> {
|
||||||
|
const next = new Map(index)
|
||||||
|
if (previousExternalId) {
|
||||||
|
next.delete(previousExternalId)
|
||||||
|
}
|
||||||
|
if (nextExternalId) {
|
||||||
|
next.set(nextExternalId, conversationId)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function reduceHydrateDetail(
|
||||||
|
state: ConversationRuntimeState,
|
||||||
|
conversationId: number,
|
||||||
|
detail: DbConversationDetail
|
||||||
|
): ConversationRuntimeState {
|
||||||
|
const current = state.byConversationId.get(conversationId)
|
||||||
|
const nextExternalId = detail.summary.external_id ?? null
|
||||||
|
const acceptSnapshot = shouldAcceptPersistedSnapshot(current, detail)
|
||||||
|
const optimisticTurns = current?.optimisticTurns ?? []
|
||||||
|
const persistedTurns = acceptSnapshot
|
||||||
|
? detail.turns
|
||||||
|
: (current?.persistedTurns ?? [])
|
||||||
|
const nextPersistedUpdatedAt = acceptSnapshot
|
||||||
|
? (detail.summary.updated_at ?? null)
|
||||||
|
: (current?.persistedUpdatedAt ?? null)
|
||||||
|
const nextPersistedMessageCount = acceptSnapshot
|
||||||
|
? detail.summary.message_count
|
||||||
|
: (current?.persistedMessageCount ?? 0)
|
||||||
|
const shouldDropOptimistic =
|
||||||
|
optimisticTurns.length > 0 &&
|
||||||
|
persistedTurns.length >= (current?.persistedTurns.length ?? 0) + 1
|
||||||
|
|
||||||
|
const nextSession: ConversationRuntimeSession = {
|
||||||
|
...(current ?? createEmptySession(conversationId)),
|
||||||
|
externalId: nextExternalId,
|
||||||
|
persistedTurns,
|
||||||
|
optimisticTurns: shouldDropOptimistic ? [] : optimisticTurns,
|
||||||
|
syncState: shouldDropOptimistic ? "idle" : (current?.syncState ?? "idle"),
|
||||||
|
activeTurnToken: shouldDropOptimistic
|
||||||
|
? null
|
||||||
|
: (current?.activeTurnToken ?? null),
|
||||||
|
lastHydratedAt: Date.now(),
|
||||||
|
lastPersistedAt: acceptSnapshot
|
||||||
|
? Date.now()
|
||||||
|
: (current?.lastPersistedAt ?? null),
|
||||||
|
persistedUpdatedAt: nextPersistedUpdatedAt,
|
||||||
|
persistedMessageCount: nextPersistedMessageCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextByConversationId = new Map(state.byConversationId)
|
||||||
|
nextByConversationId.set(conversationId, nextSession)
|
||||||
|
const nextExternalIndex = upsertExternalIdIndex(
|
||||||
|
state.conversationIdByExternalId,
|
||||||
|
current?.externalId ?? null,
|
||||||
|
nextExternalId,
|
||||||
|
conversationId
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
byConversationId: nextByConversationId,
|
||||||
|
conversationIdByExternalId: nextExternalIndex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reducer(
|
||||||
|
state: ConversationRuntimeState,
|
||||||
|
action: Action
|
||||||
|
): ConversationRuntimeState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "HYDRATE_FROM_DETAIL":
|
||||||
|
return reduceHydrateDetail(state, action.detail.summary.id, action.detail)
|
||||||
|
|
||||||
|
case "APPEND_OPTIMISTIC_TURN": {
|
||||||
|
const current =
|
||||||
|
state.byConversationId.get(action.conversationId) ??
|
||||||
|
createEmptySession(action.conversationId)
|
||||||
|
const nextSession: ConversationRuntimeSession = {
|
||||||
|
...current,
|
||||||
|
optimisticTurns: [...current.optimisticTurns, action.turn],
|
||||||
|
syncState: "awaiting_persist",
|
||||||
|
activeTurnToken: action.turnToken,
|
||||||
|
}
|
||||||
|
const nextByConversationId = new Map(state.byConversationId)
|
||||||
|
nextByConversationId.set(action.conversationId, nextSession)
|
||||||
|
return { ...state, byConversationId: nextByConversationId }
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_LIVE_MESSAGE": {
|
||||||
|
const current =
|
||||||
|
state.byConversationId.get(action.conversationId) ??
|
||||||
|
createEmptySession(action.conversationId)
|
||||||
|
const nextSession: ConversationRuntimeSession = {
|
||||||
|
...current,
|
||||||
|
liveMessage: action.liveMessage,
|
||||||
|
}
|
||||||
|
const nextByConversationId = new Map(state.byConversationId)
|
||||||
|
nextByConversationId.set(action.conversationId, nextSession)
|
||||||
|
return { ...state, byConversationId: nextByConversationId }
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ACK_PERSISTED_DETAIL": {
|
||||||
|
const nextState = reduceHydrateDetail(
|
||||||
|
state,
|
||||||
|
action.conversationId,
|
||||||
|
action.detail
|
||||||
|
)
|
||||||
|
const session = nextState.byConversationId.get(action.conversationId)
|
||||||
|
if (!session) return nextState
|
||||||
|
const nextSession: ConversationRuntimeSession = {
|
||||||
|
...session,
|
||||||
|
syncState: "idle",
|
||||||
|
activeTurnToken:
|
||||||
|
action.turnToken != null &&
|
||||||
|
action.turnToken === session.activeTurnToken
|
||||||
|
? null
|
||||||
|
: session.activeTurnToken,
|
||||||
|
}
|
||||||
|
const nextByConversationId = new Map(nextState.byConversationId)
|
||||||
|
nextByConversationId.set(action.conversationId, nextSession)
|
||||||
|
return { ...nextState, byConversationId: nextByConversationId }
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_EXTERNAL_ID": {
|
||||||
|
const current =
|
||||||
|
state.byConversationId.get(action.conversationId) ??
|
||||||
|
createEmptySession(action.conversationId)
|
||||||
|
const nextSession: ConversationRuntimeSession = {
|
||||||
|
...current,
|
||||||
|
externalId: action.externalId,
|
||||||
|
}
|
||||||
|
const nextByConversationId = new Map(state.byConversationId)
|
||||||
|
nextByConversationId.set(action.conversationId, nextSession)
|
||||||
|
const nextExternalIndex = upsertExternalIdIndex(
|
||||||
|
state.conversationIdByExternalId,
|
||||||
|
current.externalId,
|
||||||
|
action.externalId,
|
||||||
|
action.conversationId
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
byConversationId: nextByConversationId,
|
||||||
|
conversationIdByExternalId: nextExternalIndex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_SYNC_STATE": {
|
||||||
|
const current =
|
||||||
|
state.byConversationId.get(action.conversationId) ??
|
||||||
|
createEmptySession(action.conversationId)
|
||||||
|
const nextSession: ConversationRuntimeSession = {
|
||||||
|
...current,
|
||||||
|
syncState: action.syncState,
|
||||||
|
}
|
||||||
|
const nextByConversationId = new Map(state.byConversationId)
|
||||||
|
nextByConversationId.set(action.conversationId, nextSession)
|
||||||
|
return { ...state, byConversationId: nextByConversationId }
|
||||||
|
}
|
||||||
|
|
||||||
|
case "MIGRATE_CONVERSATION": {
|
||||||
|
if (action.fromConversationId === action.toConversationId) return state
|
||||||
|
const from = state.byConversationId.get(action.fromConversationId)
|
||||||
|
if (!from) return state
|
||||||
|
const to =
|
||||||
|
state.byConversationId.get(action.toConversationId) ??
|
||||||
|
createEmptySession(action.toConversationId)
|
||||||
|
|
||||||
|
const preferFromSnapshot =
|
||||||
|
from.persistedTurns.length >= to.persistedTurns.length
|
||||||
|
|
||||||
|
const merged: ConversationRuntimeSession = {
|
||||||
|
...to,
|
||||||
|
...from,
|
||||||
|
conversationId: action.toConversationId,
|
||||||
|
persistedTurns: preferFromSnapshot
|
||||||
|
? from.persistedTurns
|
||||||
|
: to.persistedTurns,
|
||||||
|
optimisticTurns: [...from.optimisticTurns, ...to.optimisticTurns],
|
||||||
|
liveMessage: to.liveMessage ?? from.liveMessage,
|
||||||
|
syncState: to.syncState !== "idle" ? to.syncState : from.syncState,
|
||||||
|
activeTurnToken: to.activeTurnToken ?? from.activeTurnToken,
|
||||||
|
lastHydratedAt:
|
||||||
|
Math.max(from.lastHydratedAt ?? 0, to.lastHydratedAt ?? 0) || null,
|
||||||
|
lastPersistedAt:
|
||||||
|
Math.max(from.lastPersistedAt ?? 0, to.lastPersistedAt ?? 0) || null,
|
||||||
|
persistedUpdatedAt:
|
||||||
|
(to.persistedUpdatedAt ?? "") > (from.persistedUpdatedAt ?? "")
|
||||||
|
? to.persistedUpdatedAt
|
||||||
|
: from.persistedUpdatedAt,
|
||||||
|
persistedMessageCount: Math.max(
|
||||||
|
from.persistedMessageCount,
|
||||||
|
to.persistedMessageCount
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextByConversationId = new Map(state.byConversationId)
|
||||||
|
nextByConversationId.delete(action.fromConversationId)
|
||||||
|
nextByConversationId.set(action.toConversationId, merged)
|
||||||
|
|
||||||
|
const nextExternalIndex = new Map(state.conversationIdByExternalId)
|
||||||
|
for (const [externalId, conversationId] of nextExternalIndex.entries()) {
|
||||||
|
if (conversationId === action.fromConversationId) {
|
||||||
|
nextExternalIndex.set(externalId, action.toConversationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (merged.externalId) {
|
||||||
|
nextExternalIndex.set(merged.externalId, action.toConversationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
byConversationId: nextByConversationId,
|
||||||
|
conversationIdByExternalId: nextExternalIndex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "REMOVE_CONVERSATION": {
|
||||||
|
const current = state.byConversationId.get(action.conversationId)
|
||||||
|
if (!current) return state
|
||||||
|
const nextByConversationId = new Map(state.byConversationId)
|
||||||
|
nextByConversationId.delete(action.conversationId)
|
||||||
|
const nextExternalIndex = new Map(state.conversationIdByExternalId)
|
||||||
|
if (current.externalId) {
|
||||||
|
nextExternalIndex.delete(current.externalId)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
byConversationId: nextByConversationId,
|
||||||
|
conversationIdByExternalId: nextExternalIndex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "RESET":
|
||||||
|
return initialState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConversationRuntimeContextValue {
|
||||||
|
getSession: (conversationId: number) => ConversationRuntimeSession | null
|
||||||
|
getConversationIdByExternalId: (externalId: string) => number | null
|
||||||
|
getTimelineTurns: (conversationId: number) => ConversationTimelineTurn[]
|
||||||
|
hydrateFromDetail: (detail: DbConversationDetail) => void
|
||||||
|
appendOptimisticTurn: (
|
||||||
|
conversationId: number,
|
||||||
|
turn: MessageTurn,
|
||||||
|
turnToken: string
|
||||||
|
) => void
|
||||||
|
setLiveMessage: (
|
||||||
|
conversationId: number,
|
||||||
|
liveMessage: LiveMessage | null
|
||||||
|
) => void
|
||||||
|
acknowledgePersistedDetail: (
|
||||||
|
conversationId: number,
|
||||||
|
detail: DbConversationDetail,
|
||||||
|
turnToken?: string | null
|
||||||
|
) => void
|
||||||
|
setExternalId: (conversationId: number, externalId: string | null) => void
|
||||||
|
setSyncState: (
|
||||||
|
conversationId: number,
|
||||||
|
syncState: ConversationSyncState
|
||||||
|
) => void
|
||||||
|
migrateConversation: (
|
||||||
|
fromConversationId: number,
|
||||||
|
toConversationId: number
|
||||||
|
) => void
|
||||||
|
removeConversation: (conversationId: number) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConversationRuntimeContext =
|
||||||
|
createContext<ConversationRuntimeContextValue | null>(null)
|
||||||
|
|
||||||
|
export function ConversationRuntimeProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState)
|
||||||
|
|
||||||
|
const getSession = useCallback(
|
||||||
|
(conversationId: number) =>
|
||||||
|
state.byConversationId.get(conversationId) ?? null,
|
||||||
|
[state.byConversationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const getConversationIdByExternalId = useCallback(
|
||||||
|
(externalId: string) =>
|
||||||
|
state.conversationIdByExternalId.get(externalId) ?? null,
|
||||||
|
[state.conversationIdByExternalId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const getTimelineTurns = useCallback(
|
||||||
|
(conversationId: number): ConversationTimelineTurn[] => {
|
||||||
|
const session = state.byConversationId.get(conversationId)
|
||||||
|
if (!session) return []
|
||||||
|
|
||||||
|
const persisted: ConversationTimelineTurn[] = session.persistedTurns.map(
|
||||||
|
(turn, index) => ({
|
||||||
|
key: `persisted-${conversationId}-${turn.id}-${index}`,
|
||||||
|
turn,
|
||||||
|
phase: "persisted",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const optimistic: ConversationTimelineTurn[] =
|
||||||
|
session.optimisticTurns.map((turn, index) => ({
|
||||||
|
key: `optimistic-${conversationId}-${turn.id}-${index}`,
|
||||||
|
turn,
|
||||||
|
phase: "optimistic",
|
||||||
|
}))
|
||||||
|
const streamingMessage = session.liveMessage
|
||||||
|
const streamingTurn = streamingMessage
|
||||||
|
? buildStreamingTurnFromLiveMessage(conversationId, streamingMessage)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!streamingTurn) {
|
||||||
|
return [...persisted, ...optimistic]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...persisted,
|
||||||
|
...optimistic,
|
||||||
|
{
|
||||||
|
key: `streaming-${conversationId}-${streamingMessage?.id ?? "unknown"}`,
|
||||||
|
turn: streamingTurn,
|
||||||
|
phase: "streaming",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
[state.byConversationId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const hydrateFromDetail = useCallback((detail: DbConversationDetail) => {
|
||||||
|
dispatch({ type: "HYDRATE_FROM_DETAIL", detail })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const appendOptimisticTurn = useCallback(
|
||||||
|
(conversationId: number, turn: MessageTurn, turnToken: string) => {
|
||||||
|
dispatch({
|
||||||
|
type: "APPEND_OPTIMISTIC_TURN",
|
||||||
|
conversationId,
|
||||||
|
turn,
|
||||||
|
turnToken,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const setLiveMessage = useCallback(
|
||||||
|
(conversationId: number, liveMessage: LiveMessage | null) => {
|
||||||
|
dispatch({ type: "SET_LIVE_MESSAGE", conversationId, liveMessage })
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const acknowledgePersistedDetail = useCallback(
|
||||||
|
(
|
||||||
|
conversationId: number,
|
||||||
|
detail: DbConversationDetail,
|
||||||
|
turnToken?: string | null
|
||||||
|
) => {
|
||||||
|
dispatch({
|
||||||
|
type: "ACK_PERSISTED_DETAIL",
|
||||||
|
conversationId,
|
||||||
|
detail,
|
||||||
|
turnToken,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const setExternalId = useCallback(
|
||||||
|
(conversationId: number, externalId: string | null) => {
|
||||||
|
dispatch({ type: "SET_EXTERNAL_ID", conversationId, externalId })
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const setSyncState = useCallback(
|
||||||
|
(conversationId: number, syncState: ConversationSyncState) => {
|
||||||
|
dispatch({ type: "SET_SYNC_STATE", conversationId, syncState })
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const migrateConversation = useCallback(
|
||||||
|
(fromConversationId: number, toConversationId: number) => {
|
||||||
|
dispatch({
|
||||||
|
type: "MIGRATE_CONVERSATION",
|
||||||
|
fromConversationId,
|
||||||
|
toConversationId,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeConversation = useCallback((conversationId: number) => {
|
||||||
|
dispatch({ type: "REMOVE_CONVERSATION", conversationId })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
dispatch({ type: "RESET" })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo<ConversationRuntimeContextValue>(
|
||||||
|
() => ({
|
||||||
|
getSession,
|
||||||
|
getConversationIdByExternalId,
|
||||||
|
getTimelineTurns,
|
||||||
|
hydrateFromDetail,
|
||||||
|
appendOptimisticTurn,
|
||||||
|
setLiveMessage,
|
||||||
|
acknowledgePersistedDetail,
|
||||||
|
setExternalId,
|
||||||
|
setSyncState,
|
||||||
|
migrateConversation,
|
||||||
|
removeConversation,
|
||||||
|
reset,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
getSession,
|
||||||
|
getConversationIdByExternalId,
|
||||||
|
getTimelineTurns,
|
||||||
|
hydrateFromDetail,
|
||||||
|
appendOptimisticTurn,
|
||||||
|
setLiveMessage,
|
||||||
|
acknowledgePersistedDetail,
|
||||||
|
setExternalId,
|
||||||
|
setSyncState,
|
||||||
|
migrateConversation,
|
||||||
|
removeConversation,
|
||||||
|
reset,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConversationRuntimeContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ConversationRuntimeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConversationRuntime() {
|
||||||
|
const ctx = useContext(ConversationRuntimeContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
"useConversationRuntime must be used within ConversationRuntimeProvider"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -22,8 +22,8 @@ import type {
|
|||||||
|
|
||||||
interface TabItemInternal {
|
interface TabItemInternal {
|
||||||
id: string
|
id: string
|
||||||
kind: "conversation" | "new_conversation"
|
kind: "conversation"
|
||||||
conversationId?: number
|
conversationId: number | null
|
||||||
agentType: AgentType
|
agentType: AgentType
|
||||||
title: string
|
title: string
|
||||||
isPinned: boolean
|
isPinned: boolean
|
||||||
@@ -43,18 +43,13 @@ interface TabContextValue {
|
|||||||
title?: string
|
title?: string
|
||||||
) => void
|
) => void
|
||||||
closeTab: (tabId: string) => void
|
closeTab: (tabId: string) => void
|
||||||
|
closeConversationTab: (conversationId: number, agentType: AgentType) => void
|
||||||
closeOtherTabs: (tabId: string) => void
|
closeOtherTabs: (tabId: string) => void
|
||||||
closeAllTabs: () => void
|
closeAllTabs: () => void
|
||||||
switchTab: (tabId: string) => void
|
switchTab: (tabId: string) => void
|
||||||
pinTab: (tabId: string) => void
|
pinTab: (tabId: string) => void
|
||||||
openNewConversationTab: (agentType: AgentType, workingDir: string) => void
|
openNewConversationTab: (agentType: AgentType, workingDir: string) => void
|
||||||
promoteNewConversationTab: (
|
bindConversationTab: (
|
||||||
tabId: string,
|
|
||||||
conversationId: number,
|
|
||||||
agentType: AgentType,
|
|
||||||
title: string
|
|
||||||
) => void
|
|
||||||
linkTabConversation: (
|
|
||||||
tabId: string,
|
tabId: string,
|
||||||
conversationId: number,
|
conversationId: number,
|
||||||
agentType: AgentType,
|
agentType: AgentType,
|
||||||
@@ -83,13 +78,6 @@ function makeConversationTabId(
|
|||||||
function makeNewConversationTabId(): string {
|
function makeNewConversationTabId(): string {
|
||||||
return `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
return `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a tab that represents the given conversation, regardless of whether
|
|
||||||
* it has been promoted to a canonical id yet. Checks canonical id first,
|
|
||||||
* then falls back to matching by conversationId + agentType (covers the
|
|
||||||
* linked-but-not-yet-promoted new_conversation tabs).
|
|
||||||
*/
|
|
||||||
function findTabIndexForConversation(
|
function findTabIndexForConversation(
|
||||||
tabs: TabItemInternal[],
|
tabs: TabItemInternal[],
|
||||||
agentType: AgentType,
|
agentType: AgentType,
|
||||||
@@ -130,7 +118,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: tabId,
|
id: tabId,
|
||||||
kind: "conversation" as const,
|
kind: "conversation",
|
||||||
conversationId: selectedConversation.id,
|
conversationId: selectedConversation.id,
|
||||||
agentType: selectedConversation.agentType,
|
agentType: selectedConversation.agentType,
|
||||||
title: t("loadingConversation"),
|
title: t("loadingConversation"),
|
||||||
@@ -187,7 +175,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
|
|
||||||
const restoredTabs: TabItemInternal[] = opened.map((oc) => ({
|
const restoredTabs: TabItemInternal[] = opened.map((oc) => ({
|
||||||
id: makeConversationTabId(oc.agent_type, oc.conversation_id),
|
id: makeConversationTabId(oc.agent_type, oc.conversation_id),
|
||||||
kind: "conversation" as const,
|
kind: "conversation",
|
||||||
conversationId: oc.conversation_id,
|
conversationId: oc.conversation_id,
|
||||||
agentType: oc.agent_type,
|
agentType: oc.agent_type,
|
||||||
title: t("loadingConversation"),
|
title: t("loadingConversation"),
|
||||||
@@ -300,13 +288,20 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
cancelNewConversation()
|
cancelNewConversation()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (tab.kind === "conversation" && tab.conversationId != null) {
|
if (tab.conversationId != null) {
|
||||||
selectConversation(tab.conversationId, tab.agentType)
|
selectConversation(tab.conversationId, tab.agentType)
|
||||||
} else if (tab.kind === "new_conversation" && tab.workingDir) {
|
} else {
|
||||||
startNewConversation(tab.agentType, tab.workingDir)
|
const workingDir = tab.workingDir ?? folder?.path
|
||||||
|
if (!workingDir) {
|
||||||
|
clearSelection()
|
||||||
|
cancelNewConversation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startNewConversation(tab.agentType, workingDir)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
folder?.path,
|
||||||
selectConversation,
|
selectConversation,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
startNewConversation,
|
startNewConversation,
|
||||||
@@ -386,10 +381,11 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
[activateConversationPane, selectConversation, t]
|
[activateConversationPane, selectConversation, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
const makeReplacementNewConversationTab = useCallback(
|
const makeReplacementDraftTab = useCallback(
|
||||||
(preferred?: TabItemInternal): TabItemInternal => ({
|
(preferred?: TabItemInternal): TabItemInternal => ({
|
||||||
id: makeNewConversationTabId(),
|
id: makeNewConversationTabId(),
|
||||||
kind: "new_conversation",
|
kind: "conversation",
|
||||||
|
conversationId: null,
|
||||||
agentType: preferred?.agentType ?? "codex",
|
agentType: preferred?.agentType ?? "codex",
|
||||||
title: t("newConversation"),
|
title: t("newConversation"),
|
||||||
isPinned: true,
|
isPinned: true,
|
||||||
@@ -410,7 +406,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
const next = prev.filter((t) => t.id !== tabId)
|
const next = prev.filter((t) => t.id !== tabId)
|
||||||
|
|
||||||
if (next.length === 0) {
|
if (next.length === 0) {
|
||||||
const replacementTab = makeReplacementNewConversationTab(closingTab)
|
const replacementTab = makeReplacementDraftTab(closingTab)
|
||||||
neighborToSync = replacementTab
|
neighborToSync = replacementTab
|
||||||
return [replacementTab]
|
return [replacementTab]
|
||||||
}
|
}
|
||||||
@@ -433,11 +429,19 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
activateConversationPane()
|
activateConversationPane()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[activateConversationPane, makeReplacementDraftTab, syncFolderContext]
|
||||||
activateConversationPane,
|
)
|
||||||
makeReplacementNewConversationTab,
|
|
||||||
syncFolderContext,
|
const closeConversationTab = useCallback(
|
||||||
]
|
(conversationId: number, agentType: AgentType) => {
|
||||||
|
const target = rawTabsRef.current.find(
|
||||||
|
(tab) =>
|
||||||
|
tab.conversationId === conversationId && tab.agentType === agentType
|
||||||
|
)
|
||||||
|
if (!target) return
|
||||||
|
closeTab(target.id)
|
||||||
|
},
|
||||||
|
[closeTab]
|
||||||
)
|
)
|
||||||
|
|
||||||
const closeOtherTabs = useCallback(
|
const closeOtherTabs = useCallback(
|
||||||
@@ -459,21 +463,17 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
const closeAllTabs = useCallback(() => {
|
const closeAllTabs = useCallback(() => {
|
||||||
const seedTab =
|
const seedTab =
|
||||||
rawTabsRef.current.find(
|
rawTabsRef.current.find(
|
||||||
(t) => t.kind === "new_conversation" && t.workingDir
|
(t) => t.conversationId == null && t.workingDir
|
||||||
) ??
|
) ??
|
||||||
rawTabsRef.current.find((t) => t.id === activeTabIdRef.current) ??
|
rawTabsRef.current.find((t) => t.id === activeTabIdRef.current) ??
|
||||||
rawTabsRef.current[0]
|
rawTabsRef.current[0]
|
||||||
|
|
||||||
const replacementTab = makeReplacementNewConversationTab(seedTab)
|
const replacementTab = makeReplacementDraftTab(seedTab)
|
||||||
setTabs([replacementTab])
|
setTabs([replacementTab])
|
||||||
setActiveTabId(replacementTab.id)
|
setActiveTabId(replacementTab.id)
|
||||||
syncFolderContext(replacementTab)
|
syncFolderContext(replacementTab)
|
||||||
activateConversationPane()
|
activateConversationPane()
|
||||||
}, [
|
}, [activateConversationPane, makeReplacementDraftTab, syncFolderContext])
|
||||||
activateConversationPane,
|
|
||||||
makeReplacementNewConversationTab,
|
|
||||||
syncFolderContext,
|
|
||||||
])
|
|
||||||
|
|
||||||
const switchTab = useCallback(
|
const switchTab = useCallback(
|
||||||
(tabId: string) => {
|
(tabId: string) => {
|
||||||
@@ -501,10 +501,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
const openNewConversationTab = useCallback(
|
const openNewConversationTab = useCallback(
|
||||||
(agentType: AgentType, workingDir: string) => {
|
(agentType: AgentType, workingDir: string) => {
|
||||||
const existingTab = rawTabsRef.current.find(
|
const existingTab = rawTabsRef.current.find(
|
||||||
(t) =>
|
(t) => t.conversationId == null && t.agentType === agentType
|
||||||
t.kind === "new_conversation" &&
|
|
||||||
t.agentType === agentType &&
|
|
||||||
!t.conversationId
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (existingTab) {
|
if (existingTab) {
|
||||||
@@ -517,7 +514,8 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
const tabId = makeNewConversationTabId()
|
const tabId = makeNewConversationTabId()
|
||||||
const newTab: TabItemInternal = {
|
const newTab: TabItemInternal = {
|
||||||
id: tabId,
|
id: tabId,
|
||||||
kind: "new_conversation",
|
kind: "conversation",
|
||||||
|
conversationId: null,
|
||||||
agentType,
|
agentType,
|
||||||
title: t("newConversation"),
|
title: t("newConversation"),
|
||||||
isPinned: true,
|
isPinned: true,
|
||||||
@@ -532,71 +530,45 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
[activateConversationPane, startNewConversation, syncFolderContext, t]
|
[activateConversationPane, startNewConversation, syncFolderContext, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
const linkTabConversation = useCallback(
|
const bindConversationTab = useCallback(
|
||||||
(
|
(
|
||||||
tabId: string,
|
tabId: string,
|
||||||
conversationId: number,
|
conversationId: number,
|
||||||
agentType: AgentType,
|
agentType: AgentType,
|
||||||
title: string
|
title: string
|
||||||
) => {
|
) => {
|
||||||
|
let nextActiveTabId: string | null = null
|
||||||
setTabs((prev) =>
|
setTabs((prev) =>
|
||||||
prev.map((t) =>
|
prev.flatMap((tab) => {
|
||||||
t.id === tabId ? { ...t, conversationId, agentType, title } : t
|
if (tab.id === tabId) {
|
||||||
)
|
const nextTab = { ...tab, conversationId, agentType, title }
|
||||||
)
|
return [nextTab]
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const promoteNewConversationTab = useCallback(
|
|
||||||
(
|
|
||||||
tabId: string,
|
|
||||||
conversationId: number,
|
|
||||||
agentType: AgentType,
|
|
||||||
title: string
|
|
||||||
) => {
|
|
||||||
let activateId: string | undefined
|
|
||||||
|
|
||||||
setTabs((prev) => {
|
|
||||||
const index = prev.findIndex((t) => t.id === tabId)
|
|
||||||
if (index < 0) return prev
|
|
||||||
|
|
||||||
const newId = makeConversationTabId(agentType, conversationId)
|
|
||||||
|
|
||||||
// Check if a *different* tab already represents this conversation
|
|
||||||
const dupeIndex = findTabIndexForConversation(
|
|
||||||
prev,
|
|
||||||
agentType,
|
|
||||||
conversationId
|
|
||||||
)
|
|
||||||
if (dupeIndex >= 0 && dupeIndex !== index) {
|
|
||||||
activateId = prev[dupeIndex].id
|
|
||||||
return prev.filter((t) => t.id !== tabId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const promoted: TabItemInternal = {
|
if (
|
||||||
...prev[index],
|
tab.conversationId === conversationId &&
|
||||||
id: newId,
|
tab.agentType === agentType
|
||||||
kind: "conversation",
|
) {
|
||||||
conversationId,
|
if (activeTabIdRef.current === tabId) {
|
||||||
agentType,
|
nextActiveTabId = tab.id
|
||||||
title,
|
}
|
||||||
isPinned: true,
|
return []
|
||||||
}
|
}
|
||||||
activateId = newId
|
|
||||||
|
|
||||||
const updated = [...prev]
|
return [tab]
|
||||||
updated[index] = promoted
|
|
||||||
return updated
|
|
||||||
})
|
})
|
||||||
|
)
|
||||||
if (activateId) {
|
if (nextActiveTabId) {
|
||||||
setActiveTabId(activateId)
|
setActiveTabId(nextActiveTabId)
|
||||||
selectConversation(conversationId, agentType)
|
const target = rawTabsRef.current.find(
|
||||||
activateConversationPane()
|
(tab) => tab.id === nextActiveTabId
|
||||||
|
)
|
||||||
|
if (target) {
|
||||||
|
syncFolderContext(target)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activateConversationPane, selectConversation]
|
[syncFolderContext]
|
||||||
)
|
)
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
@@ -605,13 +577,13 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
activeTabId,
|
activeTabId,
|
||||||
openTab,
|
openTab,
|
||||||
closeTab,
|
closeTab,
|
||||||
|
closeConversationTab,
|
||||||
closeOtherTabs,
|
closeOtherTabs,
|
||||||
closeAllTabs,
|
closeAllTabs,
|
||||||
switchTab,
|
switchTab,
|
||||||
pinTab,
|
pinTab,
|
||||||
openNewConversationTab,
|
openNewConversationTab,
|
||||||
promoteNewConversationTab,
|
bindConversationTab,
|
||||||
linkTabConversation,
|
|
||||||
reorderTabs,
|
reorderTabs,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@@ -619,13 +591,13 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
activeTabId,
|
activeTabId,
|
||||||
openTab,
|
openTab,
|
||||||
closeTab,
|
closeTab,
|
||||||
|
closeConversationTab,
|
||||||
closeOtherTabs,
|
closeOtherTabs,
|
||||||
closeAllTabs,
|
closeAllTabs,
|
||||||
switchTab,
|
switchTab,
|
||||||
pinTab,
|
pinTab,
|
||||||
openNewConversationTab,
|
openNewConversationTab,
|
||||||
promoteNewConversationTab,
|
bindConversationTab,
|
||||||
linkTabConversation,
|
|
||||||
reorderTabs,
|
reorderTabs,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ import { useAcpActions } from "@/contexts/acp-connections-context"
|
|||||||
import { useTaskContext } from "@/contexts/task-context"
|
import { useTaskContext } from "@/contexts/task-context"
|
||||||
import { useConnection, type UseConnectionReturn } from "@/hooks/use-connection"
|
import { useConnection, type UseConnectionReturn } from "@/hooks/use-connection"
|
||||||
import { AGENT_LABELS, type AgentType, type PromptDraft } from "@/lib/types"
|
import { AGENT_LABELS, type AgentType, type PromptDraft } from "@/lib/types"
|
||||||
import { getPromptDraftDisplayText } from "@/lib/prompt-draft"
|
|
||||||
import {
|
|
||||||
clearPendingPromptText,
|
|
||||||
setPendingPromptText,
|
|
||||||
} from "@/lib/pending-prompt-text"
|
|
||||||
|
|
||||||
interface UseConnectionLifecycleOptions {
|
interface UseConnectionLifecycleOptions {
|
||||||
contextKey: string
|
contextKey: string
|
||||||
@@ -50,7 +45,6 @@ export function useConnectionLifecycle({
|
|||||||
sessionId,
|
sessionId,
|
||||||
}: UseConnectionLifecycleOptions): UseConnectionLifecycleReturn {
|
}: UseConnectionLifecycleOptions): UseConnectionLifecycleReturn {
|
||||||
const t = useTranslations("Folder.chat.connectionLifecycle")
|
const t = useTranslations("Folder.chat.connectionLifecycle")
|
||||||
const sharedT = useTranslations("Folder.chat.shared")
|
|
||||||
const { setActiveKey, touchActivity } = useAcpActions()
|
const { setActiveKey, touchActivity } = useAcpActions()
|
||||||
const { addTask, updateTask, removeTask } = useTaskContext()
|
const { addTask, updateTask, removeTask } = useTaskContext()
|
||||||
const conn = useConnection(contextKey)
|
const conn = useConnection(contextKey)
|
||||||
@@ -201,11 +195,6 @@ export function useConnectionLifecycle({
|
|||||||
}
|
}
|
||||||
}, [status, addTask, updateTask, removeTask, agentType, t])
|
}, [status, addTask, updateTask, removeTask, agentType, t])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (status === "prompting") return
|
|
||||||
clearPendingPromptText(contextKey)
|
|
||||||
}, [status, contextKey])
|
|
||||||
|
|
||||||
const clearSelectorTask = useCallback(() => {
|
const clearSelectorTask = useCallback(() => {
|
||||||
if (selectorTaskTimeoutRef.current) {
|
if (selectorTaskTimeoutRef.current) {
|
||||||
clearTimeout(selectorTaskTimeoutRef.current)
|
clearTimeout(selectorTaskTimeoutRef.current)
|
||||||
@@ -313,10 +302,6 @@ export function useConnectionLifecycle({
|
|||||||
const handleSend = useCallback(
|
const handleSend = useCallback(
|
||||||
(draft: PromptDraft, modeId?: string | null) => {
|
(draft: PromptDraft, modeId?: string | null) => {
|
||||||
touchActivity(contextKey)
|
touchActivity(contextKey)
|
||||||
setPendingPromptText(
|
|
||||||
contextKey,
|
|
||||||
getPromptDraftDisplayText(draft, sharedT("attachedResources"))
|
|
||||||
)
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const currentModeId = modeIdRef.current
|
const currentModeId = modeIdRef.current
|
||||||
if (modeId && modeId !== currentModeId) {
|
if (modeId && modeId !== currentModeId) {
|
||||||
@@ -330,7 +315,7 @@ export function useConnectionLifecycle({
|
|||||||
console.error("[ConnLifecycle] sendPrompt:", e)
|
console.error("[ConnLifecycle] sendPrompt:", e)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[connSetMode, sendPrompt, contextKey, touchActivity, sharedT]
|
[connSetMode, sendPrompt, contextKey, touchActivity]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
|
|||||||
@@ -91,8 +91,22 @@ interface State {
|
|||||||
fetchSeq: number
|
fetchSeq: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isVirtualConversationId(conversationId: number): boolean {
|
||||||
|
return !Number.isFinite(conversationId) || conversationId <= 0
|
||||||
|
}
|
||||||
|
|
||||||
export function useDbMessageDetail(conversationId: number) {
|
export function useDbMessageDetail(conversationId: number) {
|
||||||
|
const isVirtualId = isVirtualConversationId(conversationId)
|
||||||
const getCachedState = useCallback((id: number): State => {
|
const getCachedState = useCallback((id: number): State => {
|
||||||
|
if (isVirtualConversationId(id)) {
|
||||||
|
return {
|
||||||
|
key: id,
|
||||||
|
detail: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
fetchSeq: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
const cached = detailCache.get(id)
|
const cached = detailCache.get(id)
|
||||||
return {
|
return {
|
||||||
key: id,
|
key: id,
|
||||||
@@ -110,19 +124,24 @@ export function useDbMessageDetail(conversationId: number) {
|
|||||||
const derivedState =
|
const derivedState =
|
||||||
state.key === conversationId ? state : getCachedState(conversationId)
|
state.key === conversationId ? state : getCachedState(conversationId)
|
||||||
|
|
||||||
useEffect(
|
useEffect(() => {
|
||||||
() =>
|
if (isVirtualId) return
|
||||||
subscribeDetail(conversationId, (detail) => {
|
return subscribeDetail(conversationId, (detail) => {
|
||||||
setState((prev) =>
|
setState((prev) => ({
|
||||||
prev.key === conversationId
|
key: conversationId,
|
||||||
? { ...prev, detail, loading: false, error: null }
|
detail,
|
||||||
: prev
|
loading: false,
|
||||||
)
|
error: null,
|
||||||
}),
|
fetchSeq: prev.key === conversationId ? prev.fetchSeq : 0,
|
||||||
[conversationId]
|
}))
|
||||||
)
|
})
|
||||||
|
}, [conversationId, isVirtualId])
|
||||||
|
|
||||||
const refetch = useCallback(() => {
|
const refetch = useCallback(() => {
|
||||||
|
if (isVirtualConversationId(conversationId)) {
|
||||||
|
setState(getCachedState(conversationId))
|
||||||
|
return
|
||||||
|
}
|
||||||
detailCache.delete(conversationId)
|
detailCache.delete(conversationId)
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
const base =
|
const base =
|
||||||
@@ -138,6 +157,7 @@ export function useDbMessageDetail(conversationId: number) {
|
|||||||
}, [conversationId, getCachedState])
|
}, [conversationId, getCachedState])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isVirtualId) return
|
||||||
// Skip fetch if cache already has data
|
// Skip fetch if cache already has data
|
||||||
if (detailCache.has(conversationId)) return
|
if (detailCache.has(conversationId)) return
|
||||||
|
|
||||||
@@ -180,7 +200,7 @@ export function useDbMessageDetail(conversationId: number) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [conversationId, derivedState.fetchSeq])
|
}, [conversationId, derivedState.fetchSeq, isVirtualId])
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
const pendingPromptTextByContextKey = new Map<string, string>()
|
|
||||||
|
|
||||||
export function setPendingPromptText(contextKey: string, text: string): void {
|
|
||||||
const normalized = text.trim()
|
|
||||||
if (!normalized) {
|
|
||||||
pendingPromptTextByContextKey.delete(contextKey)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pendingPromptTextByContextKey.set(contextKey, normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPendingPromptText(contextKey: string): string | null {
|
|
||||||
return pendingPromptTextByContextKey.get(contextKey) ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearPendingPromptText(contextKey: string): void {
|
|
||||||
pendingPromptTextByContextKey.delete(contextKey)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user