Compare commits

..

7 Commits

14 changed files with 299 additions and 45 deletions

View File

@@ -16,6 +16,8 @@ export interface Task {
status: TaskStatus status: TaskStatus
timestamp: number timestamp: number
messageIds?: string[] // IDs of messages associated with this task messageIds?: string[] // IDs of messages associated with this task
taskSessionId?: string
archived?: boolean
} }
export interface SessionTasks { export interface SessionTasks {

View File

@@ -209,6 +209,51 @@ export function registerNativeSessionsRoutes(app: FastifyInstance, deps: NativeS
} }
}) })
// Append messages to a session (client-side persistence)
app.post<{
Params: { workspaceId: string; sessionId: string }
Body: {
messages: Array<{
id?: string
role: "user" | "assistant" | "system" | "tool"
content?: string
createdAt?: number
updatedAt?: number
status?: "pending" | "streaming" | "completed" | "error"
}>
}
}>("/api/native/workspaces/:workspaceId/sessions/:sessionId/messages", async (request, reply) => {
const { workspaceId, sessionId } = request.params
const payload = request.body?.messages
if (!Array.isArray(payload)) {
reply.code(400)
return { error: "messages array is required" }
}
try {
const results: SessionMessage[] = []
for (const entry of payload) {
if (!entry || typeof entry.role !== "string") {
continue
}
const saved = await sessionManager.addMessage(workspaceId, sessionId, {
id: entry.id,
role: entry.role,
content: entry.content,
createdAt: entry.createdAt,
updatedAt: entry.updatedAt,
status: entry.status,
})
results.push(saved)
}
return { messages: results }
} catch (error) {
logger.error({ error }, "Failed to append messages")
reply.code(500)
return { error: "Failed to append messages" }
}
})
// Add a message (user prompt) and get streaming response // Add a message (user prompt) and get streaming response
app.post<{ app.post<{
Params: { workspaceId: string; sessionId: string } Params: { workspaceId: string; sessionId: string }

View File

@@ -3,7 +3,7 @@ import { z } from "zod"
import { InstanceStore } from "../../storage/instance-store" import { InstanceStore } from "../../storage/instance-store"
import { EventBus } from "../../events/bus" import { EventBus } from "../../events/bus"
import { ModelPreferenceSchema } from "../../config/schema" import { ModelPreferenceSchema } from "../../config/schema"
import type { InstanceData, Task, SessionTasks } from "../../api-types" import type { InstanceData } from "../../api-types"
import { WorkspaceManager } from "../../workspaces/manager" import { WorkspaceManager } from "../../workspaces/manager"
interface RouteDeps { interface RouteDeps {
@@ -18,6 +18,8 @@ const TaskSchema = z.object({
status: z.enum(["completed", "interrupted", "in-progress", "pending"]), status: z.enum(["completed", "interrupted", "in-progress", "pending"]),
timestamp: z.number(), timestamp: z.number(),
messageIds: z.array(z.string()).optional(), messageIds: z.array(z.string()).optional(),
taskSessionId: z.string().optional(),
archived: z.boolean().optional(),
}) })
const InstanceDataSchema = z.object({ const InstanceDataSchema = z.object({

View File

@@ -27,6 +27,12 @@ export interface SessionMessage {
status?: "pending" | "streaming" | "completed" | "error" status?: "pending" | "streaming" | "completed" | "error"
} }
type IncomingSessionMessage = Omit<SessionMessage, "id" | "sessionId" | "createdAt" | "updatedAt"> & {
id?: string
createdAt?: number
updatedAt?: number
}
export interface MessagePart { export interface MessagePart {
type: "text" | "tool_call" | "tool_result" | "thinking" | "code" type: "text" | "tool_call" | "tool_result" | "thinking" | "code"
content?: string content?: string
@@ -260,23 +266,29 @@ export class NativeSessionManager {
.filter((msg): msg is SessionMessage => msg !== undefined) .filter((msg): msg is SessionMessage => msg !== undefined)
} }
async addMessage(workspaceId: string, sessionId: string, message: Omit<SessionMessage, "id" | "sessionId" | "createdAt" | "updatedAt">): Promise<SessionMessage> { async addMessage(workspaceId: string, sessionId: string, message: IncomingSessionMessage): Promise<SessionMessage> {
const store = await this.loadStore(workspaceId) const store = await this.loadStore(workspaceId)
const session = store.sessions[sessionId] const session = store.sessions[sessionId]
if (!session) throw new Error(`Session not found: ${sessionId}`) if (!session) throw new Error(`Session not found: ${sessionId}`)
const now = Date.now() const now = Date.now()
const messageId = message.id ?? ulid()
const createdAt = typeof message.createdAt === "number" ? message.createdAt : now
const updatedAt = typeof message.updatedAt === "number" ? message.updatedAt : createdAt
const newMessage: SessionMessage = { const newMessage: SessionMessage = {
...message, ...message,
id: ulid(), id: messageId,
sessionId, sessionId,
createdAt: now, createdAt,
updatedAt: now, updatedAt,
} }
store.messages[newMessage.id] = newMessage store.messages[newMessage.id] = newMessage
session.messageIds.push(newMessage.id) if (!session.messageIds.includes(newMessage.id)) {
session.updatedAt = now session.messageIds.push(newMessage.id)
}
session.updatedAt = updatedAt
await this.saveStore(workspaceId) await this.saveStore(workspaceId)
return newMessage return newMessage

View File

@@ -45,6 +45,7 @@ import {
clearActiveParentSession, clearActiveParentSession,
createSession, createSession,
fetchSessions, fetchSessions,
flushSessionPersistence,
updateSessionAgent, updateSessionAgent,
updateSessionModel, updateSessionModel,
} from "./stores/sessions" } from "./stores/sessions"
@@ -217,6 +218,7 @@ const App: Component = () => {
if (!confirmed) return if (!confirmed) return
clearActiveParentSession(instanceId)
await stopInstance(instanceId) await stopInstance(instanceId)
} }
@@ -244,6 +246,12 @@ const App: Component = () => {
return return
} }
try {
await flushSessionPersistence(instanceId)
} catch (error) {
log.error("Failed to flush session persistence before closing", error)
}
clearActiveParentSession(instanceId) clearActiveParentSession(instanceId)
try { try {
@@ -303,7 +311,7 @@ const App: Component = () => {
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__ const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
if (tauriBridge?.event) { if (tauriBridge?.event) {
let unlistenMenu: (() => void) | null = null let unlistenMenu: (() => void) | null = null
tauriBridge.event.listen("menu:newInstance", () => { tauriBridge.event.listen("menu:newInstance", () => {
handleNewInstanceRequest() handleNewInstanceRequest()
}).then((unlisten) => { }).then((unlisten) => {
@@ -321,7 +329,7 @@ const App: Component = () => {
// Check if this is OAuth callback // Check if this is OAuth callback
const isOAuthCallback = window.location.pathname === '/auth/qwen/callback' const isOAuthCallback = window.location.pathname === '/auth/qwen/callback'
if (isOAuthCallback) { if (isOAuthCallback) {
return <QwenOAuthCallback /> return <QwenOAuthCallback />
} }
@@ -391,29 +399,29 @@ const App: Component = () => {
onNew={handleNewInstanceRequest} onNew={handleNewInstanceRequest}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)} onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/> />
<For each={Array.from(instances().values())}> <For each={Array.from(instances().values())}>
{(instance) => { {(instance) => {
const isActiveInstance = () => activeInstanceId() === instance.id const isActiveInstance = () => activeInstanceId() === instance.id
const isVisible = () => isActiveInstance() && !showFolderSelection() const isVisible = () => isActiveInstance() && !showFolderSelection()
return ( return (
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}> <div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
<InstanceMetadataProvider instance={instance}> <InstanceMetadataProvider instance={instance}>
<InstanceShell <InstanceShell
instance={instance} instance={instance}
escapeInDebounce={escapeInDebounce()} escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands} paletteCommands={paletteCommands}
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)} onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
onNewSession={() => handleNewSession(instance.id)} onNewSession={() => handleNewSession(instance.id)}
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)} handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)} handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
onExecuteCommand={executeCommand} onExecuteCommand={executeCommand}
tabBarOffset={instanceTabBarHeight()} tabBarOffset={instanceTabBarHeight()}
/> />
</InstanceMetadataProvider> </InstanceMetadataProvider>
</div> </div>
) )
}} }}
</For> </For>
@@ -458,9 +466,9 @@ const App: Component = () => {
</div> </div>
</div> </div>
</Show> </Show>
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} /> <RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
<AlertDialog /> <AlertDialog />
<Toaster <Toaster

View File

@@ -10,6 +10,7 @@
import { createSignal, Show, onMount, For, onCleanup, batch } from "solid-js"; import { createSignal, Show, onMount, For, onCleanup, batch } from "solid-js";
import toast from "solid-toast"; import toast from "solid-toast";
import { sessions, activeSessionId, setActiveSession } from "@/stores/session-state"; import { sessions, activeSessionId, setActiveSession } from "@/stores/session-state";
import { loadMessages, fetchSessions, flushSessionPersistence } from "@/stores/sessions";
import { sendMessage, compactSession, updateSessionAgent, updateSessionModelForSession, forceReset, abortSession } from "@/stores/session-actions"; import { sendMessage, compactSession, updateSessionAgent, updateSessionModelForSession, forceReset, abortSession } from "@/stores/session-actions";
import { addTask, setActiveTask, archiveTask } from "@/stores/task-actions"; import { addTask, setActiveTask, archiveTask } from "@/stores/task-actions";
import { messageStoreBus } from "@/stores/message-v2/bus"; import { messageStoreBus } from "@/stores/message-v2/bus";
@@ -76,6 +77,8 @@ export default function MultiXV2(props: MultiXV2Props) {
const [soloState, setSoloState] = createSignal({ isApex: false, isAutonomous: false, autoApproval: false, activeTaskId: null as string | null }); const [soloState, setSoloState] = createSignal({ isApex: false, isAutonomous: false, autoApproval: false, activeTaskId: null as string | null });
const [lastAssistantIndex, setLastAssistantIndex] = createSignal(-1); const [lastAssistantIndex, setLastAssistantIndex] = createSignal(-1);
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null); const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null);
const [hasUserSelection, setHasUserSelection] = createSignal(false);
const forcedLoadTimestamps = new Map<string, number>();
// Helper to check if CURRENT task is sending // Helper to check if CURRENT task is sending
const isSending = () => { const isSending = () => {
@@ -139,6 +142,10 @@ export default function MultiXV2(props: MultiXV2Props) {
setVisibleTasks(allTasks.filter(t => !t.archived)); setVisibleTasks(allTasks.filter(t => !t.archived));
// NOTE: Don't overwrite selectedTaskId from store - local state is authoritative // NOTE: Don't overwrite selectedTaskId from store - local state is authoritative
// This prevents the reactive cascade when the store updates // This prevents the reactive cascade when the store updates
if (!selectedTaskId() && !hasUserSelection() && allTasks.length > 0) {
const preferredId = session.activeTaskId || allTasks[0].id;
setSelectedTaskIdLocal(preferredId);
}
} }
// Get message IDs for currently selected task // Get message IDs for currently selected task
@@ -148,6 +155,20 @@ export default function MultiXV2(props: MultiXV2Props) {
if (task) { if (task) {
const store = getMessageStore(); const store = getMessageStore();
if (task.taskSessionId) { if (task.taskSessionId) {
const cachedIds = store.getSessionMessageIds(task.taskSessionId);
if (cachedIds.length === 0) {
const lastForced = forcedLoadTimestamps.get(task.taskSessionId) ?? 0;
if (Date.now() - lastForced > 1000) {
forcedLoadTimestamps.set(task.taskSessionId, Date.now());
loadMessages(props.instanceId, task.taskSessionId, true).catch((error) =>
log.error("Failed to load task session messages", error)
);
}
} else {
loadMessages(props.instanceId, task.taskSessionId).catch((error) =>
log.error("Failed to load task session messages", error)
);
}
setMessageIds(store.getSessionMessageIds(task.taskSessionId)); setMessageIds(store.getSessionMessageIds(task.taskSessionId));
} else { } else {
setMessageIds(task.messageIds || []); setMessageIds(task.messageIds || []);
@@ -216,6 +237,9 @@ export default function MultiXV2(props: MultiXV2Props) {
setSendingTasks(new Set<string>()); setSendingTasks(new Set<string>());
}); });
// Initialize
loadMessages(props.instanceId, props.sessionId);
fetchSessions(props.instanceId);
syncFromStore(); syncFromStore();
const interval = setInterval(syncFromStore, 150); const interval = setInterval(syncFromStore, 150);
@@ -230,6 +254,8 @@ export default function MultiXV2(props: MultiXV2Props) {
onCleanup(() => { onCleanup(() => {
clearInterval(interval); clearInterval(interval);
scrollContainer?.removeEventListener('scroll', handleScroll); scrollContainer?.removeEventListener('scroll', handleScroll);
// Ensure any pending task updates are saved immediately before we potentially reload them
flushSessionPersistence(props.instanceId);
}); });
}); });
@@ -245,6 +271,7 @@ export default function MultiXV2(props: MultiXV2Props) {
const setSelectedTaskId = (id: string | null) => { const setSelectedTaskId = (id: string | null) => {
// Update local state immediately (fast) // Update local state immediately (fast)
setSelectedTaskIdLocal(id); setSelectedTaskIdLocal(id);
setHasUserSelection(true);
// Immediately sync to load the new task's agent/model // Immediately sync to load the new task's agent/model
syncFromStore(); syncFromStore();
@@ -298,7 +325,7 @@ export default function MultiXV2(props: MultiXV2Props) {
syncFromStore(); syncFromStore();
// Set the selected task // Set the selected task
setSelectedTaskIdLocal(taskId); setSelectedTaskId(taskId);
const s = soloState(); const s = soloState();
if (s.isAutonomous) { if (s.isAutonomous) {
@@ -351,7 +378,7 @@ export default function MultiXV2(props: MultiXV2Props) {
setTimeout(async () => { setTimeout(async () => {
try { try {
const result = await addTask(props.instanceId, props.sessionId, title); const result = await addTask(props.instanceId, props.sessionId, title);
setSelectedTaskIdLocal(result.id); setSelectedTaskId(result.id);
setTimeout(() => syncFromStore(), 50); setTimeout(() => syncFromStore(), 50);
} catch (error) { } catch (error) {
log.error("handleCreateTask failed", error); log.error("handleCreateTask failed", error);

View File

@@ -66,7 +66,7 @@ import SessionView from "../session/session-view"
import { Sidebar, type FileNode } from "./sidebar" import { Sidebar, type FileNode } from "./sidebar"
import { Editor } from "./editor" import { Editor } from "./editor"
import { serverApi } from "../../lib/api-client" import { serverApi } from "../../lib/api-client"
import { Sparkles, Layout as LayoutIcon, Terminal as TerminalIcon, Search, Loader2, Zap, Shield, Settings, FileArchive } from "lucide-solid" import { Sparkles, Layout as LayoutIcon, Terminal as TerminalIcon, Search, Loader2, Zap, Shield, Settings, FileArchive, ArrowLeft } from "lucide-solid"
import { formatTokenTotal } from "../../lib/formatters" import { formatTokenTotal } from "../../lib/formatters"
import { sseManager } from "../../lib/sse-manager" import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
@@ -683,7 +683,25 @@ Now analyze the project and report your findings.`
}) })
const handleSessionSelect = (sessionId: string) => { const handleSessionSelect = (sessionId: string) => {
setActiveSession(props.instance.id, sessionId) if (sessionId === "info") {
setActiveSession(props.instance.id, sessionId)
return
}
const instanceSessions = sessions().get(props.instance.id)
const session = instanceSessions?.get(sessionId)
if (session?.parentId) {
setActiveParentSession(props.instance.id, session.parentId)
const parentSession = instanceSessions?.get(session.parentId)
const matchingTask = parentSession?.tasks?.find((task) => task.taskSessionId === sessionId)
if (matchingTask) {
setActiveTask(props.instance.id, session.parentId, matchingTask.id)
}
return
}
setActiveParentSession(props.instance.id, sessionId)
} }
@@ -731,6 +749,7 @@ Now analyze the project and report your findings.`
const sessionsMap = activeSessions() const sessionsMap = activeSessions()
const parentId = parentSessionIdForInstance() const parentId = parentSessionIdForInstance()
const activeId = activeSessionIdForInstance() const activeId = activeSessionIdForInstance()
const instanceSessions = sessions().get(props.instance.id)
setCachedSessionIds((current) => { setCachedSessionIds((current) => {
const next: string[] = [] const next: string[] = []
const append = (id: string | null) => { const append = (id: string | null) => {
@@ -743,6 +762,16 @@ Now analyze the project and report your findings.`
append(parentId) append(parentId)
append(activeId) append(activeId)
const parentSessionId = parentId || activeId
const parentSession = parentSessionId ? instanceSessions?.get(parentSessionId) : undefined
const activeTaskId = parentSession?.activeTaskId
if (activeTaskId && parentSession?.tasks?.length) {
const activeTask = parentSession.tasks.find((task) => task.id === activeTaskId)
if (activeTask?.taskSessionId) {
append(activeTask.taskSessionId)
}
}
const limit = parentId ? SESSION_CACHE_LIMIT + 1 : SESSION_CACHE_LIMIT const limit = parentId ? SESSION_CACHE_LIMIT + 1 : SESSION_CACHE_LIMIT
const trimmed = next.length > limit ? next.slice(0, limit) : next const trimmed = next.length > limit ? next.slice(0, limit) : next
const trimmedSet = new Set(trimmed) const trimmedSet = new Set(trimmed)
@@ -1342,6 +1371,14 @@ Now analyze the project and report your findings.`
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<Show when={activeSessionIdForInstance() && activeSessionIdForInstance() !== "info"}> <Show when={activeSessionIdForInstance() && activeSessionIdForInstance() !== "info"}>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<button
onClick={() => props.onCloseSession(activeSessionIdForInstance()!)}
class="flex items-center gap-1.5 px-2.5 py-1 text-[11px] font-semibold text-zinc-400 hover:text-white hover:bg-white/10 border border-transparent hover:border-white/10 transition-all rounded-full"
title="Back to Sessions"
>
<ArrowLeft size={14} strokeWidth={2} />
<span>Back</span>
</button>
{/* Compact Button */} {/* Compact Button */}
<button <button
onClick={handleCompact} onClick={handleCompact}

View File

@@ -126,7 +126,8 @@ const AntigravitySettings: Component = () => {
}) })
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to start authentication') const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || errorData.details || 'Failed to start authentication')
} }
const data = await response.json() as { const data = await response.json() as {

View File

@@ -165,6 +165,28 @@ export const nativeSessionApi = {
return data.messages return data.messages
}, },
async appendMessages(
workspaceId: string,
sessionId: string,
messages: Array<{
id?: string
role: "user" | "assistant" | "system" | "tool"
content?: string
createdAt?: number
updatedAt?: number
status?: "pending" | "streaming" | "completed" | "error"
}>
): Promise<NativeMessage[]> {
const response = await fetch(`${CODENOMAD_API_BASE}/api/native/workspaces/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages })
})
if (!response.ok) throw new Error("Failed to append messages")
const data = await response.json()
return data.messages
},
/** /**
* Import sessions from SDK mode to Native mode * Import sessions from SDK mode to Native mode
*/ */

View File

@@ -555,7 +555,7 @@ async function streamOllamaChat(
messageId: string, messageId: string,
assistantMessageId: string, assistantMessageId: string,
assistantPartId: string, assistantPartId: string,
): Promise<void> { ): Promise<string> {
const controller = new AbortController() const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS) const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
@@ -682,6 +682,8 @@ async function streamOllamaChat(
isEphemeral: false, isEphemeral: false,
}) })
}) })
return fullText
} }
async function streamQwenChat( async function streamQwenChat(
@@ -695,7 +697,7 @@ async function streamQwenChat(
messageId: string, messageId: string,
assistantMessageId: string, assistantMessageId: string,
assistantPartId: string, assistantPartId: string,
): Promise<void> { ): Promise<string> {
const controller = new AbortController() const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS) const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
@@ -831,6 +833,8 @@ async function streamQwenChat(
updatedAt: Date.now(), updatedAt: Date.now(),
isEphemeral: false, isEphemeral: false,
}) })
return fullText
} }
async function streamOpenCodeZenChat( async function streamOpenCodeZenChat(
@@ -842,7 +846,7 @@ async function streamOpenCodeZenChat(
messageId: string, messageId: string,
assistantMessageId: string, assistantMessageId: string,
assistantPartId: string, assistantPartId: string,
): Promise<void> { ): Promise<string> {
const controller = new AbortController() const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS) const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
@@ -978,6 +982,8 @@ async function streamOpenCodeZenChat(
updatedAt: Date.now(), updatedAt: Date.now(),
isEphemeral: false, isEphemeral: false,
}) })
return fullText
} }
async function streamZAIChat( async function streamZAIChat(
@@ -989,7 +995,7 @@ async function streamZAIChat(
messageId: string, messageId: string,
assistantMessageId: string, assistantMessageId: string,
assistantPartId: string, assistantPartId: string,
): Promise<void> { ): Promise<string> {
const controller = new AbortController() const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS) const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
@@ -1116,6 +1122,8 @@ async function streamZAIChat(
updatedAt: Date.now(), updatedAt: Date.now(),
isEphemeral: false, isEphemeral: false,
}) })
return fullText
} }
async function streamAntigravityChat( async function streamAntigravityChat(
@@ -1127,7 +1135,7 @@ async function streamAntigravityChat(
messageId: string, messageId: string,
assistantMessageId: string, assistantMessageId: string,
assistantPartId: string, assistantPartId: string,
): Promise<void> { ): Promise<string> {
const controller = new AbortController() const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS) const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
@@ -1254,6 +1262,26 @@ async function streamAntigravityChat(
updatedAt: Date.now(), updatedAt: Date.now(),
isEphemeral: false, isEphemeral: false,
}) })
return fullText
}
async function persistNativeMessages(
instanceId: string,
sessionId: string,
messages: Array<{
id: string
role: "user" | "assistant" | "system" | "tool"
content: string
createdAt: number
updatedAt: number
}>,
): Promise<void> {
try {
await nativeSessionApi.appendMessages(instanceId, sessionId, messages)
} catch (error) {
log.warn("Failed to persist native messages", { instanceId, sessionId, error })
}
} }
async function sendMessage( async function sendMessage(
@@ -1267,6 +1295,7 @@ async function sendMessage(
if (!instance || !instance.client) { if (!instance || !instance.client) {
throw new Error("Instance not ready") throw new Error("Instance not ready")
} }
const isNative = instance.binaryPath === "__nomadarch_native__"
const instanceSessions = sessions().get(instanceId) const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId) const session = instanceSessions?.get(sessionId)
@@ -1450,10 +1479,11 @@ async function sendMessage(
}) })
}) })
let assistantText = ""
try { try {
if (providerId === "ollama-cloud") { if (providerId === "ollama-cloud") {
const tStream1 = performance.now() const tStream1 = performance.now()
await streamOllamaChat( assistantText = await streamOllamaChat(
instanceId, instanceId,
sessionId, sessionId,
providerId, providerId,
@@ -1466,7 +1496,7 @@ async function sendMessage(
const tStream2 = performance.now() const tStream2 = performance.now()
addDebugLog(`Stream Complete: ${Math.round(tStream2 - tStream1)}ms`, "info") addDebugLog(`Stream Complete: ${Math.round(tStream2 - tStream1)}ms`, "info")
} else if (providerId === "opencode-zen") { } else if (providerId === "opencode-zen") {
await streamOpenCodeZenChat( assistantText = await streamOpenCodeZenChat(
instanceId, instanceId,
sessionId, sessionId,
providerId, providerId,
@@ -1477,7 +1507,7 @@ async function sendMessage(
assistantPartId, assistantPartId,
) )
} else if (providerId === "zai") { } else if (providerId === "zai") {
await streamZAIChat( assistantText = await streamZAIChat(
instanceId, instanceId,
sessionId, sessionId,
providerId, providerId,
@@ -1488,7 +1518,7 @@ async function sendMessage(
assistantPartId, assistantPartId,
) )
} else if (providerId === "antigravity") { } else if (providerId === "antigravity") {
await streamAntigravityChat( assistantText = await streamAntigravityChat(
instanceId, instanceId,
sessionId, sessionId,
providerId, providerId,
@@ -1526,7 +1556,7 @@ async function sendMessage(
return messageId return messageId
} }
await streamQwenChat( assistantText = await streamQwenChat(
instanceId, instanceId,
sessionId, sessionId,
providerId, providerId,
@@ -1539,6 +1569,25 @@ async function sendMessage(
assistantPartId, assistantPartId,
) )
} }
if (isNative) {
const completedAt = Date.now()
await persistNativeMessages(instanceId, sessionId, [
{
id: messageId,
role: "user",
content: resolvedPrompt,
createdAt: now,
updatedAt: now,
},
{
id: assistantMessageId,
role: "assistant",
content: assistantText,
createdAt: now,
updatedAt: completedAt,
},
])
}
return messageId return messageId
} catch (error: any) { } catch (error: any) {
if (providerId === "opencode-zen") { if (providerId === "opencode-zen") {

View File

@@ -486,6 +486,28 @@ async function fetchSessions(instanceId: string): Promise<void> {
}) })
} }
if (isNative) {
const updates: Promise<unknown>[] = []
for (const [parentId, tasks] of Object.entries(sessionTasks)) {
if (!Array.isArray(tasks)) continue
for (const task of tasks as Array<{ taskSessionId?: string }>) {
const childId = task?.taskSessionId
if (!childId) continue
const childSession = sessionMap.get(childId)
if (!childSession) continue
if (childSession.parentId === parentId) continue
sessionMap.set(childId, {
...childSession,
parentId,
})
updates.push(nativeSessionApi.updateSession(instanceId, childId, { parentId }).catch(() => undefined))
}
}
if (updates.length > 0) {
await Promise.allSettled(updates)
}
}
const validSessionIds = new Set(sessionMap.keys()) const validSessionIds = new Set(sessionMap.keys())
setSessions((prev) => { setSessions((prev) => {
@@ -1055,7 +1077,10 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
messagesInfo.set(messageId, info) messagesInfo.set(messageId, info)
const parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part)) let parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part))
if (parts.length === 0 && typeof apiMessage.content === "string" && apiMessage.content.trim().length > 0) {
parts = [normalizeMessagePart({ id: `part-${messageId}`, type: "text", text: apiMessage.content })]
}
const message: Message = { const message: Message = {
id: messageId, id: messageId,

View File

@@ -171,6 +171,15 @@ function schedulePersist(instanceId: string) {
persistTimers.set(instanceId, timer) persistTimers.set(instanceId, timer)
} }
async function flushSessionPersistence(instanceId: string) {
const existing = persistTimers.get(instanceId)
if (existing) {
clearTimeout(existing)
persistTimers.delete(instanceId)
}
await persistSessionTasks(instanceId)
}
async function persistSessionTasks(instanceId: string) { async function persistSessionTasks(instanceId: string) {
try { try {
const instanceSessions = sessions().get(instanceId) const instanceSessions = sessions().get(instanceId)
@@ -439,6 +448,7 @@ export {
pruneDraftPrompts, pruneDraftPrompts,
withSession, withSession,
persistSessionTasks, persistSessionTasks,
flushSessionPersistence,
setSessionCompactionState, setSessionCompactionState,
setSessionPendingPermission, setSessionPendingPermission,
setActiveSession, setActiveSession,

View File

@@ -26,6 +26,7 @@ import {
setActiveParentSession, setActiveParentSession,
setActiveSession, setActiveSession,
setSessionDraftPrompt, setSessionDraftPrompt,
flushSessionPersistence,
} from "./session-state" } from "./session-state"
import { getDefaultModel } from "./session-models" import { getDefaultModel } from "./session-models"
@@ -113,5 +114,6 @@ export {
setSessionDraftPrompt, setSessionDraftPrompt,
updateSessionAgent, updateSessionAgent,
updateSessionModel, updateSessionModel,
flushSessionPersistence,
} }
export type { SessionInfo } export type { SessionInfo }

View File

@@ -3,6 +3,8 @@ import { Task, TaskStatus } from "../types/session"
import { nanoid } from "nanoid" import { nanoid } from "nanoid"
import { createSession } from "./session-api" import { createSession } from "./session-api"
import { showToastNotification } from "../lib/notifications" import { showToastNotification } from "../lib/notifications"
import { instances } from "./instances"
import { nativeSessionApi } from "../lib/lite-mode"
export function setActiveTask(instanceId: string, sessionId: string, taskId: string | undefined): void { export function setActiveTask(instanceId: string, sessionId: string, taskId: string | undefined): void {
withSession(instanceId, sessionId, (session) => { withSession(instanceId, sessionId, (session) => {
@@ -35,6 +37,16 @@ export async function addTask(
taskSession.model = { ...parentModel } taskSession.model = { ...parentModel }
} }
}) })
const instance = instances().get(instanceId)
if (instance?.binaryPath === "__nomadarch_native__") {
try {
await nativeSessionApi.updateSession(instanceId, taskSessionId, {
parentId: sessionId,
})
} catch (error) {
console.warn("[task-actions] Failed to persist parent session", error)
}
}
// console.log("[task-actions] task session created", { taskSessionId }); // console.log("[task-actions] task session created", { taskSessionId });
} catch (error) { } catch (error) {
console.error("[task-actions] Failed to create session for task", error) console.error("[task-actions] Failed to create session for task", error)