restore: bring back all custom UI enhancements from checkpoint
Restored from commit 52be710 (checkpoint before qwen oauth + todo roller): Enhanced UI Features: - SMART FIX button with AI code analysis - APEX (Autonomous Programming EXecution) mode - SHIELD (Auto-approval) mode - MULTIX MODE multi-task pipeline interface - Live streaming token counter - Thinking indicator with bouncing dots animation Components restored: - packages/ui/src/components/chat/multi-task-chat.tsx - packages/ui/src/components/instance/instance-shell2.tsx - packages/ui/src/components/settings/OllamaCloudSettings.tsx - packages/ui/src/components/settings/QwenCodeSettings.tsx - packages/ui/src/stores/solo-store.ts - packages/ui/src/stores/task-actions.ts - packages/ui/src/stores/session-events.ts (autonomous mode) - packages/server/src/integrations/ollama-cloud.ts - packages/server/src/server/routes/ollama.ts - packages/server/src/server/routes/qwen.ts This ensures all custom features are preserved in source control.
This commit is contained in:
@@ -5,7 +5,7 @@ import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], agentModelSelections: {} }
|
||||
const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], agentModelSelections: {}, sessionTasks: {} }
|
||||
|
||||
const [instanceDataMap, setInstanceDataMap] = createSignal<Map<string, InstanceData>>(new Map())
|
||||
const loadPromises = new Map<string, Promise<void>>()
|
||||
@@ -17,6 +17,7 @@ function cloneInstanceData(data?: InstanceData | null): InstanceData {
|
||||
...source,
|
||||
messageHistory: Array.isArray(source.messageHistory) ? [...source.messageHistory] : [],
|
||||
agentModelSelections: { ...(source.agentModelSelections ?? {}) },
|
||||
sessionTasks: { ...(source.sessionTasks ?? {}) },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { createSignal, createMemo, batch } from "solid-js"
|
||||
import { resetSteps } from "./solo-store"
|
||||
import type { Instance, LogEntry } from "../types/instance"
|
||||
import type { LspStatus, Permission } from "@opencode-ai/sdk"
|
||||
import { sdkManager } from "../lib/sdk-manager"
|
||||
@@ -34,6 +35,11 @@ const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boole
|
||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, Permission[]>>(new Map())
|
||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
||||
|
||||
function syncHasInstancesFlag() {
|
||||
const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready")
|
||||
setHasInstances(readyExists)
|
||||
}
|
||||
interface DisconnectedInstanceInfo {
|
||||
id: string
|
||||
folder: string
|
||||
@@ -68,7 +74,6 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor) {
|
||||
updateInstance(descriptor.id, mapped)
|
||||
} else {
|
||||
addInstance(mapped)
|
||||
setHasInstances(true)
|
||||
}
|
||||
|
||||
if (descriptor.status === "ready") {
|
||||
@@ -135,9 +140,6 @@ void (async function initializeWorkspaces() {
|
||||
try {
|
||||
const workspaces = await serverApi.fetchWorkspaces()
|
||||
workspaces.forEach((workspace) => upsertWorkspace(workspace))
|
||||
if (workspaces.length === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to load workspaces", error)
|
||||
}
|
||||
@@ -159,9 +161,6 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) {
|
||||
case "workspace.stopped":
|
||||
releaseInstanceResources(event.workspaceId)
|
||||
removeInstance(event.workspaceId)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
break
|
||||
case "workspace.log":
|
||||
handleWorkspaceLog(event.entry)
|
||||
@@ -249,6 +248,8 @@ function addInstance(instance: Instance) {
|
||||
})
|
||||
ensureLogContainer(instance.id)
|
||||
ensureLogStreamingState(instance.id)
|
||||
resetSteps(instance.id) // Initialize SOLO steps
|
||||
syncHasInstancesFlag()
|
||||
}
|
||||
|
||||
function updateInstance(id: string, updates: Partial<Instance>) {
|
||||
@@ -260,6 +261,7 @@ function updateInstance(id: string, updates: Partial<Instance>) {
|
||||
}
|
||||
return next
|
||||
})
|
||||
syncHasInstancesFlag()
|
||||
}
|
||||
|
||||
function removeInstance(id: string) {
|
||||
@@ -301,6 +303,7 @@ function removeInstance(id: string) {
|
||||
clearCacheForInstance(id)
|
||||
messageStoreBus.unregisterInstance(id)
|
||||
clearInstanceDraftPrompts(id)
|
||||
syncHasInstancesFlag()
|
||||
}
|
||||
|
||||
async function createInstance(folder: string, _binaryPath?: string): Promise<string> {
|
||||
@@ -328,9 +331,6 @@ async function stopInstance(id: string) {
|
||||
}
|
||||
|
||||
removeInstance(id)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
|
||||
@@ -590,9 +590,6 @@ async function acknowledgeDisconnectedInstance(): Promise<void> {
|
||||
log.error("Failed to stop disconnected instance", error)
|
||||
} finally {
|
||||
setDisconnectedInstance(null)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,8 @@ function createEmptyUsageState(): SessionUsageState {
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalReasoningTokens: 0,
|
||||
totalCacheReadTokens: 0,
|
||||
totalCacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
actualUsageTokens: 0,
|
||||
latestMessageId: undefined,
|
||||
@@ -154,6 +156,8 @@ function removeUsageEntry(state: SessionUsageState, messageId: string | undefine
|
||||
state.totalInputTokens -= existing.inputTokens
|
||||
state.totalOutputTokens -= existing.outputTokens
|
||||
state.totalReasoningTokens -= existing.reasoningTokens
|
||||
state.totalCacheReadTokens -= existing.cacheReadTokens
|
||||
state.totalCacheWriteTokens -= existing.cacheWriteTokens
|
||||
state.totalCost -= existing.cost
|
||||
delete state.entries[messageId]
|
||||
if (state.latestMessageId === messageId) {
|
||||
@@ -520,7 +524,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
draft.partIds = [...draft.partIds, partId]
|
||||
}
|
||||
const existing = draft.parts[partId]
|
||||
const nextRevision = existing ? existing.revision + 1 : cloned.version ?? 0
|
||||
const nextRevision = existing ? existing.revision + 1 : 0
|
||||
draft.parts[partId] = {
|
||||
id: partId,
|
||||
data: cloned,
|
||||
|
||||
@@ -28,6 +28,8 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void {
|
||||
let totalInputTokens = usage?.totalInputTokens ?? 0
|
||||
let totalOutputTokens = usage?.totalOutputTokens ?? 0
|
||||
let totalReasoningTokens = usage?.totalReasoningTokens ?? 0
|
||||
let totalCacheReadTokens = usage?.totalCacheReadTokens ?? 0
|
||||
let totalCacheWriteTokens = usage?.totalCacheWriteTokens ?? 0
|
||||
let totalCost = usage?.totalCost ?? 0
|
||||
let actualUsageTokens = usage?.actualUsageTokens ?? 0
|
||||
|
||||
@@ -44,6 +46,8 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void {
|
||||
totalInputTokens = previousInfo.inputTokens
|
||||
totalOutputTokens = previousInfo.outputTokens
|
||||
totalReasoningTokens = previousInfo.reasoningTokens
|
||||
totalCacheReadTokens = previousInfo.cacheReadTokens
|
||||
totalCacheWriteTokens = previousInfo.cacheWriteTokens
|
||||
totalCost = previousInfo.cost
|
||||
actualUsageTokens = previousInfo.actualUsageTokens
|
||||
}
|
||||
@@ -129,6 +133,8 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void {
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
reasoningTokens: totalReasoningTokens,
|
||||
cacheReadTokens: totalCacheReadTokens,
|
||||
cacheWriteTokens: totalCacheWriteTokens,
|
||||
actualUsageTokens,
|
||||
modelOutputLimit,
|
||||
contextAvailableTokens,
|
||||
|
||||
@@ -83,6 +83,8 @@ export interface SessionUsageState {
|
||||
totalInputTokens: number
|
||||
totalOutputTokens: number
|
||||
totalReasoningTokens: number
|
||||
totalCacheReadTokens: number
|
||||
totalCacheWriteTokens: number
|
||||
totalCost: number
|
||||
actualUsageTokens: number
|
||||
latestMessageId?: string
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import { instances } from "./instances"
|
||||
import { addTaskMessage } from "./task-actions"
|
||||
|
||||
import { addRecentModelPreference, setAgentModelPreference } from "./preferences"
|
||||
import { sessions, withSession } from "./session-state"
|
||||
@@ -59,7 +60,8 @@ async function sendMessage(
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
attachments: any[] = [],
|
||||
): Promise<void> {
|
||||
taskId?: string,
|
||||
): Promise<string> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
@@ -72,6 +74,22 @@ async function sendMessage(
|
||||
}
|
||||
|
||||
const messageId = createId("msg")
|
||||
|
||||
// If taskId is provided, associate this message with the task and set it as active
|
||||
if (taskId) {
|
||||
addTaskMessage(instanceId, sessionId, taskId, messageId)
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
session.activeTaskId = taskId
|
||||
})
|
||||
} else {
|
||||
// If no taskId, we might want to clear activeTaskId to go back to global chat
|
||||
// or keep it if we are still "in" a task view.
|
||||
// For isolation, it's better to clear it if a global message is sent.
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
session.activeTaskId = undefined
|
||||
})
|
||||
}
|
||||
|
||||
const textPartId = createId("part")
|
||||
|
||||
const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments)
|
||||
@@ -143,6 +161,8 @@ async function sendMessage(
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const createdAt = Date.now()
|
||||
|
||||
log.info("sendMessage: upserting optimistic message", { messageId, sessionId, taskId });
|
||||
|
||||
store.upsertMessage({
|
||||
id: messageId,
|
||||
sessionId,
|
||||
@@ -177,22 +197,43 @@ async function sendMessage(
|
||||
requestBody,
|
||||
})
|
||||
|
||||
// Electron diagnostic logging
|
||||
if (typeof window !== "undefined" && (window as any).electron) {
|
||||
log.info("Electron environment detected", {
|
||||
isElectron: true,
|
||||
userAgent: navigator.userAgent,
|
||||
origin: window.location.origin
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
log.info("session.promptAsync", { instanceId, sessionId, requestBody })
|
||||
log.info("session.promptAsync starting", { instanceId, sessionId })
|
||||
const response = await instance.client.session.promptAsync({
|
||||
path: { id: sessionId },
|
||||
body: requestBody,
|
||||
})
|
||||
|
||||
log.info("sendMessage response", response)
|
||||
|
||||
if (response.error) {
|
||||
log.error("sendMessage server error", response.error)
|
||||
throw new Error(JSON.stringify(response.error) || "Failed to send message")
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to send prompt", error)
|
||||
log.info("session.promptAsync success", { instanceId, sessionId, response })
|
||||
return messageId
|
||||
} catch (error: any) {
|
||||
log.error("Failed to send prompt", {
|
||||
instanceId,
|
||||
sessionId,
|
||||
error: error?.message || error,
|
||||
stack: error?.stack,
|
||||
requestBody
|
||||
})
|
||||
|
||||
// Update message status to error in store
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
store.upsertMessage({
|
||||
id: messageId,
|
||||
sessionId,
|
||||
status: "error",
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
throw error
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,10 +404,65 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
|
||||
})
|
||||
}
|
||||
|
||||
async function revertSession(instanceId: string, sessionId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
try {
|
||||
await instance.client.session.revert({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
} catch (error) {
|
||||
log.error("Failed to revert session", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function forkSession(instanceId: string, sessionId: string): Promise<string> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await instance.client.session.fork({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(JSON.stringify(response.error) || "Failed to fork session")
|
||||
}
|
||||
|
||||
const newSessionId = response.data?.id
|
||||
if (!newSessionId) {
|
||||
throw new Error("No session ID returned from fork operation")
|
||||
}
|
||||
|
||||
return newSessionId
|
||||
} catch (error) {
|
||||
log.error("Failed to fork session", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
abortSession,
|
||||
executeCustomCommand,
|
||||
forkSession,
|
||||
renameSession,
|
||||
revertSession,
|
||||
runShellCommand,
|
||||
sendMessage,
|
||||
updateSessionAgent,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
setLoading,
|
||||
cleanupBlankSessions,
|
||||
} from "./session-state"
|
||||
import { getInstanceConfig, ensureInstanceConfigLoaded } from "./instance-config"
|
||||
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
|
||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
@@ -79,6 +80,11 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
|
||||
const existingSessions = sessions().get(instanceId)
|
||||
|
||||
// Load session tasks from storage
|
||||
await ensureInstanceConfigLoaded(instanceId)
|
||||
const instanceData = getInstanceConfig(instanceId)
|
||||
const sessionTasks = instanceData.sessionTasks || {}
|
||||
|
||||
for (const apiSession of response.data) {
|
||||
const existingSession = existingSessions?.get(apiSession.id)
|
||||
|
||||
@@ -101,6 +107,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
diff: apiSession.revert.diff,
|
||||
}
|
||||
: undefined,
|
||||
tasks: sessionTasks[apiSession.id] || [],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -226,6 +233,8 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
actualUsageTokens: 0,
|
||||
modelOutputLimit: initialOutputLimit,
|
||||
contextAvailableTokens: initialContextAvailable,
|
||||
@@ -284,7 +293,7 @@ async function forkSession(
|
||||
id: info.id,
|
||||
instanceId,
|
||||
title: info.title || "Forked Session",
|
||||
parentId: info.parentID || null,
|
||||
parentId: info.parentID || sourceSessionId, // Fallback to source session to ensure parent-child relationship
|
||||
agent: info.agent || "",
|
||||
model: {
|
||||
providerId: info.model?.providerID || "",
|
||||
@@ -329,6 +338,8 @@ async function forkSession(
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
actualUsageTokens: 0,
|
||||
modelOutputLimit: forkOutputLimit,
|
||||
contextAvailableTokens: forkContextAvailable,
|
||||
|
||||
@@ -17,11 +17,14 @@ import type { MessageStatus } from "./message-v2/types"
|
||||
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||
import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances"
|
||||
import { instances, addPermissionToQueue, removePermissionFromQueue, sendPermissionResponse } from "./instances"
|
||||
import { getSoloState, incrementStep, popFromTaskQueue, setActiveTaskId } from "./solo-store"
|
||||
import { sendMessage } from "./session-actions"
|
||||
import { showAlertDialog } from "./alerts"
|
||||
import { sessions, setSessions, withSession } from "./session-state"
|
||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { addTaskMessage, replaceTaskMessageId } from "./task-actions"
|
||||
|
||||
const log = getLogger("sse")
|
||||
import { loadMessages } from "./session-api"
|
||||
@@ -77,20 +80,20 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
if (event.type === "message.part.updated") {
|
||||
const rawPart = event.properties?.part
|
||||
if (!rawPart) return
|
||||
|
||||
|
||||
const part = normalizeMessagePart(rawPart)
|
||||
const messageInfo = (event as any)?.properties?.message as MessageInfo | undefined
|
||||
|
||||
|
||||
const fallbackSessionId = typeof messageInfo?.sessionID === "string" ? messageInfo.sessionID : undefined
|
||||
const fallbackMessageId = typeof messageInfo?.id === "string" ? messageInfo.id : undefined
|
||||
|
||||
|
||||
const sessionId = typeof part.sessionID === "string" ? part.sessionID : fallbackSessionId
|
||||
const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId
|
||||
if (!sessionId || !messageId) return
|
||||
|
||||
|
||||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
// Note: session may be null for newly forked sessions where SSE event arrives before session is registered
|
||||
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const role: MessageRole = resolveMessageRole(messageInfo)
|
||||
const createdAt = typeof messageInfo?.time?.created === "number" ? messageInfo.time.created : Date.now()
|
||||
@@ -101,6 +104,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
const pendingId = findPendingMessageId(store, sessionId, role)
|
||||
if (pendingId && pendingId !== messageId) {
|
||||
replaceMessageIdV2(instanceId, pendingId, messageId)
|
||||
replaceTaskMessageId(instanceId, sessionId, pendingId, messageId)
|
||||
record = store.getMessage(messageId)
|
||||
}
|
||||
}
|
||||
@@ -115,12 +119,37 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
updatedAt: createdAt,
|
||||
isEphemeral: true,
|
||||
})
|
||||
|
||||
// Try to associate message with task
|
||||
if (session?.activeTaskId) {
|
||||
addTaskMessage(instanceId, sessionId, session.activeTaskId, messageId)
|
||||
} else if (session?.parentId) {
|
||||
// This is a task session. Find the parent and update the task.
|
||||
const parentSession = instanceSessions.get(session.parentId)
|
||||
if (parentSession?.tasks) {
|
||||
const task = parentSession.tasks.find((t) => t.taskSessionId === sessionId)
|
||||
if (task) {
|
||||
addTaskMessage(instanceId, session.parentId, task.id, messageId)
|
||||
}
|
||||
}
|
||||
} else if (!session) {
|
||||
// Session not found yet - search all sessions for a task with this sessionId
|
||||
for (const [, candidateSession] of instanceSessions) {
|
||||
if (candidateSession.tasks) {
|
||||
const task = candidateSession.tasks.find((t) => t.taskSessionId === sessionId)
|
||||
if (task) {
|
||||
addTaskMessage(instanceId, candidateSession.id, task.id, messageId)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messageInfo) {
|
||||
upsertMessageInfoV2(instanceId, messageInfo, { status: "streaming" })
|
||||
}
|
||||
|
||||
|
||||
applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId })
|
||||
|
||||
|
||||
@@ -134,18 +163,30 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
if (!sessionId || !messageId) return
|
||||
|
||||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) return
|
||||
// Note: session may be null for newly forked sessions where SSE event arrives before session is registered
|
||||
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const role: MessageRole = info.role === "user" ? "user" : "assistant"
|
||||
const hasError = Boolean((info as any).error)
|
||||
const status: MessageStatus = hasError ? "error" : "complete"
|
||||
|
||||
// Auto-correction logic for SOLO
|
||||
const solo = getSoloState(instanceId)
|
||||
if (hasError && solo.isAutonomous && solo.currentStep < solo.maxSteps) {
|
||||
log.info(`[SOLO] Error detected in autonomous mode, prompting for fix: ${messageId}`)
|
||||
const errorMessage = (info as any).error?.message || "Unknown error"
|
||||
incrementStep(instanceId)
|
||||
sendMessage(instanceId, sessionId, `The previous step failed with error: ${errorMessage}. Please analyze the error and try a different approach.`, [], solo.activeTaskId || undefined).catch((err) => {
|
||||
log.error("[SOLO] Failed to send error correction message", err)
|
||||
})
|
||||
}
|
||||
|
||||
let record = store.getMessage(messageId)
|
||||
if (!record) {
|
||||
const pendingId = findPendingMessageId(store, sessionId, role)
|
||||
if (pendingId && pendingId !== messageId) {
|
||||
replaceMessageIdV2(instanceId, pendingId, messageId)
|
||||
replaceTaskMessageId(instanceId, sessionId, pendingId, messageId)
|
||||
record = store.getMessage(messageId)
|
||||
}
|
||||
}
|
||||
@@ -161,6 +202,31 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
createdAt,
|
||||
updatedAt: completedAt ?? createdAt,
|
||||
})
|
||||
|
||||
// Try to associate message with task
|
||||
if (session?.activeTaskId) {
|
||||
addTaskMessage(instanceId, sessionId, session.activeTaskId, messageId)
|
||||
} else if (session?.parentId) {
|
||||
// This is a task session. Find the parent and update the task.
|
||||
const parentSession = instanceSessions.get(session.parentId)
|
||||
if (parentSession?.tasks) {
|
||||
const task = parentSession.tasks.find((t) => t.taskSessionId === sessionId)
|
||||
if (task) {
|
||||
addTaskMessage(instanceId, session.parentId, task.id, messageId)
|
||||
}
|
||||
}
|
||||
} else if (!session) {
|
||||
// Session not found yet - search all sessions for a task with this sessionId
|
||||
for (const [, candidateSession] of instanceSessions) {
|
||||
if (candidateSession.tasks) {
|
||||
const task = candidateSession.tasks.find((t) => t.taskSessionId === sessionId)
|
||||
if (task) {
|
||||
addTaskMessage(instanceId, candidateSession.id, task.id, messageId)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true })
|
||||
@@ -198,9 +264,9 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
||||
time: info.time
|
||||
? { ...info.time }
|
||||
: {
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
},
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
},
|
||||
} as any
|
||||
|
||||
setSessions((prev) => {
|
||||
@@ -228,11 +294,11 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
||||
time: mergedTime,
|
||||
revert: info.revert
|
||||
? {
|
||||
messageID: info.revert.messageID,
|
||||
partID: info.revert.partID,
|
||||
snapshot: info.revert.snapshot,
|
||||
diff: info.revert.diff,
|
||||
}
|
||||
messageID: info.revert.messageID,
|
||||
partID: info.revert.partID,
|
||||
snapshot: info.revert.snapshot,
|
||||
diff: info.revert.diff,
|
||||
}
|
||||
: existingSession.revert,
|
||||
}
|
||||
|
||||
@@ -247,11 +313,50 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionIdle(_instanceId: string, event: EventSessionIdle): void {
|
||||
function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
|
||||
const sessionId = event.properties?.sessionID
|
||||
if (!sessionId) return
|
||||
|
||||
log.info(`[SSE] Session idle: ${sessionId}`)
|
||||
|
||||
// Autonomous continuation logic for SOLO
|
||||
const solo = getSoloState(instanceId)
|
||||
if (solo.isAutonomous && solo.currentStep < solo.maxSteps) {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
// If there's an active task, we might want to prompt the agent to continue or check progress
|
||||
if (solo.activeTaskId) {
|
||||
log.info(`[SOLO] Session idle in autonomous mode, prompting continuation for task: ${solo.activeTaskId}`)
|
||||
incrementStep(instanceId)
|
||||
sendMessage(instanceId, sessionId, "Continue", [], solo.activeTaskId).catch((err) => {
|
||||
log.error("[SOLO] Failed to send continuation message", err)
|
||||
})
|
||||
} else {
|
||||
// Check if there's another task in the queue
|
||||
const nextTaskId = popFromTaskQueue(instanceId)
|
||||
if (nextTaskId) {
|
||||
log.info(`[SOLO] Session idle, starting next task from queue: ${nextTaskId}`)
|
||||
|
||||
// Find the task title to provide context
|
||||
let taskTitle = "Start next task"
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (session?.tasks) {
|
||||
const task = session.tasks.find(t => t.id === nextTaskId)
|
||||
if (task) {
|
||||
taskTitle = `Please start working on the task: "${task.title}". Provide a plan and begin execution.`
|
||||
}
|
||||
}
|
||||
|
||||
setActiveTaskId(instanceId, nextTaskId)
|
||||
sendMessage(instanceId, sessionId, taskTitle, [], nextTaskId).catch((err) => {
|
||||
log.error("[SOLO] Failed to start next task", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void {
|
||||
@@ -284,7 +389,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
|
||||
})
|
||||
}
|
||||
|
||||
function handleSessionError(_instanceId: string, event: EventSessionError): void {
|
||||
function handleSessionError(instanceId: string, event: EventSessionError): void {
|
||||
const error = event.properties?.error
|
||||
log.error(`[SSE] Session error:`, error)
|
||||
|
||||
@@ -298,10 +403,21 @@ function handleSessionError(_instanceId: string, event: EventSessionError): void
|
||||
}
|
||||
}
|
||||
|
||||
showAlertDialog(`Error: ${message}`, {
|
||||
title: "Session error",
|
||||
variant: "error",
|
||||
})
|
||||
// Autonomous error recovery for SOLO
|
||||
const solo = getSoloState(instanceId)
|
||||
const sessionId = (event.properties as any)?.sessionID
|
||||
if (solo.isAutonomous && sessionId && solo.currentStep < solo.maxSteps) {
|
||||
log.info(`[SOLO] Session error in autonomous mode, prompting fix: ${message}`)
|
||||
incrementStep(instanceId)
|
||||
sendMessage(instanceId, sessionId, `I encountered an error: "${message}". Please analyze the cause and provide a fix.`, [], solo.activeTaskId || undefined).catch((err) => {
|
||||
log.error("[SOLO] Failed to send error recovery message", err)
|
||||
})
|
||||
} else {
|
||||
showAlertDialog(`Error: ${message}`, {
|
||||
title: "Session error",
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {
|
||||
@@ -344,6 +460,18 @@ function handlePermissionUpdated(instanceId: string, event: EventPermissionUpdat
|
||||
log.info(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
|
||||
addPermissionToQueue(instanceId, permission)
|
||||
upsertPermissionV2(instanceId, permission)
|
||||
|
||||
// Auto-approval logic for SOLO autonomous agent
|
||||
const solo = getSoloState(instanceId)
|
||||
if (solo.isAutonomous && solo.autoApproval) {
|
||||
log.info(`[SOLO] Auto-approving permission: ${permission.id}`)
|
||||
const sessionId = permission.sessionID
|
||||
if (sessionId) {
|
||||
sendPermissionResponse(instanceId, sessionId, permission.id, "always").catch((err) => {
|
||||
log.error(`[SOLO] Failed to auto-approve permission ${permission.id}`, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { messageStoreBus } from "./message-v2/bus"
|
||||
import { instances } from "./instances"
|
||||
import { showConfirmDialog } from "./alerts"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { updateInstanceConfig } from "./instance-config"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -17,6 +18,8 @@ export interface SessionInfo {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheWriteTokens: number
|
||||
actualUsageTokens: number
|
||||
modelOutputLimit: number
|
||||
contextAvailableTokens: number | null
|
||||
@@ -150,6 +153,29 @@ function withSession(instanceId: string, sessionId: string, updater: (session: S
|
||||
next.set(instanceId, newInstanceSessions)
|
||||
return next
|
||||
})
|
||||
|
||||
// Persist session tasks to storage
|
||||
persistSessionTasks(instanceId)
|
||||
}
|
||||
|
||||
async function persistSessionTasks(instanceId: string) {
|
||||
try {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
|
||||
const sessionTasks: Record<string, any[]> = {}
|
||||
for (const [sessionId, session] of instanceSessions) {
|
||||
if (session.tasks && session.tasks.length > 0) {
|
||||
sessionTasks[sessionId] = session.tasks
|
||||
}
|
||||
}
|
||||
|
||||
await updateInstanceConfig(instanceId, (draft) => {
|
||||
draft.sessionTasks = sessionTasks
|
||||
})
|
||||
} catch (error) {
|
||||
log.error("Failed to persist session tasks", error)
|
||||
}
|
||||
}
|
||||
|
||||
function setSessionCompactionState(instanceId: string, sessionId: string, isCompacting: boolean): void {
|
||||
@@ -378,6 +404,7 @@ export {
|
||||
clearInstanceDraftPrompts,
|
||||
pruneDraftPrompts,
|
||||
withSession,
|
||||
persistSessionTasks,
|
||||
setSessionCompactionState,
|
||||
setSessionPendingPermission,
|
||||
setActiveSession,
|
||||
|
||||
77
packages/ui/src/stores/solo-store.ts
Normal file
77
packages/ui/src/stores/solo-store.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("solo")
|
||||
|
||||
export interface SoloState {
|
||||
isAutonomous: boolean
|
||||
autoApproval: boolean
|
||||
maxSteps: number
|
||||
currentStep: number
|
||||
activeTaskId: string | null
|
||||
taskQueue: string[]
|
||||
}
|
||||
|
||||
const [soloStates, setSoloStates] = createSignal<Map<string, SoloState>>(new Map())
|
||||
|
||||
export function getSoloState(instanceId: string): SoloState {
|
||||
const state = soloStates().get(instanceId)
|
||||
if (!state) {
|
||||
return {
|
||||
isAutonomous: false,
|
||||
autoApproval: false,
|
||||
maxSteps: 50,
|
||||
currentStep: 0,
|
||||
activeTaskId: null,
|
||||
taskQueue: [],
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
export function setSoloState(instanceId: string, partial: Partial<SoloState>) {
|
||||
setSoloStates((prev) => {
|
||||
const next = new Map(prev)
|
||||
const current = getSoloState(instanceId)
|
||||
next.set(instanceId, { ...current, ...partial })
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleAutonomous(instanceId: string) {
|
||||
const current = getSoloState(instanceId)
|
||||
setSoloState(instanceId, { isAutonomous: !current.isAutonomous })
|
||||
log.info(`Autonomous mode ${!current.isAutonomous ? "enabled" : "disabled"} for ${instanceId}`)
|
||||
}
|
||||
|
||||
export function toggleAutoApproval(instanceId: string) {
|
||||
const current = getSoloState(instanceId)
|
||||
setSoloState(instanceId, { autoApproval: !current.autoApproval })
|
||||
log.info(`Auto-approval ${!current.autoApproval ? "enabled" : "disabled"} for ${instanceId}`)
|
||||
}
|
||||
|
||||
export function incrementStep(instanceId: string) {
|
||||
const state = getSoloState(instanceId)
|
||||
setSoloState(instanceId, { currentStep: state.currentStep + 1 })
|
||||
}
|
||||
|
||||
export function resetSteps(instanceId: string) {
|
||||
setSoloState(instanceId, { currentStep: 0 })
|
||||
}
|
||||
|
||||
export function setActiveTaskId(instanceId: string, taskId: string | null) {
|
||||
setSoloState(instanceId, { activeTaskId: taskId })
|
||||
}
|
||||
|
||||
export function addToTaskQueue(instanceId: string, taskId: string) {
|
||||
const current = getSoloState(instanceId)
|
||||
setSoloState(instanceId, { taskQueue: [...current.taskQueue, taskId] })
|
||||
}
|
||||
|
||||
export function popFromTaskQueue(instanceId: string): string | null {
|
||||
const current = getSoloState(instanceId)
|
||||
if (current.taskQueue.length === 0) return null
|
||||
const [next, ...rest] = current.taskQueue
|
||||
setSoloState(instanceId, { taskQueue: rest })
|
||||
return next
|
||||
}
|
||||
163
packages/ui/src/stores/task-actions.ts
Normal file
163
packages/ui/src/stores/task-actions.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { withSession } from "./session-state"
|
||||
import { Task, TaskStatus } from "../types/session"
|
||||
import { nanoid } from "nanoid"
|
||||
import { forkSession } from "./session-api"
|
||||
|
||||
export function setActiveTask(instanceId: string, sessionId: string, taskId: string | undefined): void {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
session.activeTaskId = taskId
|
||||
})
|
||||
}
|
||||
|
||||
export async function addTask(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
title: string
|
||||
): Promise<{ id: string; taskSessionId?: string }> {
|
||||
const id = nanoid()
|
||||
console.log("[task-actions] addTask started", { instanceId, sessionId, title, taskId: id });
|
||||
|
||||
let taskSessionId: string | undefined
|
||||
try {
|
||||
console.log("[task-actions] forking session...");
|
||||
const forked = await forkSession(instanceId, sessionId)
|
||||
taskSessionId = forked.id
|
||||
console.log("[task-actions] fork successful", { taskSessionId });
|
||||
} catch (error) {
|
||||
console.error("[task-actions] Failed to fork session for task", error)
|
||||
}
|
||||
|
||||
const newTask: Task = {
|
||||
id,
|
||||
title,
|
||||
status: "pending",
|
||||
timestamp: Date.now(),
|
||||
messageIds: [],
|
||||
taskSessionId,
|
||||
}
|
||||
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
if (!session.tasks) {
|
||||
session.tasks = []
|
||||
}
|
||||
session.tasks = [newTask, ...session.tasks]
|
||||
console.log("[task-actions] task added to session", { taskCount: session.tasks.length });
|
||||
})
|
||||
|
||||
return { id, taskSessionId }
|
||||
}
|
||||
|
||||
export function addTaskMessage(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
taskId: string,
|
||||
messageId: string,
|
||||
): void {
|
||||
console.log("[task-actions] addTaskMessage called", { instanceId, sessionId, taskId, messageId });
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
let targetSessionId = sessionId
|
||||
let targetTaskId = taskId
|
||||
|
||||
// If this is a child session, the tasks are on the parent
|
||||
if (session.parentId && !session.tasks) {
|
||||
targetSessionId = session.parentId
|
||||
console.log("[task-actions] task session detected, targeting parent", { parentId: session.parentId });
|
||||
}
|
||||
|
||||
withSession(instanceId, targetSessionId, (targetSession) => {
|
||||
if (!targetSession.tasks) {
|
||||
console.warn("[task-actions] target session has no tasks array", { targetSessionId });
|
||||
return
|
||||
}
|
||||
|
||||
const taskIndex = targetSession.tasks.findIndex((t) => t.id === targetTaskId || t.taskSessionId === sessionId)
|
||||
if (taskIndex !== -1) {
|
||||
const task = targetSession.tasks[taskIndex]
|
||||
const messageIds = [...(task.messageIds || [])]
|
||||
|
||||
if (!messageIds.includes(messageId)) {
|
||||
messageIds.push(messageId)
|
||||
|
||||
// Replace the task object and the tasks array to trigger reactivity
|
||||
const updatedTask = { ...task, messageIds }
|
||||
const updatedTasks = [...targetSession.tasks]
|
||||
updatedTasks[taskIndex] = updatedTask
|
||||
targetSession.tasks = updatedTasks
|
||||
|
||||
console.log("[task-actions] message ID added to task with reactivity", { taskId: task.id, messageCount: messageIds.length });
|
||||
} else {
|
||||
console.log("[task-actions] message ID already in task", { taskId: task.id });
|
||||
}
|
||||
} else {
|
||||
console.warn("[task-actions] task not found in session", { targetTaskId, sessionId, availableTaskCount: targetSession.tasks.length });
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function replaceTaskMessageId(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
oldMessageId: string,
|
||||
newMessageId: string,
|
||||
): void {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
let targetSessionId = sessionId
|
||||
|
||||
if (session.parentId && !session.tasks) {
|
||||
targetSessionId = session.parentId
|
||||
}
|
||||
|
||||
withSession(instanceId, targetSessionId, (targetSession) => {
|
||||
if (!targetSession.tasks) return
|
||||
|
||||
const taskIndex = targetSession.tasks.findIndex((t) =>
|
||||
t.messageIds?.includes(oldMessageId) || t.taskSessionId === sessionId
|
||||
)
|
||||
|
||||
if (taskIndex !== -1) {
|
||||
const task = targetSession.tasks[taskIndex]
|
||||
const messageIds = [...(task.messageIds || [])]
|
||||
const index = messageIds.indexOf(oldMessageId)
|
||||
|
||||
let changed = false
|
||||
if (index !== -1) {
|
||||
messageIds[index] = newMessageId
|
||||
changed = true
|
||||
} else if (task.taskSessionId === sessionId && !messageIds.includes(newMessageId)) {
|
||||
messageIds.push(newMessageId)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
const updatedTask = { ...task, messageIds }
|
||||
const updatedTasks = [...targetSession.tasks]
|
||||
updatedTasks[taskIndex] = updatedTask
|
||||
targetSession.tasks = updatedTasks
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTaskStatus(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
taskId: string,
|
||||
status: TaskStatus,
|
||||
): void {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
if (!session.tasks) return
|
||||
session.tasks = session.tasks.map((t) => (t.id === taskId ? { ...t, status } : t))
|
||||
})
|
||||
}
|
||||
|
||||
export function removeTask(instanceId: string, sessionId: string, taskId: string): void {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
if (!session.tasks) return
|
||||
session.tasks = session.tasks.filter((t) => t.id !== taskId)
|
||||
if (session.activeTaskId === taskId) {
|
||||
session.activeTaskId = undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user