重构会话消息处理和显示逻辑
This commit is contained in:
@@ -10,6 +10,7 @@ import { FolderProvider } from "@/contexts/folder-context"
|
||||
import { TaskProvider } from "@/contexts/task-context"
|
||||
import { AlertProvider } from "@/contexts/alert-context"
|
||||
import { AcpConnectionsProvider } from "@/contexts/acp-connections-context"
|
||||
import { ConversationRuntimeProvider } from "@/contexts/conversation-runtime-context"
|
||||
import { TabProvider } from "@/contexts/tab-context"
|
||||
import { SessionStatsProvider } from "@/contexts/session-stats-context"
|
||||
import { SidebarProvider, useSidebarContext } from "@/contexts/sidebar-context"
|
||||
@@ -643,36 +644,38 @@ function FolderLayoutInner({ children }: { children: React.ReactNode }) {
|
||||
<AlertProvider>
|
||||
<TaskProvider>
|
||||
<AcpConnectionsProvider>
|
||||
<WorkspaceProvider key={`workspace-${normalizedFolderId}`}>
|
||||
<TabProvider>
|
||||
<SessionStatsProvider>
|
||||
<SidebarProvider
|
||||
key={`left-sidebar-${normalizedFolderId}`}
|
||||
folderId={normalizedFolderId}
|
||||
>
|
||||
<AuxPanelProvider
|
||||
key={`right-sidebar-${normalizedFolderId}`}
|
||||
<ConversationRuntimeProvider>
|
||||
<WorkspaceProvider key={`workspace-${normalizedFolderId}`}>
|
||||
<TabProvider>
|
||||
<SessionStatsProvider>
|
||||
<SidebarProvider
|
||||
key={`left-sidebar-${normalizedFolderId}`}
|
||||
folderId={normalizedFolderId}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<div className="flex h-screen flex-col overflow-hidden">
|
||||
<FolderTitleBar />
|
||||
<FolderWorkspaceShell>
|
||||
{children}
|
||||
</FolderWorkspaceShell>
|
||||
<StatusBar />
|
||||
<AppToaster
|
||||
position="bottom-right"
|
||||
duration={TOAST_DURATION_MS}
|
||||
closeButton
|
||||
/>
|
||||
</div>
|
||||
</TerminalProvider>
|
||||
</AuxPanelProvider>
|
||||
</SidebarProvider>
|
||||
</SessionStatsProvider>
|
||||
</TabProvider>
|
||||
</WorkspaceProvider>
|
||||
<AuxPanelProvider
|
||||
key={`right-sidebar-${normalizedFolderId}`}
|
||||
folderId={normalizedFolderId}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<div className="flex h-screen flex-col overflow-hidden">
|
||||
<FolderTitleBar />
|
||||
<FolderWorkspaceShell>
|
||||
{children}
|
||||
</FolderWorkspaceShell>
|
||||
<StatusBar />
|
||||
<AppToaster
|
||||
position="bottom-right"
|
||||
duration={TOAST_DURATION_MS}
|
||||
closeButton
|
||||
/>
|
||||
</div>
|
||||
</TerminalProvider>
|
||||
</AuxPanelProvider>
|
||||
</SidebarProvider>
|
||||
</SessionStatsProvider>
|
||||
</TabProvider>
|
||||
</WorkspaceProvider>
|
||||
</ConversationRuntimeProvider>
|
||||
</AcpConnectionsProvider>
|
||||
</TaskProvider>
|
||||
</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 { MessageListView } from "@/components/message/message-list-view"
|
||||
import { ConversationShell } from "@/components/chat/conversation-shell"
|
||||
import { WelcomeInputPanel } from "@/components/chat/welcome-input-panel"
|
||||
import { updateConversationStatus } from "@/lib/tauri"
|
||||
import { AgentSelector } from "@/components/chat/agent-selector"
|
||||
import {
|
||||
createConversation,
|
||||
openSettingsWindow,
|
||||
updateConversationExternalId,
|
||||
updateConversationStatus,
|
||||
} from "@/lib/tauri"
|
||||
import { useConversationRuntime } from "@/contexts/conversation-runtime-context"
|
||||
import {
|
||||
invalidateDetailCache,
|
||||
refreshDetailCache,
|
||||
useDbMessageDetail,
|
||||
warmupDetailCache,
|
||||
} from "@/hooks/use-db-message-detail"
|
||||
import type { AcpEvent, AgentType, PromptDraft } from "@/lib/types"
|
||||
import type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter"
|
||||
import {
|
||||
buildUserMessageTextPartsFromDraft,
|
||||
extractUserImagesFromDraft,
|
||||
extractUserResourcesFromDraft,
|
||||
getPromptDraftDisplayText,
|
||||
} 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 {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -32,62 +48,184 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
|
||||
interface ExistingConversationViewProps {
|
||||
interface ConversationTabViewProps {
|
||||
tabId: string
|
||||
conversationId: number
|
||||
conversationId: number | null
|
||||
agentType: AgentType
|
||||
workingDir?: string
|
||||
isActive: boolean
|
||||
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,
|
||||
conversationId,
|
||||
agentType,
|
||||
workingDir,
|
||||
isActive,
|
||||
reloadSignal,
|
||||
}: ExistingConversationViewProps) {
|
||||
}: ConversationTabViewProps) {
|
||||
const t = useTranslations("Folder.conversation")
|
||||
const tWelcome = useTranslations("Folder.chat.welcomeInputPanel")
|
||||
const sharedT = useTranslations("Folder.chat.shared")
|
||||
const { refreshConversations, folder } = useFolderContext()
|
||||
const contextKey = `conv-${agentType}-${conversationId}`
|
||||
|
||||
// Get external_id to resume existing agent session via LoadSessionRequest.
|
||||
// Gate workingDir on loading so auto-connect waits for sessionId to resolve.
|
||||
const { folder, folderId, refreshConversations } = useFolderContext()
|
||||
const { bindConversationTab } = useTabContext()
|
||||
const {
|
||||
detail,
|
||||
loading: detailLoading,
|
||||
error: detailError,
|
||||
refetch: refetchConversationDetail,
|
||||
} = useDbMessageDetail(conversationId)
|
||||
const externalId = detail?.summary.external_id ?? undefined
|
||||
acknowledgePersistedDetail,
|
||||
appendOptimisticTurn,
|
||||
migrateConversation,
|
||||
setExternalId,
|
||||
setLiveMessage,
|
||||
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 pendingReloadState = useRef<{
|
||||
signal: number
|
||||
sawLoading: boolean
|
||||
} | 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 {
|
||||
conn,
|
||||
modeLoading,
|
||||
configOptionsLoading,
|
||||
autoConnectError,
|
||||
handleFocus,
|
||||
handleSend,
|
||||
handleSend: lifecycleSend,
|
||||
handleSetConfigOption,
|
||||
handleCancel,
|
||||
handleRespondPermission,
|
||||
} = useConnectionLifecycle({
|
||||
contextKey,
|
||||
agentType,
|
||||
isActive,
|
||||
workingDir: detailLoading ? undefined : folder?.path,
|
||||
sessionId: externalId,
|
||||
contextKey: tabId,
|
||||
agentType: selectedAgent,
|
||||
isActive: isActive && canAutoConnect,
|
||||
workingDir: workingDirForConnection,
|
||||
sessionId: dbConversationId != null ? externalId : undefined,
|
||||
})
|
||||
|
||||
const [pendingMessages, setPendingMessages] = useState<AdaptedMessage[]>([])
|
||||
const [modeId, setModeId] = useState<string | null>(null)
|
||||
const clearPending = useCallback(() => setPendingMessages([]), [])
|
||||
|
||||
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]
|
||||
@@ -108,79 +246,145 @@ const ExistingConversationView = memo(function ExistingConversationView({
|
||||
return conn.modes?.current_mode_id ?? connectionModes[0]?.id ?? null
|
||||
}, [conn.modes?.current_mode_id, connectionModes, modeId])
|
||||
|
||||
// Track status transitions for updating conversation metadata
|
||||
const prevStatusRef = useRef(conn.status)
|
||||
const statusUpdatedRef = useRef(false)
|
||||
const clearReconcileTimer = useCallback(() => {
|
||||
if (!reconcileTimerRef.current) return
|
||||
clearTimeout(reconcileTimerRef.current)
|
||||
reconcileTimerRef.current = null
|
||||
}, [])
|
||||
|
||||
// Wrap handleSend to update status
|
||||
const handleSendWithPersist = useCallback(
|
||||
(draft: PromptDraft, selectedModeId?: string | null) => {
|
||||
setPendingMessages([
|
||||
{
|
||||
id: `pending-${Date.now()}`,
|
||||
role: "user",
|
||||
content: buildUserMessageTextPartsFromDraft(
|
||||
draft,
|
||||
sharedT("attachedResources")
|
||||
),
|
||||
userImages: extractUserImagesFromDraft(draft),
|
||||
userResources: extractUserResourcesFromDraft(draft),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
updateConversationStatus(conversationId, "in_progress")
|
||||
.then(() => refreshConversations())
|
||||
.catch((e) => console.error("[ExistingConv] update status:", e))
|
||||
statusUpdatedRef.current = false
|
||||
handleSend(draft, selectedModeId)
|
||||
const refreshFromDb = useCallback(
|
||||
async (refreshConversationId: number) => {
|
||||
try {
|
||||
const refreshed = await refreshDetailCache(refreshConversationId)
|
||||
acknowledgePersistedDetail(refreshConversationId, refreshed)
|
||||
} catch (error) {
|
||||
setSyncState(refreshConversationId, "failed")
|
||||
console.error(
|
||||
"[ConversationTabView] refresh detail cache failed:",
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
[conversationId, handleSend, refreshConversations, sharedT]
|
||||
[acknowledgePersistedDetail, setSyncState]
|
||||
)
|
||||
|
||||
// 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(() => {
|
||||
const prev = prevStatusRef.current
|
||||
prevStatusRef.current = conn.status
|
||||
prevStatusRef.current = connStatus
|
||||
if (prev !== "prompting" || connStatus === "prompting") return
|
||||
|
||||
if (prev === "prompting" && conn.status !== "prompting") {
|
||||
// Mark as pending_review unless it's a terminal state
|
||||
if (conn.status !== "disconnected" && conn.status !== "error") {
|
||||
updateConversationStatus(conversationId, "pending_review")
|
||||
.then(() => refreshConversations())
|
||||
.catch((e: unknown) =>
|
||||
console.error("[ExistingConv] update status:", e)
|
||||
)
|
||||
}
|
||||
setSyncState(effectiveConversationId, "reconciling")
|
||||
const persistedId = dbConvIdRef.current
|
||||
if (!persistedId) return
|
||||
|
||||
invalidateDetailCache(persistedId)
|
||||
clearReconcileTimer()
|
||||
reconcileTimerRef.current = setTimeout(() => {
|
||||
void refreshFromDb(persistedId)
|
||||
}, 1200)
|
||||
|
||||
if (connStatus !== "disconnected" && connStatus !== "error") {
|
||||
updateConversationStatus(persistedId, "pending_review")
|
||||
.then(() => refreshConversations())
|
||||
.catch((e: unknown) =>
|
||||
console.error("[ConversationTabView] update status:", e)
|
||||
)
|
||||
}
|
||||
}, [conn.status, conversationId, refreshConversations])
|
||||
}, [
|
||||
clearReconcileTimer,
|
||||
connStatus,
|
||||
effectiveConversationId,
|
||||
refreshConversations,
|
||||
refreshFromDb,
|
||||
setSyncState,
|
||||
])
|
||||
|
||||
// Update status on disconnect/error
|
||||
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
|
||||
return
|
||||
}
|
||||
if (statusUpdatedRef.current) return
|
||||
if (conn.status === "disconnected") {
|
||||
const persistedId = dbConvIdRef.current
|
||||
if (!persistedId) return
|
||||
if (connStatus === "disconnected") {
|
||||
statusUpdatedRef.current = true
|
||||
updateConversationStatus(conversationId, "completed")
|
||||
.then(() => {
|
||||
setPendingMessages([])
|
||||
refreshConversations()
|
||||
})
|
||||
.catch((e) => console.error("[ExistingConv] update status:", e))
|
||||
} else if (conn.status === "error") {
|
||||
updateConversationStatus(persistedId, "completed")
|
||||
.then(() => refreshConversations())
|
||||
.catch((e) => console.error("[ConversationTabView] update status:", e))
|
||||
} else if (connStatus === "error") {
|
||||
statusUpdatedRef.current = true
|
||||
updateConversationStatus(conversationId, "cancelled")
|
||||
.then(() => {
|
||||
setPendingMessages([])
|
||||
refreshConversations()
|
||||
})
|
||||
.catch((e) => console.error("[ExistingConv] update status:", e))
|
||||
updateConversationStatus(persistedId, "cancelled")
|
||||
.then(() => refreshConversations())
|
||||
.catch((e) => console.error("[ConversationTabView] update status:", e))
|
||||
}
|
||||
}, [conn.status, conversationId, refreshConversations])
|
||||
}, [connStatus, refreshConversations])
|
||||
|
||||
useEffect(() => {
|
||||
if (dbConversationId == null) return
|
||||
if (reloadSignal === latestReloadSignal.current) return
|
||||
latestReloadSignal.current = reloadSignal
|
||||
pendingReloadState.current = {
|
||||
@@ -188,7 +392,7 @@ const ExistingConversationView = memo(function ExistingConversationView({
|
||||
sawLoading: false,
|
||||
}
|
||||
refetchConversationDetail()
|
||||
}, [reloadSignal, refetchConversationDetail])
|
||||
}, [dbConversationId, reloadSignal, refetchConversationDetail])
|
||||
|
||||
useEffect(() => {
|
||||
const pending = pendingReloadState.current
|
||||
@@ -211,15 +415,182 @@ const ExistingConversationView = memo(function ExistingConversationView({
|
||||
toast.success(t("reloaded"))
|
||||
}, [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 (
|
||||
<ConversationShell
|
||||
status={conn.status}
|
||||
status={connStatus}
|
||||
promptCapabilities={conn.promptCapabilities}
|
||||
defaultPath={folder?.path}
|
||||
defaultPath={workingDirForConnection}
|
||||
error={conn.error}
|
||||
pendingPermission={conn.pendingPermission}
|
||||
onFocus={handleFocus}
|
||||
onSend={handleSendWithPersist}
|
||||
onSend={handleSend}
|
||||
onCancel={handleCancel}
|
||||
onRespondPermission={handleRespondPermission}
|
||||
modes={connectionModes}
|
||||
@@ -231,41 +602,66 @@ const ExistingConversationView = memo(function ExistingConversationView({
|
||||
onConfigOptionChange={handleSetConfigOption}
|
||||
availableCommands={connectionCommands}
|
||||
attachmentTabId={tabId}
|
||||
draftStorageKey={buildConversationDraftStorageKey(
|
||||
agentType,
|
||||
conversationId
|
||||
)}
|
||||
draftStorageKey={draftStorageKey}
|
||||
>
|
||||
<MessageListView
|
||||
conversationId={conversationId}
|
||||
liveMessage={conn.liveMessage}
|
||||
connStatus={conn.status}
|
||||
pendingMessages={pendingMessages}
|
||||
onPendingClear={clearPending}
|
||||
isActive={isActive}
|
||||
/>
|
||||
{showDraftHeader ? (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<AgentSelector
|
||||
defaultAgentType={selectedAgent}
|
||||
onSelect={handleAgentSelect}
|
||||
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>
|
||||
)
|
||||
})
|
||||
|
||||
export function ConversationDetailPanel() {
|
||||
const t = useTranslations("Folder.conversation")
|
||||
const {
|
||||
acknowledgePersistedDetail,
|
||||
getConversationIdByExternalId,
|
||||
setSyncState,
|
||||
} = useConversationRuntime()
|
||||
const { folder, newConversation, conversations, refreshConversations } =
|
||||
useFolderContext()
|
||||
const { tabs, activeTabId, openNewConversationTab, closeTab } =
|
||||
useTabContext()
|
||||
const [reloadByTabId, setReloadByTabId] = useState<Record<string, number>>({})
|
||||
const tabsRef = useRef(tabs)
|
||||
const conversationsRef = useRef(conversations)
|
||||
const pendingClosedConversationIdsRef = useRef<Set<number>>(new Set())
|
||||
const pendingRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
tabsRef.current = tabs
|
||||
}, [tabs])
|
||||
|
||||
useEffect(() => {
|
||||
conversationsRef.current = conversations
|
||||
}, [conversations])
|
||||
@@ -294,8 +690,10 @@ export function ConversationDetailPanel() {
|
||||
}
|
||||
|
||||
try {
|
||||
await warmupDetailCache(conversationId)
|
||||
const detail = await refreshDetailCache(conversationId)
|
||||
acknowledgePersistedDetail(conversationId, detail)
|
||||
} catch (error) {
|
||||
setSyncState(conversationId, "failed")
|
||||
console.error(
|
||||
"[ConversationDetailPanel] background detail cache refresh failed:",
|
||||
error
|
||||
@@ -306,7 +704,7 @@ export function ConversationDetailPanel() {
|
||||
|
||||
refreshConversations()
|
||||
})()
|
||||
}, [refreshConversations])
|
||||
}, [acknowledgePersistedDetail, refreshConversations, setSyncState])
|
||||
|
||||
const scheduleClosedConversationRefresh = useCallback(
|
||||
(conversationId: number) => {
|
||||
@@ -333,17 +731,20 @@ export function ConversationDetailPanel() {
|
||||
const payload = event.payload
|
||||
if (payload.type !== "turn_complete") return
|
||||
|
||||
const runtimeConversationId = getConversationIdByExternalId(
|
||||
payload.session_id
|
||||
)
|
||||
const summary = conversationsRef.current.find(
|
||||
(item) => item.external_id === payload.session_id
|
||||
)
|
||||
if (!summary) return
|
||||
const matchedConversationId =
|
||||
runtimeConversationId ?? summary?.id ?? null
|
||||
if (!matchedConversationId) return
|
||||
|
||||
const isOpenInTabs = tabsRef.current.some(
|
||||
(tab) => tab.conversationId === summary.id
|
||||
)
|
||||
if (isOpenInTabs) return
|
||||
invalidateDetailCache(matchedConversationId)
|
||||
setSyncState(matchedConversationId, "reconciling")
|
||||
|
||||
scheduleClosedConversationRefresh(summary.id)
|
||||
scheduleClosedConversationRefresh(matchedConversationId)
|
||||
})
|
||||
)
|
||||
.then((dispose) => {
|
||||
@@ -372,27 +773,18 @@ export function ConversationDetailPanel() {
|
||||
"ConversationDetailPanel.backgroundRefresh"
|
||||
)
|
||||
}
|
||||
}, [scheduleClosedConversationRefresh])
|
||||
}, [
|
||||
getConversationIdByExternalId,
|
||||
acknowledgePersistedDetail,
|
||||
scheduleClosedConversationRefresh,
|
||||
setSyncState,
|
||||
])
|
||||
|
||||
const conversationTabs = useMemo(
|
||||
() =>
|
||||
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 hasNoTabs = tabs.length === 0 && !activeTabId
|
||||
const activeConversationTab = useMemo(
|
||||
() =>
|
||||
tabs.find(
|
||||
(tab) =>
|
||||
tab.id === activeTabId &&
|
||||
tab.kind === "conversation" &&
|
||||
tab.conversationId != null
|
||||
(tab) => tab.id === activeTabId && tab.conversationId != null
|
||||
) ?? null,
|
||||
[tabs, activeTabId]
|
||||
)
|
||||
@@ -435,19 +827,14 @@ export function ConversationDetailPanel() {
|
||||
|
||||
// Empty state: no tabs at all — show full-screen welcome
|
||||
if (hasNoTabs) {
|
||||
return (
|
||||
<WelcomeInputPanel
|
||||
defaultAgentType={newConversation?.agentType ?? "codex"}
|
||||
workingDir={newConversation?.workingDir ?? folder?.path}
|
||||
/>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="relative h-full min-h-0 overflow-hidden">
|
||||
{conversationTabs.map((tab) => {
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.id === activeTabId
|
||||
return (
|
||||
<div
|
||||
@@ -458,36 +845,17 @@ export function ConversationDetailPanel() {
|
||||
: "absolute inset-0 invisible pointer-events-none"
|
||||
}
|
||||
>
|
||||
<ExistingConversationView
|
||||
<ConversationTabView
|
||||
tabId={tab.id}
|
||||
conversationId={tab.conversationId!}
|
||||
conversationId={tab.conversationId}
|
||||
agentType={tab.agentType}
|
||||
workingDir={tab.workingDir ?? folder?.path}
|
||||
isActive={active}
|
||||
reloadSignal={reloadByTabId[tab.id] ?? 0}
|
||||
/>
|
||||
</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>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
|
||||
@@ -94,7 +94,8 @@ export function SidebarConversationList({
|
||||
refreshConversations,
|
||||
} = useFolderContext()
|
||||
|
||||
const { openTab, closeTab, openNewConversationTab } = useTabContext()
|
||||
const { openTab, closeConversationTab, openNewConversationTab } =
|
||||
useTabContext()
|
||||
const { addTask, updateTask } = useTaskContext()
|
||||
|
||||
const [importing, setImporting] = useState(false)
|
||||
@@ -206,10 +207,10 @@ export function SidebarConversationList({
|
||||
const handleDelete = useCallback(
|
||||
async (id: number, agentType: string) => {
|
||||
await deleteConversation(id)
|
||||
closeTab(`conv-${agentType}-${id}`)
|
||||
closeConversationTab(id, agentType as Parameters<typeof openTab>[1])
|
||||
refreshConversations()
|
||||
},
|
||||
[closeTab, refreshConversations]
|
||||
[closeConversationTab, refreshConversations]
|
||||
)
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
|
||||
@@ -842,10 +842,7 @@ export function FileTreeTab() {
|
||||
const activeSessionTabId = useMemo(() => {
|
||||
const activeTab = tabs.find((tab) => tab.id === activeTabId)
|
||||
if (!activeTab) return null
|
||||
if (
|
||||
activeTab.kind !== "conversation" &&
|
||||
activeTab.kind !== "new_conversation"
|
||||
) {
|
||||
if (activeTab.kind !== "conversation") {
|
||||
return null
|
||||
}
|
||||
return activeTab.id
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { ChevronRight, FileIcon } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useFolderContext } from "@/contexts/folder-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 { useConnection } from "@/hooks/use-connection"
|
||||
import { useDbMessageDetail } from "@/hooks/use-db-message-detail"
|
||||
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 {
|
||||
CommitFileAdditions,
|
||||
CommitFileDeletions,
|
||||
@@ -27,8 +20,6 @@ import {
|
||||
} from "@/components/ui/collapsible"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const LIVE_FILE_WRITE_OPS = new Set(["edit", "write", "apply_patch"])
|
||||
|
||||
function isRemovedFileDiff(diff: string | null): boolean {
|
||||
if (!diff) return false
|
||||
|
||||
@@ -61,123 +52,17 @@ function toFolderRelativePath(filePath: string, folderPath?: string): string {
|
||||
return normalizedFilePath
|
||||
}
|
||||
|
||||
function extractTurnText(turn: MessageTurn | null): string | null {
|
||||
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
|
||||
}) {
|
||||
function SessionFilesContent({ conversationId }: { conversationId: number }) {
|
||||
const t = useTranslations("Folder.sessionFiles")
|
||||
const { detail, loading, refetch } = useDbMessageDetail(conversationId)
|
||||
const { loading } = useDbMessageDetail(conversationId)
|
||||
const { getTimelineTurns } = useConversationRuntime()
|
||||
const { openSessionFileDiff } = useWorkspaceContext()
|
||||
const { folder } = useFolderContext()
|
||||
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(
|
||||
() =>
|
||||
mergeLiveTurns({
|
||||
turns: detail?.turns ?? [],
|
||||
liveMessage,
|
||||
connStatus,
|
||||
pendingPromptText,
|
||||
fallbackPromptText: t("currentResponse"),
|
||||
}),
|
||||
[detail?.turns, liveMessage, connStatus, pendingPromptText, t]
|
||||
() => getTimelineTurns(conversationId).map((item) => item.turn),
|
||||
[conversationId, getTimelineTurns]
|
||||
)
|
||||
const groups = useMemo(
|
||||
() => (turns.length > 0 ? extractSessionFilesGrouped(turns) : []),
|
||||
@@ -197,7 +82,7 @@ function SessionFilesContent({
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
if (loading && groups.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<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 conversationId = activeTab?.conversationId
|
||||
const contextKey = activeTab?.id ?? "__session-files-tab__"
|
||||
const conn = useConnection(contextKey)
|
||||
const pendingPromptText = getPendingPromptText(contextKey)
|
||||
|
||||
if (!activeTab) {
|
||||
return (
|
||||
@@ -408,12 +290,7 @@ export function SessionFilesTab() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<SessionFilesContent
|
||||
conversationId={conversationId}
|
||||
liveMessage={conn.liveMessage}
|
||||
connStatus={conn.status}
|
||||
pendingPromptText={pendingPromptText}
|
||||
/>
|
||||
<SessionFilesContent conversationId={conversationId} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"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 { useConversationRuntime } from "@/contexts/conversation-runtime-context"
|
||||
import { ContentPartsRenderer } from "./content-parts-renderer"
|
||||
import {
|
||||
adaptMessageTurns,
|
||||
type AdaptedMessage,
|
||||
type AdaptedContentPart,
|
||||
type MessageGroup,
|
||||
type UserImageDisplay,
|
||||
@@ -18,9 +18,7 @@ import { LiveTurnStats } from "./live-turn-stats"
|
||||
import { UserResourceLinks } from "./user-resource-links"
|
||||
import { UserImageAttachments } from "./user-image-attachments"
|
||||
import { useSessionStats } from "@/contexts/session-stats-context"
|
||||
import { LiveMessageBlock } from "@/components/chat/live-message-block"
|
||||
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 { Message, MessageContent } from "@/components/ai-elements/message"
|
||||
import { Loader2 } from "lucide-react"
|
||||
@@ -35,9 +33,6 @@ import { VirtualizedMessageThread } from "@/components/message/virtualized-messa
|
||||
interface MessageListViewProps {
|
||||
conversationId: number
|
||||
connStatus?: ConnectionStatus | null
|
||||
liveMessage?: LiveMessage | null
|
||||
pendingMessages?: AdaptedMessage[]
|
||||
onPendingClear?: () => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
@@ -50,24 +45,14 @@ interface ResolvedMessageGroup extends MessageGroup {
|
||||
type ThreadRenderItem =
|
||||
| {
|
||||
key: string
|
||||
kind: "historical"
|
||||
group: ResolvedMessageGroup
|
||||
}
|
||||
| {
|
||||
key: string
|
||||
kind: "pending"
|
||||
kind: "turn"
|
||||
group: ResolvedMessageGroup
|
||||
phase: "persisted" | "optimistic" | "streaming"
|
||||
}
|
||||
| {
|
||||
key: string
|
||||
kind: "typing"
|
||||
}
|
||||
| {
|
||||
key: string
|
||||
kind: "live"
|
||||
message: LiveMessage
|
||||
isStreaming: boolean
|
||||
}
|
||||
|
||||
function fallbackExtractUserResources(
|
||||
group: MessageGroup,
|
||||
@@ -140,11 +125,13 @@ function resolveMessageGroup(
|
||||
|
||||
const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
|
||||
group,
|
||||
dimmed = false,
|
||||
}: {
|
||||
group: ResolvedMessageGroup
|
||||
dimmed?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className={dimmed ? "opacity-70" : undefined}>
|
||||
<Message from={group.role}>
|
||||
{group.role === "user" && group.images.length > 0 ? (
|
||||
<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() {
|
||||
return (
|
||||
<Message from="assistant">
|
||||
@@ -207,33 +172,15 @@ const PendingTypingIndicator = memo(function PendingTypingIndicator() {
|
||||
export function MessageListView({
|
||||
conversationId,
|
||||
connStatus,
|
||||
liveMessage,
|
||||
pendingMessages,
|
||||
onPendingClear,
|
||||
isActive = true,
|
||||
}: MessageListViewProps) {
|
||||
const t = useTranslations("Folder.chat.messageList")
|
||||
const sharedT = useTranslations("Folder.chat.shared")
|
||||
const { detail, loading, error } = useDbMessageDetail(conversationId)
|
||||
const turnCount = detail?.turns.length ?? 0
|
||||
|
||||
// 移除了 prompting 结束后的立即刷新
|
||||
// 原因:后端自动持久化可能有延迟,立即刷新会读到不完整数据
|
||||
// 现在通过清空 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 { getSession, getTimelineTurns } = useConversationRuntime()
|
||||
const session = getSession(conversationId)
|
||||
const liveMessage = session?.liveMessage ?? null
|
||||
const timelineTurns = getTimelineTurns(conversationId)
|
||||
|
||||
const { setSessionStats } = useSessionStats()
|
||||
const sessionStats = detail?.session_stats ?? null
|
||||
@@ -244,106 +191,105 @@ export function MessageListView({
|
||||
}
|
||||
}, [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
|
||||
? adaptMessageTurns(detail.turns, {
|
||||
attachedResources: sharedT("attachedResources"),
|
||||
toolCallFailed: sharedT("toolCallFailed"),
|
||||
})
|
||||
: [],
|
||||
[detail, sharedT]
|
||||
timelineTurns.reduce<
|
||||
Array<{
|
||||
phase: "persisted" | "optimistic" | "streaming"
|
||||
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
|
||||
}, []),
|
||||
[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(
|
||||
() => extractLatestPlanEntriesFromMessages(messages),
|
||||
[messages]
|
||||
() => extractLatestPlanEntriesFromMessages(historicalMessages),
|
||||
[historicalMessages]
|
||||
)
|
||||
const historicalPlanKey = useMemo(
|
||||
() => buildPlanKey(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) => {
|
||||
switch (item.kind) {
|
||||
case "historical":
|
||||
return <HistoricalMessageGroup group={item.group} />
|
||||
case "pending":
|
||||
return <PendingMessageGroup group={item.group} />
|
||||
case "typing":
|
||||
return <PendingTypingIndicator />
|
||||
case "live":
|
||||
case "turn":
|
||||
return (
|
||||
<LiveMessageBlock
|
||||
message={item.message}
|
||||
isStreaming={item.isStreaming}
|
||||
<HistoricalMessageGroup
|
||||
group={item.group}
|
||||
dimmed={item.phase === "optimistic"}
|
||||
/>
|
||||
)
|
||||
case "typing":
|
||||
return <PendingTypingIndicator />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -362,7 +308,9 @@ export function MessageListView({
|
||||
|
||||
const agentPlanOverlayKey = liveMessage?.id ?? `history-${conversationId}`
|
||||
|
||||
if (loading && !detail) {
|
||||
const hasRenderableContent = threadItems.length > 0 || Boolean(liveMessage)
|
||||
|
||||
if (loading && !hasRenderableContent) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<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 (
|
||||
<div className="p-6">
|
||||
<div className="text-center py-12">
|
||||
@@ -385,8 +333,6 @@ export function MessageListView({
|
||||
)
|
||||
}
|
||||
|
||||
if (!detail) return null
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col">
|
||||
<MessageThread
|
||||
@@ -402,7 +348,7 @@ export function MessageListView({
|
||||
overscan={10}
|
||||
/>
|
||||
</MessageThread>
|
||||
{showLiveMessage && liveMessage && connStatus === "prompting" && (
|
||||
{liveMessage && connStatus === "prompting" && (
|
||||
<LiveTurnStats
|
||||
message={liveMessage}
|
||||
isStreaming={connStatus === "prompting"}
|
||||
@@ -413,7 +359,7 @@ export function MessageListView({
|
||||
message={liveMessage ?? null}
|
||||
entries={historicalPlanEntries}
|
||||
planKey={historicalPlanKey}
|
||||
defaultExpanded={showLiveMessage}
|
||||
defaultExpanded={connStatus === "prompting"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -974,7 +974,6 @@ export interface AcpActionsValue {
|
||||
requestId: string,
|
||||
optionId: string
|
||||
): Promise<void>
|
||||
migrateContextKey(fromKey: string, toKey: string): void
|
||||
setActiveKey(key: string | null): void
|
||||
touchActivity(contextKey: string): void
|
||||
}
|
||||
@@ -1754,56 +1753,6 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
dispatch({ type: "REMOVE_ALL" })
|
||||
}, [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(
|
||||
async (contextKey: string, blocks: PromptInputBlock[]) => {
|
||||
const conn = storeRef.current.connections.get(contextKey)
|
||||
@@ -1869,7 +1818,6 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
setConfigOption,
|
||||
cancel,
|
||||
respondPermission,
|
||||
migrateContextKey,
|
||||
setActiveKey,
|
||||
touchActivity,
|
||||
}),
|
||||
@@ -1882,7 +1830,6 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
setConfigOption,
|
||||
cancel,
|
||||
respondPermission,
|
||||
migrateContextKey,
|
||||
setActiveKey,
|
||||
touchActivity,
|
||||
]
|
||||
|
||||
651
src/contexts/conversation-runtime-context.tsx
Normal file
651
src/contexts/conversation-runtime-context.tsx
Normal file
@@ -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 {
|
||||
id: string
|
||||
kind: "conversation" | "new_conversation"
|
||||
conversationId?: number
|
||||
kind: "conversation"
|
||||
conversationId: number | null
|
||||
agentType: AgentType
|
||||
title: string
|
||||
isPinned: boolean
|
||||
@@ -43,18 +43,13 @@ interface TabContextValue {
|
||||
title?: string
|
||||
) => void
|
||||
closeTab: (tabId: string) => void
|
||||
closeConversationTab: (conversationId: number, agentType: AgentType) => void
|
||||
closeOtherTabs: (tabId: string) => void
|
||||
closeAllTabs: () => void
|
||||
switchTab: (tabId: string) => void
|
||||
pinTab: (tabId: string) => void
|
||||
openNewConversationTab: (agentType: AgentType, workingDir: string) => void
|
||||
promoteNewConversationTab: (
|
||||
tabId: string,
|
||||
conversationId: number,
|
||||
agentType: AgentType,
|
||||
title: string
|
||||
) => void
|
||||
linkTabConversation: (
|
||||
bindConversationTab: (
|
||||
tabId: string,
|
||||
conversationId: number,
|
||||
agentType: AgentType,
|
||||
@@ -83,13 +78,6 @@ function makeConversationTabId(
|
||||
function makeNewConversationTabId(): string {
|
||||
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(
|
||||
tabs: TabItemInternal[],
|
||||
agentType: AgentType,
|
||||
@@ -130,7 +118,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
return [
|
||||
{
|
||||
id: tabId,
|
||||
kind: "conversation" as const,
|
||||
kind: "conversation",
|
||||
conversationId: selectedConversation.id,
|
||||
agentType: selectedConversation.agentType,
|
||||
title: t("loadingConversation"),
|
||||
@@ -187,7 +175,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
|
||||
const restoredTabs: TabItemInternal[] = opened.map((oc) => ({
|
||||
id: makeConversationTabId(oc.agent_type, oc.conversation_id),
|
||||
kind: "conversation" as const,
|
||||
kind: "conversation",
|
||||
conversationId: oc.conversation_id,
|
||||
agentType: oc.agent_type,
|
||||
title: t("loadingConversation"),
|
||||
@@ -300,13 +288,20 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
cancelNewConversation()
|
||||
return
|
||||
}
|
||||
if (tab.kind === "conversation" && tab.conversationId != null) {
|
||||
if (tab.conversationId != null) {
|
||||
selectConversation(tab.conversationId, tab.agentType)
|
||||
} else if (tab.kind === "new_conversation" && tab.workingDir) {
|
||||
startNewConversation(tab.agentType, tab.workingDir)
|
||||
} else {
|
||||
const workingDir = tab.workingDir ?? folder?.path
|
||||
if (!workingDir) {
|
||||
clearSelection()
|
||||
cancelNewConversation()
|
||||
return
|
||||
}
|
||||
startNewConversation(tab.agentType, workingDir)
|
||||
}
|
||||
},
|
||||
[
|
||||
folder?.path,
|
||||
selectConversation,
|
||||
clearSelection,
|
||||
startNewConversation,
|
||||
@@ -386,10 +381,11 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
[activateConversationPane, selectConversation, t]
|
||||
)
|
||||
|
||||
const makeReplacementNewConversationTab = useCallback(
|
||||
const makeReplacementDraftTab = useCallback(
|
||||
(preferred?: TabItemInternal): TabItemInternal => ({
|
||||
id: makeNewConversationTabId(),
|
||||
kind: "new_conversation",
|
||||
kind: "conversation",
|
||||
conversationId: null,
|
||||
agentType: preferred?.agentType ?? "codex",
|
||||
title: t("newConversation"),
|
||||
isPinned: true,
|
||||
@@ -410,7 +406,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
const next = prev.filter((t) => t.id !== tabId)
|
||||
|
||||
if (next.length === 0) {
|
||||
const replacementTab = makeReplacementNewConversationTab(closingTab)
|
||||
const replacementTab = makeReplacementDraftTab(closingTab)
|
||||
neighborToSync = replacementTab
|
||||
return [replacementTab]
|
||||
}
|
||||
@@ -433,11 +429,19 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
activateConversationPane()
|
||||
}
|
||||
},
|
||||
[
|
||||
activateConversationPane,
|
||||
makeReplacementNewConversationTab,
|
||||
syncFolderContext,
|
||||
]
|
||||
[activateConversationPane, makeReplacementDraftTab, 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(
|
||||
@@ -459,21 +463,17 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
const closeAllTabs = useCallback(() => {
|
||||
const seedTab =
|
||||
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[0]
|
||||
|
||||
const replacementTab = makeReplacementNewConversationTab(seedTab)
|
||||
const replacementTab = makeReplacementDraftTab(seedTab)
|
||||
setTabs([replacementTab])
|
||||
setActiveTabId(replacementTab.id)
|
||||
syncFolderContext(replacementTab)
|
||||
activateConversationPane()
|
||||
}, [
|
||||
activateConversationPane,
|
||||
makeReplacementNewConversationTab,
|
||||
syncFolderContext,
|
||||
])
|
||||
}, [activateConversationPane, makeReplacementDraftTab, syncFolderContext])
|
||||
|
||||
const switchTab = useCallback(
|
||||
(tabId: string) => {
|
||||
@@ -501,10 +501,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
const openNewConversationTab = useCallback(
|
||||
(agentType: AgentType, workingDir: string) => {
|
||||
const existingTab = rawTabsRef.current.find(
|
||||
(t) =>
|
||||
t.kind === "new_conversation" &&
|
||||
t.agentType === agentType &&
|
||||
!t.conversationId
|
||||
(t) => t.conversationId == null && t.agentType === agentType
|
||||
)
|
||||
|
||||
if (existingTab) {
|
||||
@@ -517,7 +514,8 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
const tabId = makeNewConversationTabId()
|
||||
const newTab: TabItemInternal = {
|
||||
id: tabId,
|
||||
kind: "new_conversation",
|
||||
kind: "conversation",
|
||||
conversationId: null,
|
||||
agentType,
|
||||
title: t("newConversation"),
|
||||
isPinned: true,
|
||||
@@ -532,71 +530,45 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
[activateConversationPane, startNewConversation, syncFolderContext, t]
|
||||
)
|
||||
|
||||
const linkTabConversation = useCallback(
|
||||
const bindConversationTab = useCallback(
|
||||
(
|
||||
tabId: string,
|
||||
conversationId: number,
|
||||
agentType: AgentType,
|
||||
title: string
|
||||
) => {
|
||||
let nextActiveTabId: string | null = null
|
||||
setTabs((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === tabId ? { ...t, conversationId, agentType, title } : t
|
||||
)
|
||||
prev.flatMap((tab) => {
|
||||
if (tab.id === tabId) {
|
||||
const nextTab = { ...tab, conversationId, agentType, title }
|
||||
return [nextTab]
|
||||
}
|
||||
|
||||
if (
|
||||
tab.conversationId === conversationId &&
|
||||
tab.agentType === agentType
|
||||
) {
|
||||
if (activeTabIdRef.current === tabId) {
|
||||
nextActiveTabId = tab.id
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
return [tab]
|
||||
})
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
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 (nextActiveTabId) {
|
||||
setActiveTabId(nextActiveTabId)
|
||||
const target = rawTabsRef.current.find(
|
||||
(tab) => tab.id === nextActiveTabId
|
||||
)
|
||||
if (dupeIndex >= 0 && dupeIndex !== index) {
|
||||
activateId = prev[dupeIndex].id
|
||||
return prev.filter((t) => t.id !== tabId)
|
||||
if (target) {
|
||||
syncFolderContext(target)
|
||||
}
|
||||
|
||||
const promoted: TabItemInternal = {
|
||||
...prev[index],
|
||||
id: newId,
|
||||
kind: "conversation",
|
||||
conversationId,
|
||||
agentType,
|
||||
title,
|
||||
isPinned: true,
|
||||
}
|
||||
activateId = newId
|
||||
|
||||
const updated = [...prev]
|
||||
updated[index] = promoted
|
||||
return updated
|
||||
})
|
||||
|
||||
if (activateId) {
|
||||
setActiveTabId(activateId)
|
||||
selectConversation(conversationId, agentType)
|
||||
activateConversationPane()
|
||||
}
|
||||
},
|
||||
[activateConversationPane, selectConversation]
|
||||
[syncFolderContext]
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
@@ -605,13 +577,13 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
activeTabId,
|
||||
openTab,
|
||||
closeTab,
|
||||
closeConversationTab,
|
||||
closeOtherTabs,
|
||||
closeAllTabs,
|
||||
switchTab,
|
||||
pinTab,
|
||||
openNewConversationTab,
|
||||
promoteNewConversationTab,
|
||||
linkTabConversation,
|
||||
bindConversationTab,
|
||||
reorderTabs,
|
||||
}),
|
||||
[
|
||||
@@ -619,13 +591,13 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
activeTabId,
|
||||
openTab,
|
||||
closeTab,
|
||||
closeConversationTab,
|
||||
closeOtherTabs,
|
||||
closeAllTabs,
|
||||
switchTab,
|
||||
pinTab,
|
||||
openNewConversationTab,
|
||||
promoteNewConversationTab,
|
||||
linkTabConversation,
|
||||
bindConversationTab,
|
||||
reorderTabs,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -6,11 +6,6 @@ import { useAcpActions } from "@/contexts/acp-connections-context"
|
||||
import { useTaskContext } from "@/contexts/task-context"
|
||||
import { useConnection, type UseConnectionReturn } from "@/hooks/use-connection"
|
||||
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 {
|
||||
contextKey: string
|
||||
@@ -50,7 +45,6 @@ export function useConnectionLifecycle({
|
||||
sessionId,
|
||||
}: UseConnectionLifecycleOptions): UseConnectionLifecycleReturn {
|
||||
const t = useTranslations("Folder.chat.connectionLifecycle")
|
||||
const sharedT = useTranslations("Folder.chat.shared")
|
||||
const { setActiveKey, touchActivity } = useAcpActions()
|
||||
const { addTask, updateTask, removeTask } = useTaskContext()
|
||||
const conn = useConnection(contextKey)
|
||||
@@ -201,11 +195,6 @@ export function useConnectionLifecycle({
|
||||
}
|
||||
}, [status, addTask, updateTask, removeTask, agentType, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "prompting") return
|
||||
clearPendingPromptText(contextKey)
|
||||
}, [status, contextKey])
|
||||
|
||||
const clearSelectorTask = useCallback(() => {
|
||||
if (selectorTaskTimeoutRef.current) {
|
||||
clearTimeout(selectorTaskTimeoutRef.current)
|
||||
@@ -313,10 +302,6 @@ export function useConnectionLifecycle({
|
||||
const handleSend = useCallback(
|
||||
(draft: PromptDraft, modeId?: string | null) => {
|
||||
touchActivity(contextKey)
|
||||
setPendingPromptText(
|
||||
contextKey,
|
||||
getPromptDraftDisplayText(draft, sharedT("attachedResources"))
|
||||
)
|
||||
void (async () => {
|
||||
const currentModeId = modeIdRef.current
|
||||
if (modeId && modeId !== currentModeId) {
|
||||
@@ -330,7 +315,7 @@ export function useConnectionLifecycle({
|
||||
console.error("[ConnLifecycle] sendPrompt:", e)
|
||||
)
|
||||
},
|
||||
[connSetMode, sendPrompt, contextKey, touchActivity, sharedT]
|
||||
[connSetMode, sendPrompt, contextKey, touchActivity]
|
||||
)
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
|
||||
@@ -91,8 +91,22 @@ interface State {
|
||||
fetchSeq: number
|
||||
}
|
||||
|
||||
function isVirtualConversationId(conversationId: number): boolean {
|
||||
return !Number.isFinite(conversationId) || conversationId <= 0
|
||||
}
|
||||
|
||||
export function useDbMessageDetail(conversationId: number) {
|
||||
const isVirtualId = isVirtualConversationId(conversationId)
|
||||
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)
|
||||
return {
|
||||
key: id,
|
||||
@@ -110,19 +124,24 @@ export function useDbMessageDetail(conversationId: number) {
|
||||
const derivedState =
|
||||
state.key === conversationId ? state : getCachedState(conversationId)
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeDetail(conversationId, (detail) => {
|
||||
setState((prev) =>
|
||||
prev.key === conversationId
|
||||
? { ...prev, detail, loading: false, error: null }
|
||||
: prev
|
||||
)
|
||||
}),
|
||||
[conversationId]
|
||||
)
|
||||
useEffect(() => {
|
||||
if (isVirtualId) return
|
||||
return subscribeDetail(conversationId, (detail) => {
|
||||
setState((prev) => ({
|
||||
key: conversationId,
|
||||
detail,
|
||||
loading: false,
|
||||
error: null,
|
||||
fetchSeq: prev.key === conversationId ? prev.fetchSeq : 0,
|
||||
}))
|
||||
})
|
||||
}, [conversationId, isVirtualId])
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
if (isVirtualConversationId(conversationId)) {
|
||||
setState(getCachedState(conversationId))
|
||||
return
|
||||
}
|
||||
detailCache.delete(conversationId)
|
||||
setState((prev) => {
|
||||
const base =
|
||||
@@ -138,6 +157,7 @@ export function useDbMessageDetail(conversationId: number) {
|
||||
}, [conversationId, getCachedState])
|
||||
|
||||
useEffect(() => {
|
||||
if (isVirtualId) return
|
||||
// Skip fetch if cache already has data
|
||||
if (detailCache.has(conversationId)) return
|
||||
|
||||
@@ -180,7 +200,7 @@ export function useDbMessageDetail(conversationId: number) {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [conversationId, derivedState.fetchSeq])
|
||||
}, [conversationId, derivedState.fetchSeq, isVirtualId])
|
||||
|
||||
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