重构会话消息处理和显示逻辑
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user