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

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

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