align permission attachments with SSE stream

This commit is contained in:
Shantur Rathore
2025-12-02 23:53:34 +00:00
parent 78338f33c1
commit 6a16dd8f98
6 changed files with 53 additions and 160 deletions

View File

@@ -208,17 +208,19 @@ export default function ToolCall(props: ToolCallProps) {
const { isDark } = useTheme()
const toolCallMemo = createMemo(() => props.toolCall)
const toolName = createMemo(() => toolCallMemo()?.tool || "")
const toolCallId = () => props.toolCallId || toolCallMemo()?.id || ""
const toolCallIdentifier = createMemo(() => toolCallMemo()?.callID || props.toolCallId || toolCallMemo()?.id || "")
const toolState = createMemo(() => toolCallMemo()?.state)
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
const cacheContext = createMemo(() => ({
toolCallId: toolCallId(),
toolCallId: toolCallIdentifier(),
messageId: props.messageId,
partId: toolCallMemo()?.id ?? null,
}))
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
const createVariantCache = (variant: string) =>
useGlobalCache({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
@@ -232,7 +234,7 @@ export default function ToolCall(props: ToolCallProps) {
const diffCache = createVariantCache("diff")
const permissionDiffCache = createVariantCache("permission-diff")
const markdownCache = createVariantCache("markdown")
const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallMemo()?.id))
const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallIdentifier()))
const pendingPermission = createMemo(() => {
const state = permissionState()
if (state) {

View File

@@ -1,8 +1,6 @@
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 } from "../types/message"
import { sdkManager } from "../lib/sdk-manager"
import { sseManager } from "../lib/sse-manager"
import { serverApi } from "../lib/api-client"
@@ -21,10 +19,9 @@ import { setSessionPendingPermission } from "./session-state"
import { setHasInstances } from "./ui"
import { messageStoreBus } from "./message-v2/bus"
import { clearCacheForInstance } from "../lib/global-cache"
import type { MessageRecord } from "./message-v2/types"
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
@@ -461,17 +458,6 @@ function addPermissionToQueue(instanceId: string, permission: Permission): void
const sessionId = getPermissionSessionId(permission)
incrementSessionPendingCount(instanceId, sessionId)
setSessionPendingPermission(instanceId, sessionId, true)
const isActive = getActivePermission(instanceId)?.id === permission.id
attachPermissionToToolPart(instanceId, permission, isActive)
}
function getActivePermission(instanceId: string): Permission | null {
const activeId = activePermissionId().get(instanceId)
if (!activeId) return null
const queue = getPermissionQueue(instanceId)
return queue.find(p => p.id === activeId) ?? null
}
function removePermissionFromQueue(instanceId: string, permissionId: string): void {
@@ -512,16 +498,10 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
const removed = removedPermission
if (removed) {
clearPermissionFromToolPart(instanceId, removed)
const removedSessionId = getPermissionSessionId(removed)
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0)
}
const nextActivePermission = getActivePermission(instanceId)
if (nextActivePermission) {
attachPermissionToToolPart(instanceId, nextActivePermission, true)
}
}
function clearPermissionQueue(instanceId: string): void {
@@ -542,131 +522,6 @@ function getPermissionSessionId(permission: Permission): string {
return (permission as any).sessionID
}
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 { partId, part }
}
if (!toolCallId && (part.id === expectedCallId || (permissionMessageId && partMessageId === permissionMessageId))) {
return { partId, part }
}
continue
}
if (
(toolCallId && toolCallId === permissionId) ||
part.id === permissionId ||
(permissionMessageId && partMessageId === permissionMessageId)
) {
return { partId, part }
}
}
return null
}
function mutateToolPartPermission(
instanceId: string,
permission: Permission,
mutator: (part: ClientPart) => boolean,
): void {
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
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()
}),
)
// Permission attachment/removal can change the rendered height of the
// message list (e.g., permission blocks or diffs), so bump the
// session revision to ensure auto-scroll reacts.
if (messageRecord.sessionId) {
store.setState("sessionRevisions", messageRecord.sessionId, (value: number = 0) => value + 1)
}
}
function attachPermissionToToolPart(instanceId: string, permission: Permission, active: boolean): void {
mutateToolPartPermission(instanceId, permission, (part) => {
const existing = part.pendingPermission
if (existing && existing.permission.id === permission.id && existing.active === active) {
return false
}
part.pendingPermission = { permission, active }
return true
})
}
function clearPermissionFromToolPart(instanceId: string, permission: Permission): void {
mutateToolPartPermission(instanceId, permission, (part) => {
if (!part.pendingPermission || part.pendingPermission.permission.id !== permission.id) {
return false
}
delete part.pendingPermission
return true
})
}
function refreshPermissionsForSession(instanceId: string, sessionId: string): void {
const queue = getPermissionQueue(instanceId)
if (queue.length === 0) {
setSessionPendingPermission(instanceId, sessionId, false)
return
}
const activeId = activePermissionId().get(instanceId)
for (const permission of queue) {
if (getPermissionSessionId(permission) !== sessionId) continue
const isActive = permission.id === activeId
attachPermissionToToolPart(instanceId, permission, isActive)
}
const pendingCount = permissionSessionCounts.get(instanceId)?.get(sessionId) ?? 0
setSessionPendingPermission(instanceId, sessionId, pendingCount > 0)
}
async function sendPermissionResponse(
instanceId: string,
sessionId: string,
@@ -768,10 +623,8 @@ export {
getPermissionQueue,
getPermissionQueueLength,
addPermissionToQueue,
getActivePermission,
removePermissionFromQueue,
clearPermissionQueue,
refreshPermissionsForSession,
sendPermissionResponse,
disconnectedInstance,
acknowledgeDisconnectedInstance,

View File

@@ -40,7 +40,22 @@ function ensurePartId(messageId: string, part: ClientPart, index: number): strin
if (typeof part.id === "string" && part.id.length > 0) {
return part.id
}
return `${messageId}-part-${index}`
const toolCallId =
(part as any).callID ??
(part as any).callId ??
(part as any).toolCallID ??
(part as any).toolCallId ??
undefined
if (part.type === "tool" && typeof toolCallId === "string" && toolCallId.length > 0) {
part.id = toolCallId
return toolCallId
}
const fallbackId = `${messageId}-part-${index}`
part.id = fallbackId
return fallbackId
}
const PENDING_PART_MAX_AGE_MS = 30_000

View File

@@ -26,11 +26,37 @@ function decodeTextSegment(segment: any): any {
return segment
}
function deriveToolPartId(part: any): string | undefined {
if (!part || typeof part !== "object") {
return undefined
}
if (part.type !== "tool") {
return undefined
}
const callId =
part.callID ??
part.callId ??
part.toolCallID ??
part.toolCallId ??
undefined
if (typeof callId === "string" && callId.length > 0) {
return callId
}
return undefined
}
export function normalizeMessagePart(part: any): any {
if (!part || typeof part !== "object") {
return part
}
if ((typeof part.id !== "string" || part.id.length === 0) && part.type === "tool") {
const inferredId = deriveToolPartId(part)
if (inferredId) {
part = { ...part, id: inferredId }
}
}
if (part.type !== "text") {
return part
}

View File

@@ -1,7 +1,7 @@
import type { Session } from "../types/session"
import type { Message } from "../types/message"
import { instances, refreshPermissionsForSession } from "./instances"
import { instances } from "./instances"
import { preferences, setAgentModelPreference } from "./preferences"
import { setSessionCompactionState } from "./session-compaction"
import {
@@ -618,7 +618,6 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
}
updateSessionInfo(instanceId, sessionId)
refreshPermissionsForSession(instanceId, sessionId)
}
export {

View File

@@ -16,7 +16,7 @@ import type {
import type { MessageStatus } from "./message-v2/types"
import { showToastNotification, ToastVariant } from "../lib/notifications"
import { instances, addPermissionToQueue, removePermissionFromQueue, refreshPermissionsForSession } from "./instances"
import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances"
import { showAlertDialog } from "./alerts"
import {
sessions,
@@ -129,7 +129,6 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
updateSessionInfo(instanceId, sessionId)
refreshPermissionsForSession(instanceId, sessionId)
} else if (event.type === "message.updated") {
const info = event.properties?.info
if (!info) return
@@ -171,13 +170,12 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true })
updateSessionInfo(instanceId, sessionId)
refreshPermissionsForSession(instanceId, sessionId)
}
}
}
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
const info = event.properties?.info
if (!info) return
const compactingFlag = info.time?.compacting