重构会话消息处理和显示逻辑

This commit is contained in:
xintaofei
2026-03-10 19:32:44 +08:00
parent aa1ff9a6df
commit 91636ada7f
13 changed files with 1429 additions and 1629 deletions

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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(

View File

@@ -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

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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,
]

View 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
}

View File

@@ -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,
]
)

View File

@@ -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(() => {

View File

@@ -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(
() => ({

View File

@@ -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)
}