重构会话消息处理和显示逻辑
This commit is contained in:
@@ -974,7 +974,6 @@ export interface AcpActionsValue {
|
||||
requestId: string,
|
||||
optionId: string
|
||||
): Promise<void>
|
||||
migrateContextKey(fromKey: string, toKey: string): void
|
||||
setActiveKey(key: string | null): void
|
||||
touchActivity(contextKey: string): void
|
||||
}
|
||||
@@ -1754,56 +1753,6 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
dispatch({ type: "REMOVE_ALL" })
|
||||
}, [dispatch])
|
||||
|
||||
const migrateContextKey = useCallback(
|
||||
(fromKey: string, toKey: string) => {
|
||||
if (!fromKey || !toKey || fromKey === toKey) return
|
||||
|
||||
const current = storeRef.current.connections
|
||||
const conn = current.get(fromKey)
|
||||
if (!conn) return
|
||||
|
||||
const targetConn = current.get(toKey)
|
||||
const migratedConn = targetConn
|
||||
? {
|
||||
...conn,
|
||||
// Preserve the most recent error from the target, if any.
|
||||
error: targetConn.error ?? conn.error,
|
||||
contextKey: toKey,
|
||||
}
|
||||
: { ...conn, contextKey: toKey }
|
||||
|
||||
const next = new Map(current)
|
||||
next.delete(fromKey)
|
||||
next.set(toKey, migratedConn)
|
||||
storeRef.current.connections = next
|
||||
|
||||
for (const [connectionId, mappedKey] of reverseMapRef.current) {
|
||||
if (mappedKey === fromKey) {
|
||||
reverseMapRef.current.set(connectionId, toKey)
|
||||
}
|
||||
}
|
||||
|
||||
const lastActive = lastActivityRef.current.get(fromKey)
|
||||
if (lastActive != null) {
|
||||
lastActivityRef.current.set(toKey, lastActive)
|
||||
lastActivityRef.current.delete(fromKey)
|
||||
}
|
||||
|
||||
if (connectingKeysRef.current.delete(fromKey)) {
|
||||
connectingKeysRef.current.add(toKey)
|
||||
}
|
||||
|
||||
if (storeRef.current.activeKey === fromKey) {
|
||||
storeRef.current.activeKey = toKey
|
||||
notifyActiveKeyListeners()
|
||||
}
|
||||
|
||||
notifyKeyListeners(fromKey)
|
||||
notifyKeyListeners(toKey)
|
||||
},
|
||||
[notifyActiveKeyListeners, notifyKeyListeners]
|
||||
)
|
||||
|
||||
const sendPrompt = useCallback(
|
||||
async (contextKey: string, blocks: PromptInputBlock[]) => {
|
||||
const conn = storeRef.current.connections.get(contextKey)
|
||||
@@ -1869,7 +1818,6 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
setConfigOption,
|
||||
cancel,
|
||||
respondPermission,
|
||||
migrateContextKey,
|
||||
setActiveKey,
|
||||
touchActivity,
|
||||
}),
|
||||
@@ -1882,7 +1830,6 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
setConfigOption,
|
||||
cancel,
|
||||
respondPermission,
|
||||
migrateContextKey,
|
||||
setActiveKey,
|
||||
touchActivity,
|
||||
]
|
||||
|
||||
651
src/contexts/conversation-runtime-context.tsx
Normal file
651
src/contexts/conversation-runtime-context.tsx
Normal file
@@ -0,0 +1,651 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useReducer,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
||||
import type { DbConversationDetail, MessageTurn } from "@/lib/types"
|
||||
import { inferLiveToolName } from "@/lib/tool-call-normalization"
|
||||
|
||||
export type ConversationSyncState =
|
||||
| "idle"
|
||||
| "awaiting_persist"
|
||||
| "reconciling"
|
||||
| "failed"
|
||||
|
||||
export type ConversationTimelinePhase = "persisted" | "optimistic" | "streaming"
|
||||
|
||||
export interface ConversationTimelineTurn {
|
||||
key: string
|
||||
turn: MessageTurn
|
||||
phase: ConversationTimelinePhase
|
||||
}
|
||||
|
||||
export interface ConversationRuntimeSession {
|
||||
conversationId: number
|
||||
externalId: string | null
|
||||
persistedTurns: MessageTurn[]
|
||||
optimisticTurns: MessageTurn[]
|
||||
liveMessage: LiveMessage | null
|
||||
syncState: ConversationSyncState
|
||||
activeTurnToken: string | null
|
||||
lastHydratedAt: number | null
|
||||
lastPersistedAt: number | null
|
||||
persistedUpdatedAt: string | null
|
||||
persistedMessageCount: number
|
||||
}
|
||||
|
||||
interface ConversationRuntimeState {
|
||||
byConversationId: Map<number, ConversationRuntimeSession>
|
||||
conversationIdByExternalId: Map<string, number>
|
||||
}
|
||||
|
||||
const initialState: ConversationRuntimeState = {
|
||||
byConversationId: new Map(),
|
||||
conversationIdByExternalId: new Map(),
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: "HYDRATE_FROM_DETAIL"; detail: DbConversationDetail }
|
||||
| {
|
||||
type: "APPEND_OPTIMISTIC_TURN"
|
||||
conversationId: number
|
||||
turn: MessageTurn
|
||||
turnToken: string
|
||||
}
|
||||
| {
|
||||
type: "SET_LIVE_MESSAGE"
|
||||
conversationId: number
|
||||
liveMessage: LiveMessage | null
|
||||
}
|
||||
| {
|
||||
type: "ACK_PERSISTED_DETAIL"
|
||||
conversationId: number
|
||||
detail: DbConversationDetail
|
||||
turnToken?: string | null
|
||||
}
|
||||
| {
|
||||
type: "SET_EXTERNAL_ID"
|
||||
conversationId: number
|
||||
externalId: string | null
|
||||
}
|
||||
| {
|
||||
type: "SET_SYNC_STATE"
|
||||
conversationId: number
|
||||
syncState: ConversationSyncState
|
||||
}
|
||||
| {
|
||||
type: "MIGRATE_CONVERSATION"
|
||||
fromConversationId: number
|
||||
toConversationId: number
|
||||
}
|
||||
| { type: "REMOVE_CONVERSATION"; conversationId: number }
|
||||
| { type: "RESET" }
|
||||
|
||||
function createEmptySession(
|
||||
conversationId: number
|
||||
): ConversationRuntimeSession {
|
||||
return {
|
||||
conversationId,
|
||||
externalId: null,
|
||||
persistedTurns: [],
|
||||
optimisticTurns: [],
|
||||
liveMessage: null,
|
||||
syncState: "idle",
|
||||
activeTurnToken: null,
|
||||
lastHydratedAt: null,
|
||||
lastPersistedAt: null,
|
||||
persistedUpdatedAt: null,
|
||||
persistedMessageCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function formatLivePlanEntries(
|
||||
entries: Array<{ content: string; priority: string; status: string }>
|
||||
): string {
|
||||
if (entries.length === 0) {
|
||||
return "Plan updated."
|
||||
}
|
||||
const lines = entries.map(
|
||||
(entry) => `- [${entry.status}] ${entry.content} (${entry.priority})`
|
||||
)
|
||||
return `Plan updated:\n${lines.join("\n")}`
|
||||
}
|
||||
|
||||
function buildStreamingTurnFromLiveMessage(
|
||||
conversationId: number,
|
||||
liveMessage: LiveMessage
|
||||
): MessageTurn | null {
|
||||
const blocks: MessageTurn["blocks"] = []
|
||||
|
||||
for (const block of liveMessage.content) {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
if (block.text.length > 0) {
|
||||
blocks.push({ type: "text", text: block.text })
|
||||
}
|
||||
break
|
||||
case "thinking":
|
||||
if (block.text.length > 0) {
|
||||
blocks.push({ type: "thinking", text: block.text })
|
||||
}
|
||||
break
|
||||
case "plan": {
|
||||
blocks.push({
|
||||
type: "thinking",
|
||||
text: formatLivePlanEntries(block.entries),
|
||||
})
|
||||
break
|
||||
}
|
||||
case "tool_call": {
|
||||
const toolName = inferLiveToolName({
|
||||
title: block.info.title,
|
||||
kind: block.info.kind,
|
||||
rawInput: block.info.raw_input,
|
||||
})
|
||||
blocks.push({
|
||||
type: "tool_use",
|
||||
tool_use_id: block.info.tool_call_id,
|
||||
tool_name: toolName,
|
||||
input_preview: block.info.raw_input,
|
||||
})
|
||||
const isFinalState =
|
||||
block.info.status === "completed" || block.info.status === "failed"
|
||||
if (isFinalState) {
|
||||
blocks.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: block.info.tool_call_id,
|
||||
output_preview: block.info.raw_output ?? block.info.content,
|
||||
is_error: block.info.status === "failed",
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (blocks.length === 0) return null
|
||||
|
||||
return {
|
||||
id: `live-${conversationId}-${liveMessage.id}`,
|
||||
role: "assistant",
|
||||
blocks,
|
||||
timestamp: new Date(liveMessage.startedAt).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function shouldAcceptPersistedSnapshot(
|
||||
current: ConversationRuntimeSession | undefined,
|
||||
detail: DbConversationDetail
|
||||
): boolean {
|
||||
if (!current) return true
|
||||
|
||||
const nextUpdatedAt = detail.summary.updated_at ?? null
|
||||
const nextMessageCount = detail.summary.message_count
|
||||
const nextTurnCount = detail.turns.length
|
||||
|
||||
if (nextMessageCount < current.persistedMessageCount) return false
|
||||
if (nextTurnCount < current.persistedTurns.length) return false
|
||||
if (!current.persistedUpdatedAt || !nextUpdatedAt) return true
|
||||
if (nextUpdatedAt < current.persistedUpdatedAt) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function upsertExternalIdIndex(
|
||||
index: Map<string, number>,
|
||||
previousExternalId: string | null,
|
||||
nextExternalId: string | null,
|
||||
conversationId: number
|
||||
): Map<string, number> {
|
||||
const next = new Map(index)
|
||||
if (previousExternalId) {
|
||||
next.delete(previousExternalId)
|
||||
}
|
||||
if (nextExternalId) {
|
||||
next.set(nextExternalId, conversationId)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function reduceHydrateDetail(
|
||||
state: ConversationRuntimeState,
|
||||
conversationId: number,
|
||||
detail: DbConversationDetail
|
||||
): ConversationRuntimeState {
|
||||
const current = state.byConversationId.get(conversationId)
|
||||
const nextExternalId = detail.summary.external_id ?? null
|
||||
const acceptSnapshot = shouldAcceptPersistedSnapshot(current, detail)
|
||||
const optimisticTurns = current?.optimisticTurns ?? []
|
||||
const persistedTurns = acceptSnapshot
|
||||
? detail.turns
|
||||
: (current?.persistedTurns ?? [])
|
||||
const nextPersistedUpdatedAt = acceptSnapshot
|
||||
? (detail.summary.updated_at ?? null)
|
||||
: (current?.persistedUpdatedAt ?? null)
|
||||
const nextPersistedMessageCount = acceptSnapshot
|
||||
? detail.summary.message_count
|
||||
: (current?.persistedMessageCount ?? 0)
|
||||
const shouldDropOptimistic =
|
||||
optimisticTurns.length > 0 &&
|
||||
persistedTurns.length >= (current?.persistedTurns.length ?? 0) + 1
|
||||
|
||||
const nextSession: ConversationRuntimeSession = {
|
||||
...(current ?? createEmptySession(conversationId)),
|
||||
externalId: nextExternalId,
|
||||
persistedTurns,
|
||||
optimisticTurns: shouldDropOptimistic ? [] : optimisticTurns,
|
||||
syncState: shouldDropOptimistic ? "idle" : (current?.syncState ?? "idle"),
|
||||
activeTurnToken: shouldDropOptimistic
|
||||
? null
|
||||
: (current?.activeTurnToken ?? null),
|
||||
lastHydratedAt: Date.now(),
|
||||
lastPersistedAt: acceptSnapshot
|
||||
? Date.now()
|
||||
: (current?.lastPersistedAt ?? null),
|
||||
persistedUpdatedAt: nextPersistedUpdatedAt,
|
||||
persistedMessageCount: nextPersistedMessageCount,
|
||||
}
|
||||
|
||||
const nextByConversationId = new Map(state.byConversationId)
|
||||
nextByConversationId.set(conversationId, nextSession)
|
||||
const nextExternalIndex = upsertExternalIdIndex(
|
||||
state.conversationIdByExternalId,
|
||||
current?.externalId ?? null,
|
||||
nextExternalId,
|
||||
conversationId
|
||||
)
|
||||
|
||||
return {
|
||||
byConversationId: nextByConversationId,
|
||||
conversationIdByExternalId: nextExternalIndex,
|
||||
}
|
||||
}
|
||||
|
||||
function reducer(
|
||||
state: ConversationRuntimeState,
|
||||
action: Action
|
||||
): ConversationRuntimeState {
|
||||
switch (action.type) {
|
||||
case "HYDRATE_FROM_DETAIL":
|
||||
return reduceHydrateDetail(state, action.detail.summary.id, action.detail)
|
||||
|
||||
case "APPEND_OPTIMISTIC_TURN": {
|
||||
const current =
|
||||
state.byConversationId.get(action.conversationId) ??
|
||||
createEmptySession(action.conversationId)
|
||||
const nextSession: ConversationRuntimeSession = {
|
||||
...current,
|
||||
optimisticTurns: [...current.optimisticTurns, action.turn],
|
||||
syncState: "awaiting_persist",
|
||||
activeTurnToken: action.turnToken,
|
||||
}
|
||||
const nextByConversationId = new Map(state.byConversationId)
|
||||
nextByConversationId.set(action.conversationId, nextSession)
|
||||
return { ...state, byConversationId: nextByConversationId }
|
||||
}
|
||||
|
||||
case "SET_LIVE_MESSAGE": {
|
||||
const current =
|
||||
state.byConversationId.get(action.conversationId) ??
|
||||
createEmptySession(action.conversationId)
|
||||
const nextSession: ConversationRuntimeSession = {
|
||||
...current,
|
||||
liveMessage: action.liveMessage,
|
||||
}
|
||||
const nextByConversationId = new Map(state.byConversationId)
|
||||
nextByConversationId.set(action.conversationId, nextSession)
|
||||
return { ...state, byConversationId: nextByConversationId }
|
||||
}
|
||||
|
||||
case "ACK_PERSISTED_DETAIL": {
|
||||
const nextState = reduceHydrateDetail(
|
||||
state,
|
||||
action.conversationId,
|
||||
action.detail
|
||||
)
|
||||
const session = nextState.byConversationId.get(action.conversationId)
|
||||
if (!session) return nextState
|
||||
const nextSession: ConversationRuntimeSession = {
|
||||
...session,
|
||||
syncState: "idle",
|
||||
activeTurnToken:
|
||||
action.turnToken != null &&
|
||||
action.turnToken === session.activeTurnToken
|
||||
? null
|
||||
: session.activeTurnToken,
|
||||
}
|
||||
const nextByConversationId = new Map(nextState.byConversationId)
|
||||
nextByConversationId.set(action.conversationId, nextSession)
|
||||
return { ...nextState, byConversationId: nextByConversationId }
|
||||
}
|
||||
|
||||
case "SET_EXTERNAL_ID": {
|
||||
const current =
|
||||
state.byConversationId.get(action.conversationId) ??
|
||||
createEmptySession(action.conversationId)
|
||||
const nextSession: ConversationRuntimeSession = {
|
||||
...current,
|
||||
externalId: action.externalId,
|
||||
}
|
||||
const nextByConversationId = new Map(state.byConversationId)
|
||||
nextByConversationId.set(action.conversationId, nextSession)
|
||||
const nextExternalIndex = upsertExternalIdIndex(
|
||||
state.conversationIdByExternalId,
|
||||
current.externalId,
|
||||
action.externalId,
|
||||
action.conversationId
|
||||
)
|
||||
return {
|
||||
byConversationId: nextByConversationId,
|
||||
conversationIdByExternalId: nextExternalIndex,
|
||||
}
|
||||
}
|
||||
|
||||
case "SET_SYNC_STATE": {
|
||||
const current =
|
||||
state.byConversationId.get(action.conversationId) ??
|
||||
createEmptySession(action.conversationId)
|
||||
const nextSession: ConversationRuntimeSession = {
|
||||
...current,
|
||||
syncState: action.syncState,
|
||||
}
|
||||
const nextByConversationId = new Map(state.byConversationId)
|
||||
nextByConversationId.set(action.conversationId, nextSession)
|
||||
return { ...state, byConversationId: nextByConversationId }
|
||||
}
|
||||
|
||||
case "MIGRATE_CONVERSATION": {
|
||||
if (action.fromConversationId === action.toConversationId) return state
|
||||
const from = state.byConversationId.get(action.fromConversationId)
|
||||
if (!from) return state
|
||||
const to =
|
||||
state.byConversationId.get(action.toConversationId) ??
|
||||
createEmptySession(action.toConversationId)
|
||||
|
||||
const preferFromSnapshot =
|
||||
from.persistedTurns.length >= to.persistedTurns.length
|
||||
|
||||
const merged: ConversationRuntimeSession = {
|
||||
...to,
|
||||
...from,
|
||||
conversationId: action.toConversationId,
|
||||
persistedTurns: preferFromSnapshot
|
||||
? from.persistedTurns
|
||||
: to.persistedTurns,
|
||||
optimisticTurns: [...from.optimisticTurns, ...to.optimisticTurns],
|
||||
liveMessage: to.liveMessage ?? from.liveMessage,
|
||||
syncState: to.syncState !== "idle" ? to.syncState : from.syncState,
|
||||
activeTurnToken: to.activeTurnToken ?? from.activeTurnToken,
|
||||
lastHydratedAt:
|
||||
Math.max(from.lastHydratedAt ?? 0, to.lastHydratedAt ?? 0) || null,
|
||||
lastPersistedAt:
|
||||
Math.max(from.lastPersistedAt ?? 0, to.lastPersistedAt ?? 0) || null,
|
||||
persistedUpdatedAt:
|
||||
(to.persistedUpdatedAt ?? "") > (from.persistedUpdatedAt ?? "")
|
||||
? to.persistedUpdatedAt
|
||||
: from.persistedUpdatedAt,
|
||||
persistedMessageCount: Math.max(
|
||||
from.persistedMessageCount,
|
||||
to.persistedMessageCount
|
||||
),
|
||||
}
|
||||
|
||||
const nextByConversationId = new Map(state.byConversationId)
|
||||
nextByConversationId.delete(action.fromConversationId)
|
||||
nextByConversationId.set(action.toConversationId, merged)
|
||||
|
||||
const nextExternalIndex = new Map(state.conversationIdByExternalId)
|
||||
for (const [externalId, conversationId] of nextExternalIndex.entries()) {
|
||||
if (conversationId === action.fromConversationId) {
|
||||
nextExternalIndex.set(externalId, action.toConversationId)
|
||||
}
|
||||
}
|
||||
if (merged.externalId) {
|
||||
nextExternalIndex.set(merged.externalId, action.toConversationId)
|
||||
}
|
||||
|
||||
return {
|
||||
byConversationId: nextByConversationId,
|
||||
conversationIdByExternalId: nextExternalIndex,
|
||||
}
|
||||
}
|
||||
|
||||
case "REMOVE_CONVERSATION": {
|
||||
const current = state.byConversationId.get(action.conversationId)
|
||||
if (!current) return state
|
||||
const nextByConversationId = new Map(state.byConversationId)
|
||||
nextByConversationId.delete(action.conversationId)
|
||||
const nextExternalIndex = new Map(state.conversationIdByExternalId)
|
||||
if (current.externalId) {
|
||||
nextExternalIndex.delete(current.externalId)
|
||||
}
|
||||
return {
|
||||
byConversationId: nextByConversationId,
|
||||
conversationIdByExternalId: nextExternalIndex,
|
||||
}
|
||||
}
|
||||
|
||||
case "RESET":
|
||||
return initialState
|
||||
}
|
||||
}
|
||||
|
||||
interface ConversationRuntimeContextValue {
|
||||
getSession: (conversationId: number) => ConversationRuntimeSession | null
|
||||
getConversationIdByExternalId: (externalId: string) => number | null
|
||||
getTimelineTurns: (conversationId: number) => ConversationTimelineTurn[]
|
||||
hydrateFromDetail: (detail: DbConversationDetail) => void
|
||||
appendOptimisticTurn: (
|
||||
conversationId: number,
|
||||
turn: MessageTurn,
|
||||
turnToken: string
|
||||
) => void
|
||||
setLiveMessage: (
|
||||
conversationId: number,
|
||||
liveMessage: LiveMessage | null
|
||||
) => void
|
||||
acknowledgePersistedDetail: (
|
||||
conversationId: number,
|
||||
detail: DbConversationDetail,
|
||||
turnToken?: string | null
|
||||
) => void
|
||||
setExternalId: (conversationId: number, externalId: string | null) => void
|
||||
setSyncState: (
|
||||
conversationId: number,
|
||||
syncState: ConversationSyncState
|
||||
) => void
|
||||
migrateConversation: (
|
||||
fromConversationId: number,
|
||||
toConversationId: number
|
||||
) => void
|
||||
removeConversation: (conversationId: number) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const ConversationRuntimeContext =
|
||||
createContext<ConversationRuntimeContextValue | null>(null)
|
||||
|
||||
export function ConversationRuntimeProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState)
|
||||
|
||||
const getSession = useCallback(
|
||||
(conversationId: number) =>
|
||||
state.byConversationId.get(conversationId) ?? null,
|
||||
[state.byConversationId]
|
||||
)
|
||||
|
||||
const getConversationIdByExternalId = useCallback(
|
||||
(externalId: string) =>
|
||||
state.conversationIdByExternalId.get(externalId) ?? null,
|
||||
[state.conversationIdByExternalId]
|
||||
)
|
||||
|
||||
const getTimelineTurns = useCallback(
|
||||
(conversationId: number): ConversationTimelineTurn[] => {
|
||||
const session = state.byConversationId.get(conversationId)
|
||||
if (!session) return []
|
||||
|
||||
const persisted: ConversationTimelineTurn[] = session.persistedTurns.map(
|
||||
(turn, index) => ({
|
||||
key: `persisted-${conversationId}-${turn.id}-${index}`,
|
||||
turn,
|
||||
phase: "persisted",
|
||||
})
|
||||
)
|
||||
const optimistic: ConversationTimelineTurn[] =
|
||||
session.optimisticTurns.map((turn, index) => ({
|
||||
key: `optimistic-${conversationId}-${turn.id}-${index}`,
|
||||
turn,
|
||||
phase: "optimistic",
|
||||
}))
|
||||
const streamingMessage = session.liveMessage
|
||||
const streamingTurn = streamingMessage
|
||||
? buildStreamingTurnFromLiveMessage(conversationId, streamingMessage)
|
||||
: null
|
||||
|
||||
if (!streamingTurn) {
|
||||
return [...persisted, ...optimistic]
|
||||
}
|
||||
|
||||
return [
|
||||
...persisted,
|
||||
...optimistic,
|
||||
{
|
||||
key: `streaming-${conversationId}-${streamingMessage?.id ?? "unknown"}`,
|
||||
turn: streamingTurn,
|
||||
phase: "streaming",
|
||||
},
|
||||
]
|
||||
},
|
||||
[state.byConversationId]
|
||||
)
|
||||
|
||||
const hydrateFromDetail = useCallback((detail: DbConversationDetail) => {
|
||||
dispatch({ type: "HYDRATE_FROM_DETAIL", detail })
|
||||
}, [])
|
||||
|
||||
const appendOptimisticTurn = useCallback(
|
||||
(conversationId: number, turn: MessageTurn, turnToken: string) => {
|
||||
dispatch({
|
||||
type: "APPEND_OPTIMISTIC_TURN",
|
||||
conversationId,
|
||||
turn,
|
||||
turnToken,
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const setLiveMessage = useCallback(
|
||||
(conversationId: number, liveMessage: LiveMessage | null) => {
|
||||
dispatch({ type: "SET_LIVE_MESSAGE", conversationId, liveMessage })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const acknowledgePersistedDetail = useCallback(
|
||||
(
|
||||
conversationId: number,
|
||||
detail: DbConversationDetail,
|
||||
turnToken?: string | null
|
||||
) => {
|
||||
dispatch({
|
||||
type: "ACK_PERSISTED_DETAIL",
|
||||
conversationId,
|
||||
detail,
|
||||
turnToken,
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const setExternalId = useCallback(
|
||||
(conversationId: number, externalId: string | null) => {
|
||||
dispatch({ type: "SET_EXTERNAL_ID", conversationId, externalId })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const setSyncState = useCallback(
|
||||
(conversationId: number, syncState: ConversationSyncState) => {
|
||||
dispatch({ type: "SET_SYNC_STATE", conversationId, syncState })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const migrateConversation = useCallback(
|
||||
(fromConversationId: number, toConversationId: number) => {
|
||||
dispatch({
|
||||
type: "MIGRATE_CONVERSATION",
|
||||
fromConversationId,
|
||||
toConversationId,
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const removeConversation = useCallback((conversationId: number) => {
|
||||
dispatch({ type: "REMOVE_CONVERSATION", conversationId })
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
dispatch({ type: "RESET" })
|
||||
}, [])
|
||||
|
||||
const value = useMemo<ConversationRuntimeContextValue>(
|
||||
() => ({
|
||||
getSession,
|
||||
getConversationIdByExternalId,
|
||||
getTimelineTurns,
|
||||
hydrateFromDetail,
|
||||
appendOptimisticTurn,
|
||||
setLiveMessage,
|
||||
acknowledgePersistedDetail,
|
||||
setExternalId,
|
||||
setSyncState,
|
||||
migrateConversation,
|
||||
removeConversation,
|
||||
reset,
|
||||
}),
|
||||
[
|
||||
getSession,
|
||||
getConversationIdByExternalId,
|
||||
getTimelineTurns,
|
||||
hydrateFromDetail,
|
||||
appendOptimisticTurn,
|
||||
setLiveMessage,
|
||||
acknowledgePersistedDetail,
|
||||
setExternalId,
|
||||
setSyncState,
|
||||
migrateConversation,
|
||||
removeConversation,
|
||||
reset,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<ConversationRuntimeContext.Provider value={value}>
|
||||
{children}
|
||||
</ConversationRuntimeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useConversationRuntime() {
|
||||
const ctx = useContext(ConversationRuntimeContext)
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useConversationRuntime must be used within ConversationRuntimeProvider"
|
||||
)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -22,8 +22,8 @@ import type {
|
||||
|
||||
interface TabItemInternal {
|
||||
id: string
|
||||
kind: "conversation" | "new_conversation"
|
||||
conversationId?: number
|
||||
kind: "conversation"
|
||||
conversationId: number | null
|
||||
agentType: AgentType
|
||||
title: string
|
||||
isPinned: boolean
|
||||
@@ -43,18 +43,13 @@ interface TabContextValue {
|
||||
title?: string
|
||||
) => void
|
||||
closeTab: (tabId: string) => void
|
||||
closeConversationTab: (conversationId: number, agentType: AgentType) => void
|
||||
closeOtherTabs: (tabId: string) => void
|
||||
closeAllTabs: () => void
|
||||
switchTab: (tabId: string) => void
|
||||
pinTab: (tabId: string) => void
|
||||
openNewConversationTab: (agentType: AgentType, workingDir: string) => void
|
||||
promoteNewConversationTab: (
|
||||
tabId: string,
|
||||
conversationId: number,
|
||||
agentType: AgentType,
|
||||
title: string
|
||||
) => void
|
||||
linkTabConversation: (
|
||||
bindConversationTab: (
|
||||
tabId: string,
|
||||
conversationId: number,
|
||||
agentType: AgentType,
|
||||
@@ -83,13 +78,6 @@ function makeConversationTabId(
|
||||
function makeNewConversationTabId(): string {
|
||||
return `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a tab that represents the given conversation, regardless of whether
|
||||
* it has been promoted to a canonical id yet. Checks canonical id first,
|
||||
* then falls back to matching by conversationId + agentType (covers the
|
||||
* linked-but-not-yet-promoted new_conversation tabs).
|
||||
*/
|
||||
function findTabIndexForConversation(
|
||||
tabs: TabItemInternal[],
|
||||
agentType: AgentType,
|
||||
@@ -130,7 +118,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
return [
|
||||
{
|
||||
id: tabId,
|
||||
kind: "conversation" as const,
|
||||
kind: "conversation",
|
||||
conversationId: selectedConversation.id,
|
||||
agentType: selectedConversation.agentType,
|
||||
title: t("loadingConversation"),
|
||||
@@ -187,7 +175,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
|
||||
const restoredTabs: TabItemInternal[] = opened.map((oc) => ({
|
||||
id: makeConversationTabId(oc.agent_type, oc.conversation_id),
|
||||
kind: "conversation" as const,
|
||||
kind: "conversation",
|
||||
conversationId: oc.conversation_id,
|
||||
agentType: oc.agent_type,
|
||||
title: t("loadingConversation"),
|
||||
@@ -300,13 +288,20 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
cancelNewConversation()
|
||||
return
|
||||
}
|
||||
if (tab.kind === "conversation" && tab.conversationId != null) {
|
||||
if (tab.conversationId != null) {
|
||||
selectConversation(tab.conversationId, tab.agentType)
|
||||
} else if (tab.kind === "new_conversation" && tab.workingDir) {
|
||||
startNewConversation(tab.agentType, tab.workingDir)
|
||||
} else {
|
||||
const workingDir = tab.workingDir ?? folder?.path
|
||||
if (!workingDir) {
|
||||
clearSelection()
|
||||
cancelNewConversation()
|
||||
return
|
||||
}
|
||||
startNewConversation(tab.agentType, workingDir)
|
||||
}
|
||||
},
|
||||
[
|
||||
folder?.path,
|
||||
selectConversation,
|
||||
clearSelection,
|
||||
startNewConversation,
|
||||
@@ -386,10 +381,11 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
[activateConversationPane, selectConversation, t]
|
||||
)
|
||||
|
||||
const makeReplacementNewConversationTab = useCallback(
|
||||
const makeReplacementDraftTab = useCallback(
|
||||
(preferred?: TabItemInternal): TabItemInternal => ({
|
||||
id: makeNewConversationTabId(),
|
||||
kind: "new_conversation",
|
||||
kind: "conversation",
|
||||
conversationId: null,
|
||||
agentType: preferred?.agentType ?? "codex",
|
||||
title: t("newConversation"),
|
||||
isPinned: true,
|
||||
@@ -410,7 +406,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
const next = prev.filter((t) => t.id !== tabId)
|
||||
|
||||
if (next.length === 0) {
|
||||
const replacementTab = makeReplacementNewConversationTab(closingTab)
|
||||
const replacementTab = makeReplacementDraftTab(closingTab)
|
||||
neighborToSync = replacementTab
|
||||
return [replacementTab]
|
||||
}
|
||||
@@ -433,11 +429,19 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
activateConversationPane()
|
||||
}
|
||||
},
|
||||
[
|
||||
activateConversationPane,
|
||||
makeReplacementNewConversationTab,
|
||||
syncFolderContext,
|
||||
]
|
||||
[activateConversationPane, makeReplacementDraftTab, syncFolderContext]
|
||||
)
|
||||
|
||||
const closeConversationTab = useCallback(
|
||||
(conversationId: number, agentType: AgentType) => {
|
||||
const target = rawTabsRef.current.find(
|
||||
(tab) =>
|
||||
tab.conversationId === conversationId && tab.agentType === agentType
|
||||
)
|
||||
if (!target) return
|
||||
closeTab(target.id)
|
||||
},
|
||||
[closeTab]
|
||||
)
|
||||
|
||||
const closeOtherTabs = useCallback(
|
||||
@@ -459,21 +463,17 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
const closeAllTabs = useCallback(() => {
|
||||
const seedTab =
|
||||
rawTabsRef.current.find(
|
||||
(t) => t.kind === "new_conversation" && t.workingDir
|
||||
(t) => t.conversationId == null && t.workingDir
|
||||
) ??
|
||||
rawTabsRef.current.find((t) => t.id === activeTabIdRef.current) ??
|
||||
rawTabsRef.current[0]
|
||||
|
||||
const replacementTab = makeReplacementNewConversationTab(seedTab)
|
||||
const replacementTab = makeReplacementDraftTab(seedTab)
|
||||
setTabs([replacementTab])
|
||||
setActiveTabId(replacementTab.id)
|
||||
syncFolderContext(replacementTab)
|
||||
activateConversationPane()
|
||||
}, [
|
||||
activateConversationPane,
|
||||
makeReplacementNewConversationTab,
|
||||
syncFolderContext,
|
||||
])
|
||||
}, [activateConversationPane, makeReplacementDraftTab, syncFolderContext])
|
||||
|
||||
const switchTab = useCallback(
|
||||
(tabId: string) => {
|
||||
@@ -501,10 +501,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
const openNewConversationTab = useCallback(
|
||||
(agentType: AgentType, workingDir: string) => {
|
||||
const existingTab = rawTabsRef.current.find(
|
||||
(t) =>
|
||||
t.kind === "new_conversation" &&
|
||||
t.agentType === agentType &&
|
||||
!t.conversationId
|
||||
(t) => t.conversationId == null && t.agentType === agentType
|
||||
)
|
||||
|
||||
if (existingTab) {
|
||||
@@ -517,7 +514,8 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
const tabId = makeNewConversationTabId()
|
||||
const newTab: TabItemInternal = {
|
||||
id: tabId,
|
||||
kind: "new_conversation",
|
||||
kind: "conversation",
|
||||
conversationId: null,
|
||||
agentType,
|
||||
title: t("newConversation"),
|
||||
isPinned: true,
|
||||
@@ -532,71 +530,45 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
[activateConversationPane, startNewConversation, syncFolderContext, t]
|
||||
)
|
||||
|
||||
const linkTabConversation = useCallback(
|
||||
const bindConversationTab = useCallback(
|
||||
(
|
||||
tabId: string,
|
||||
conversationId: number,
|
||||
agentType: AgentType,
|
||||
title: string
|
||||
) => {
|
||||
let nextActiveTabId: string | null = null
|
||||
setTabs((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === tabId ? { ...t, conversationId, agentType, title } : t
|
||||
)
|
||||
prev.flatMap((tab) => {
|
||||
if (tab.id === tabId) {
|
||||
const nextTab = { ...tab, conversationId, agentType, title }
|
||||
return [nextTab]
|
||||
}
|
||||
|
||||
if (
|
||||
tab.conversationId === conversationId &&
|
||||
tab.agentType === agentType
|
||||
) {
|
||||
if (activeTabIdRef.current === tabId) {
|
||||
nextActiveTabId = tab.id
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
return [tab]
|
||||
})
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const promoteNewConversationTab = useCallback(
|
||||
(
|
||||
tabId: string,
|
||||
conversationId: number,
|
||||
agentType: AgentType,
|
||||
title: string
|
||||
) => {
|
||||
let activateId: string | undefined
|
||||
|
||||
setTabs((prev) => {
|
||||
const index = prev.findIndex((t) => t.id === tabId)
|
||||
if (index < 0) return prev
|
||||
|
||||
const newId = makeConversationTabId(agentType, conversationId)
|
||||
|
||||
// Check if a *different* tab already represents this conversation
|
||||
const dupeIndex = findTabIndexForConversation(
|
||||
prev,
|
||||
agentType,
|
||||
conversationId
|
||||
if (nextActiveTabId) {
|
||||
setActiveTabId(nextActiveTabId)
|
||||
const target = rawTabsRef.current.find(
|
||||
(tab) => tab.id === nextActiveTabId
|
||||
)
|
||||
if (dupeIndex >= 0 && dupeIndex !== index) {
|
||||
activateId = prev[dupeIndex].id
|
||||
return prev.filter((t) => t.id !== tabId)
|
||||
if (target) {
|
||||
syncFolderContext(target)
|
||||
}
|
||||
|
||||
const promoted: TabItemInternal = {
|
||||
...prev[index],
|
||||
id: newId,
|
||||
kind: "conversation",
|
||||
conversationId,
|
||||
agentType,
|
||||
title,
|
||||
isPinned: true,
|
||||
}
|
||||
activateId = newId
|
||||
|
||||
const updated = [...prev]
|
||||
updated[index] = promoted
|
||||
return updated
|
||||
})
|
||||
|
||||
if (activateId) {
|
||||
setActiveTabId(activateId)
|
||||
selectConversation(conversationId, agentType)
|
||||
activateConversationPane()
|
||||
}
|
||||
},
|
||||
[activateConversationPane, selectConversation]
|
||||
[syncFolderContext]
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
@@ -605,13 +577,13 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
activeTabId,
|
||||
openTab,
|
||||
closeTab,
|
||||
closeConversationTab,
|
||||
closeOtherTabs,
|
||||
closeAllTabs,
|
||||
switchTab,
|
||||
pinTab,
|
||||
openNewConversationTab,
|
||||
promoteNewConversationTab,
|
||||
linkTabConversation,
|
||||
bindConversationTab,
|
||||
reorderTabs,
|
||||
}),
|
||||
[
|
||||
@@ -619,13 +591,13 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
activeTabId,
|
||||
openTab,
|
||||
closeTab,
|
||||
closeConversationTab,
|
||||
closeOtherTabs,
|
||||
closeAllTabs,
|
||||
switchTab,
|
||||
pinTab,
|
||||
openNewConversationTab,
|
||||
promoteNewConversationTab,
|
||||
linkTabConversation,
|
||||
bindConversationTab,
|
||||
reorderTabs,
|
||||
]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user