diff --git a/packages/ui/src/components/message-stream-v2.tsx b/packages/ui/src/components/message-stream-v2.tsx index 44e8fd26..49840aa2 100644 --- a/packages/ui/src/components/message-stream-v2.tsx +++ b/packages/ui/src/components/message-stream-v2.tsx @@ -2,8 +2,8 @@ import { For, Show, createMemo, createSignal, createEffect, onCleanup } from "so import MessageItem from "./message-item" import ToolCall from "./tool-call" import Kbd from "./kbd" -import type { Message, MessageInfo, ClientPart } from "../types/message" -import { computeDisplayParts } from "../stores/session-messages" +import type { Message, MessageInfo, ClientPart, MessageDisplayParts } from "../types/message" +import { partHasRenderableText } from "../types/message" import { getSessionInfo } from "../stores/sessions" import { showCommandPalette } from "../stores/command-palette" import { messageStoreBus } from "../stores/message-v2/bus" @@ -71,6 +71,27 @@ function recordToMessage(record: MessageRecord): Message { } } +function computeDisplayPartsForMessage(message: Message, showThinking: boolean): MessageDisplayParts { + const text: ClientPart[] = [] + const tool: ClientPart[] = [] + const reasoning: ClientPart[] = [] + + for (const part of message.parts) { + if (part.type === "text" && !part.synthetic && partHasRenderableText(part)) { + text.push(part) + } else if (part.type === "tool") { + tool.push(part) + } else if (part.type === "reasoning" && showThinking && partHasRenderableText(part)) { + reasoning.push(part) + } + } + + const combined = reasoning.length > 0 ? [...text, ...reasoning] : [...text] + const version = typeof message.version === "number" ? message.version : 0 + + return { text, tool, reasoning, combined, showThinking, version } +} + function hasRenderableContent(message: Message, combinedParts: ClientPart[], info?: MessageInfo): boolean { if (message.type !== "assistant" && message.type !== "user") { return false @@ -156,7 +177,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { } const baseMessage = recordToMessage(record) - const displayParts = computeDisplayParts(baseMessage, showThinking) + const displayParts = computeDisplayPartsForMessage(baseMessage, showThinking) baseMessage.displayParts = displayParts const combinedParts = displayParts.combined const messageInfo = infoMap.get(record.id) diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index 1794af61..f2127072 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -258,7 +258,7 @@ export function useCommands(options: UseCommandsOptions) { const revertState = store.getSessionRevert(sessionId) ?? session.revert let after = 0 if (revertState?.messageID) { - const revertInfo = infoMap.get(revertState.messageID) ?? session.messagesInfo.get(revertState.messageID) + const revertInfo = infoMap.get(revertState.messageID) ?? store.getMessageInfo(revertState.messageID) after = revertInfo?.time?.created || 0 } @@ -267,7 +267,7 @@ export function useCommands(options: UseCommandsOptions) { for (let i = messageIds.length - 1; i >= 0; i--) { const id = messageIds[i] const record = store.getMessage(id) - const info = infoMap.get(id) + const info = infoMap.get(id) ?? store.getMessageInfo(id) if (record?.role === "user" && info?.time?.created) { if (after > 0 && info.time.created >= after) { continue @@ -278,25 +278,6 @@ export function useCommands(options: UseCommandsOptions) { } } - if (!messageID) { - for (let i = session.messages.length - 1; i >= 0; i--) { - const msg = session.messages[i] - const info = session.messagesInfo.get(msg.id) - - if (msg.type === "user" && info?.time?.created) { - if (after > 0 && info.time.created >= after) { - continue - } - messageID = msg.id - const textParts = msg.parts.filter((p): p is ClientPart & { type: "text"; text: string } => p.type === "text" && typeof (p as any).text === "string") - if (textParts.length > 0) { - restoredText = textParts.map((p) => (p as any).text as string).join("\n") - } - break - } - } - } - if (!messageID) { showAlertDialog("Nothing to undo", { title: "No actions to undo", @@ -312,16 +293,8 @@ export function useCommands(options: UseCommandsOptions) { }) if (!restoredText) { - const revertedMessage = session.messages.find((m) => m.id === messageID) - const revertedInfo = session.messagesInfo.get(messageID) - if (revertedMessage && revertedInfo?.role === "user") { - const textParts = revertedMessage.parts.filter( - (p): p is ClientPart & { type: "text"; text: string } => p.type === "text" && typeof (p as any).text === "string", - ) - if (textParts.length > 0) { - restoredText = textParts.map((p) => (p as any).text as string).join("\n") - } - } + const fallbackRecord = store.getMessage(messageID) + restoredText = extractUserTextFromRecord(fallbackRecord) } if (restoredText) { diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 1a1f3fb3..1e26e919 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -1,7 +1,8 @@ import { createSignal } from "solid-js" +import { produce } from "solid-js/store" import type { Instance, LogEntry } from "../types/instance" import type { LspStatus, Permission } from "@opencode-ai/sdk" -import type { ClientPart, Message } from "../types/message" +import type { ClientPart } from "../types/message" import { sdkManager } from "../lib/sdk-manager" import { sseManager } from "../lib/sse-manager" import { serverApi } from "../lib/api-client" @@ -17,9 +18,10 @@ import { } from "./sessions" import { fetchCommands, clearCommands } from "./commands" import { preferences } from "./preferences" -import { computeDisplayParts } from "./session-messages" -import { withSession, setSessionPendingPermission } from "./session-state" +import { setSessionPendingPermission } from "./session-state" import { setHasInstances } from "./ui" +import { messageStoreBus } from "./message-v2/bus" +import type { MessageRecord } from "./message-v2/types" const [instances, setInstances] = createSignal>(new Map()) @@ -539,23 +541,49 @@ function getPermissionSessionId(permission: Permission): string { return (permission as any).sessionID } -function findToolPartForPermission(message: Message, permission: Permission): ClientPart | null { - const expectedCallId = permission.callID - for (const part of message.parts) { - if (part.type !== "tool") continue - const toolCallId = (part as any).callID +function getPermissionMessageId(permission: Permission): string | undefined { + return (permission as any).messageID ?? (permission as any).messageId ?? undefined +} + +function getPermissionCallIdentifier(permission: Permission): string | undefined { + return ( + (permission as any).callID ?? + (permission as any).callId ?? + (permission as any).toolCallID ?? + (permission as any).toolCallId ?? + undefined + ) +} + +function findToolPartForPermission(record: MessageRecord, permission: Permission): { partId: string; part: ClientPart } | null { + const expectedCallId = getPermissionCallIdentifier(permission) + const permissionId = permission.id + const permissionMessageId = getPermissionMessageId(permission) + + for (const partId of record.partIds) { + const entry = record.parts[partId] + if (!entry) continue + const part = entry.data + if (!part || part.type !== "tool") continue + const toolCallId = (part as any).callID ?? (part as any).callId + const partMessageId = (part as any).messageID ?? (part as any).messageId + if (expectedCallId) { if (toolCallId === expectedCallId) { - return part as ClientPart + return { partId, part } } - if (!toolCallId && (part.id === expectedCallId || part.messageID === permission.messageID)) { - return part as ClientPart + if (!toolCallId && (part.id === expectedCallId || (permissionMessageId && partMessageId === permissionMessageId))) { + return { partId, part } } continue } - if ((toolCallId && toolCallId === permission.id) || part.id === permission.id || part.messageID === permission.messageID) { - return part as ClientPart + if ( + (toolCallId && toolCallId === permissionId) || + part.id === permissionId || + (permissionMessageId && partMessageId === permissionMessageId) + ) { + return { partId, part } } } return null @@ -564,23 +592,31 @@ function findToolPartForPermission(message: Message, permission: Permission): Cl function mutateToolPartPermission( instanceId: string, permission: Permission, - mutator: (part: ClientPart, message: Message) => boolean, + mutator: (part: ClientPart) => boolean, ): void { - const permissionSessionId = getPermissionSessionId(permission) - withSession(instanceId, permissionSessionId, (session) => { - const message = session.messages.find((msg) => msg.id === permission.messageID) - if (!message) return - const targetPart = findToolPartForPermission(message, permission) - if (!targetPart) return + const messageId = getPermissionMessageId(permission) + if (!messageId) return + const store = messageStoreBus.getOrCreate(instanceId) + const messageRecord = store.getMessage(messageId) + if (!messageRecord) return + const targetPart = findToolPartForPermission(messageRecord, permission) + if (!targetPart) return - const changed = mutator(targetPart, message) - if (!changed) return - - const nextPartVersion = typeof targetPart.version === "number" ? targetPart.version + 1 : 1 - targetPart.version = nextPartVersion - message.version = (message.version ?? 0) + 1 - message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks) - }) + store.setState( + "messages", + messageId, + produce((draft: MessageRecord) => { + const partRecord = draft.parts[targetPart.partId] + if (!partRecord) return + const changed = mutator(partRecord.data) + if (!changed) return + const nextVersion = typeof partRecord.data.version === "number" ? partRecord.data.version + 1 : 1 + partRecord.data.version = nextVersion + partRecord.revision += 1 + draft.revision += 1 + draft.updatedAt = Date.now() + }), + ) } function attachPermissionToToolPart(instanceId: string, permission: Permission, active: boolean): void { diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index 11def492..7fe5875a 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -156,11 +156,12 @@ export function removePermissionV2(instanceId: string, permissionId: string): vo export function ensureSessionMetadataV2(instanceId: string, session: Session | null | undefined): void { if (!session) return const store = messageStoreBus.getOrCreate(instanceId) + const existingMessageIds = store.getSessionMessageIds(session.id) store.addOrUpdateSession({ id: session.id, title: session.title, parentId: session.parentId ?? null, - messageIds: session.messages.map((message: Message) => message.id), + messageIds: existingMessageIds, }) } diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index ccc8d581..a87ecfcb 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -2,7 +2,7 @@ import type { Session } from "../types/session" import type { Message } from "../types/message" import { instances, refreshPermissionsForSession } from "./instances" -import { preferences, setAgentModelPreference } from "./preferences" +import { setAgentModelPreference } from "./preferences" import { setSessionCompactionState } from "./session-compaction" import { activeSessionId, @@ -22,16 +22,7 @@ import { setLoading, } from "./session-state" import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models" -import { - computeDisplayParts, - clearSessionIndex, - getSessionIndex, - initializePartVersion, - normalizeMessagePart, - rebuildSessionIndex, - rebuildSessionUsage, - updateSessionInfo, -} from "./session-messages" +import { normalizeMessagePart, updateSessionInfo } from "./session-messages" import { seedSessionMessagesV2 } from "./message-v2/bridge" interface SessionForkResponse { @@ -101,8 +92,8 @@ async function fetchSessions(instanceId: string): Promise { diff: apiSession.revert.diff, } : undefined, - messages: existingSession?.messages ?? [], - messagesInfo: existingSession?.messagesInfo ?? new Map(), + messages: [], + messagesInfo: new Map(), }) } @@ -238,8 +229,6 @@ async function createSession(instanceId: string, agent?: string): Promise { const next = new Map(prev) @@ -522,8 +507,8 @@ async function loadMessages(instanceId: string, sessionId: string, force = false }) try { - console.log(`[HTTP] GET /session.messages for instance ${instanceId}`, { sessionId }) - const response = await instance.client.session.messages({ path: { id: sessionId } }) + console.log(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) + const response = await instance.client.session["messages"]({ path: { id: sessionId } }) if (!response.data || !Array.isArray(response.data)) { return @@ -549,10 +534,6 @@ async function loadMessages(instanceId: string, sessionId: string, force = false version: 0, } - parts.forEach((part: any) => initializePartVersion(part)) - - message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks) - return message }) @@ -587,8 +568,6 @@ async function loadMessages(instanceId: string, sessionId: string, force = false if (existingSession) { const updatedSession = { ...existingSession, - messages, - messagesInfo, agent: agentName || existingSession.agent, model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model, } @@ -600,9 +579,6 @@ async function loadMessages(instanceId: string, sessionId: string, force = false return next }) - rebuildSessionIndex(instanceId, sessionId, messages) - rebuildSessionUsage(instanceId, sessionId, messagesInfo) - setMessagesLoaded((prev) => { const next = new Map(prev) const loadedSet = next.get(instanceId) || new Set() diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index 4c4d982f..e646cd64 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -109,8 +109,6 @@ function withSession(instanceId: string, sessionId: string, updater: (session: S const updatedSession = { ...session, - messages: [...session.messages], - messagesInfo: new Map(session.messagesInfo), } setSessions((prev) => { diff --git a/packages/ui/src/stores/session-status.ts b/packages/ui/src/stores/session-status.ts index 54588104..7d77827e 100644 --- a/packages/ui/src/stores/session-status.ts +++ b/packages/ui/src/stores/session-status.ts @@ -1,5 +1,5 @@ import type { Session, SessionStatus } from "../types/session" -import type { Message, MessageInfo } from "../types/message" +import type { MessageInfo } from "../types/message" import type { MessageRecord } from "./message-v2/types" import { sessions } from "./sessions" import { isSessionCompactionActive } from "./session-compaction" @@ -55,38 +55,6 @@ function getLastMessageFromStore(instanceId: string, sessionId: string): Message return latest } -function getLegacyLastMessage(session: Session): Message | undefined { - let latest: Message | undefined - let latestTimestamp = Number.NEGATIVE_INFINITY - for (const message of session.messages) { - if (!message) continue - const info = session.messagesInfo.get(message.id) - const timestamp = info?.time?.created ?? message.timestamp ?? Number.NEGATIVE_INFINITY - if (timestamp >= latestTimestamp) { - latest = message - latestTimestamp = timestamp - } - } - return latest -} - -function getLegacyLastMessageInfo(session: Session, role?: MessageInfo["role"]): MessageInfo | undefined { - if (session.messagesInfo.size === 0) { - return undefined - } - let latest: MessageInfo | undefined - let latestTimestamp = Number.NEGATIVE_INFINITY - for (const info of session.messagesInfo.values()) { - if (!info) continue - if (role && info.role !== role) continue - const timestamp = info.time?.created ?? 0 - if (timestamp >= latestTimestamp) { - latest = info - latestTimestamp = timestamp - } - } - return latest -} function getInfoCreatedTimestamp(info?: MessageInfo): number { if (!info) { @@ -143,26 +111,6 @@ function isAssistantStillGeneratingRecord(record: MessageRecord, info?: MessageI return !(record.status === "complete" || record.status === "sent") } -function isAssistantStillGeneratingLegacy(message: Message, info?: MessageInfo): boolean { - if (message.type !== "assistant") { - return false - } - - if (message.status === "error") { - return false - } - - if (message.status === "streaming" || message.status === "sending") { - return true - } - - const completedAt = (info?.time as { completed?: number } | undefined)?.completed - if (completedAt !== undefined && completedAt !== null) { - return false - } - - return !(message.status === "complete" || message.status === "sent") -} export function getSessionStatus(instanceId: string, sessionId: string): SessionStatus { const session = getSession(instanceId, sessionId) @@ -176,14 +124,13 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session return "compacting" } - const latestUserInfo = getLatestInfoFromStore(instanceId, sessionId, "user") ?? getLegacyLastMessageInfo(session, "user") - const latestAssistantInfo = getLatestInfoFromStore(instanceId, sessionId, "assistant") ?? getLegacyLastMessageInfo(session, "assistant") + const latestUserInfo = getLatestInfoFromStore(instanceId, sessionId, "user") + const latestAssistantInfo = getLatestInfoFromStore(instanceId, sessionId, "assistant") const lastRecord = getLastMessageFromStore(instanceId, sessionId) - const legacyFallbackMessage = lastRecord ? undefined : getLegacyLastMessage(session) - if (!lastRecord && !legacyFallbackMessage) { - const latestInfo = latestUserInfo ?? latestAssistantInfo ?? getLegacyLastMessageInfo(session) + if (!lastRecord) { + const latestInfo = latestUserInfo ?? latestAssistantInfo if (!latestInfo) { return "idle" } @@ -194,22 +141,12 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session return infoCompleted ? "idle" : "working" } - if (lastRecord) { - if (lastRecord.role === "user") { - return "working" - } - const infoForRecord = store.getMessageInfo(lastRecord.id) ?? latestAssistantInfo - if (infoForRecord && isAssistantStillGeneratingRecord(lastRecord, infoForRecord)) { - return "working" - } - } else if (legacyFallbackMessage) { - if (legacyFallbackMessage.type === "user") { - return "working" - } - const infoForLegacy = session.messagesInfo.get(legacyFallbackMessage.id) ?? latestAssistantInfo - if (isAssistantStillGeneratingLegacy(legacyFallbackMessage, infoForLegacy)) { - return "working" - } + if (lastRecord.role === "user") { + return "working" + } + const infoForRecord = store.getMessageInfo(lastRecord.id) ?? latestAssistantInfo + if (infoForRecord && isAssistantStillGeneratingRecord(lastRecord, infoForRecord)) { + return "working" } if (isAssistantInfoPending(latestAssistantInfo)) {