Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions packages/core/src/agent/run-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function startRun(
send: (event: AgentChatEvent) => void,
signal: AbortSignal,
) => Promise<void>,
onComplete?: (run: ActiveRun) => void,
onComplete?: (run: ActiveRun) => void | Promise<void>,
): ActiveRun {
// If there's already a run for this thread, abort it
const existingRunId = threadToRun.get(threadId);
Expand Down Expand Up @@ -157,13 +157,18 @@ export function startRun(
runId,
run.status === "errored" ? "errored" : "completed",
).catch(() => {});
// Call completion callback (e.g. to save thread data)
// Call completion callback (e.g. to save thread data).
// onComplete may be async — handle the returned Promise so rejections
// don't go unobserved and the callback reliably runs to completion.
if (onComplete) {
try {
onComplete(run);
} catch {
// Don't let callback errors break cleanup
}
Promise.resolve()
.then(() => onComplete(run))
.catch((err) => {
console.error(
"[run-manager] onComplete callback error:",
err instanceof Error ? err.message : err,
);
});
}
// Schedule in-memory cleanup
setTimeout(() => {
Expand Down
38 changes: 36 additions & 2 deletions packages/core/src/client/AgentTaskCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
IconLoader2,
IconCheck,
Expand Down Expand Up @@ -63,10 +64,12 @@ export function AgentTaskCard({
useEffect(() => {
if (status !== "running") return;
let stopped = false;
let pollCount = 0;
const poll = async () => {
while (!stopped) {
await new Promise((r) => setTimeout(r, 3000));
if (stopped) break;
pollCount++;
try {
const res = await fetch(
`/_agent-native/application-state/agent-task:${taskId}`,
Expand All @@ -92,6 +95,37 @@ export function AgentTaskCard({
if (task.preview) setPreview(task.preview);
if (task.currentStep) setCurrentStep(task.currentStep);
}

// Fallback: every 5th poll, check if the sub-agent's run is still
// active. If it's gone (completed without updating app-state), mark
// the task as completed so the card doesn't spin forever.
if (pollCount % 5 === 0) {
try {
const runRes = await fetch(
`/_agent-native/agent-chat/runs/active?threadId=${encodeURIComponent(threadId)}`,
);
if (runRes.ok) {
const runData = await runRes.json();
// null or non-running status means the run finished
if (
!runData ||
runData.status === "completed" ||
runData.status === "errored"
) {
const finalStatus =
runData?.status === "errored" ? "errored" : "completed";
setStatus(finalStatus);
setCurrentStep("");
setSummary(
(prev) => prev || task?.preview || "Task completed.",
);
break;
}
}
} catch {
// Fallback check failed — continue normal polling
}
}
} catch {
// Polling error — continue
}
Expand All @@ -101,7 +135,7 @@ export function AgentTaskCard({
return () => {
stopped = true;
};
}, [status, taskId]);
}, [status, taskId, threadId]);

// Auto-scroll preview to bottom
useEffect(() => {
Expand Down Expand Up @@ -182,7 +216,7 @@ export function AgentTaskCard({
ref={previewRef}
className="rounded-md bg-muted/30 px-3 py-2 text-xs text-muted-foreground break-words max-h-48 overflow-y-auto agent-markdown prose prose-sm prose-invert max-w-none"
>
<ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{displayText.length > 800
? "..." + displayText.slice(-800)
: displayText}
Expand Down
55 changes: 45 additions & 10 deletions packages/core/src/client/AssistantChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from "@assistant-ui/react";
import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { createAgentChatAdapter } from "./agent-chat-adapter.js";
import { type ContentPart, readSSEStreamRaw } from "./sse-event-processor.js";
import { cn } from "./utils.js";
Expand Down Expand Up @@ -98,7 +99,11 @@ function MarkdownText() {
injectMarkdownStyles();
}, []);
return (
<MarkdownTextPrimitive smooth className="agent-markdown break-words" />
<MarkdownTextPrimitive
smooth
className="agent-markdown break-words"
remarkPlugins={[remarkGfm]}
/>
);
}

Expand Down Expand Up @@ -342,7 +347,9 @@ function ToolCallFallback({
ref={streamRef}
className="mt-1 rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground break-words max-h-48 overflow-y-auto agent-markdown prose prose-sm prose-invert max-w-none"
>
<ReactMarkdown>{agentStreamText}</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{agentStreamText}
</ReactMarkdown>
</div>
)}
{isExpanded && !isAgentCall && result !== undefined && (
Expand Down Expand Up @@ -472,7 +479,9 @@ function ReconnectStreamToolCall({
ref={streamRef}
className="mt-1 rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground break-words max-h-48 overflow-y-auto agent-markdown prose prose-sm prose-invert max-w-none"
>
<ReactMarkdown>{agentStreamText}</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{agentStreamText}
</ReactMarkdown>
</div>
)}
{isExpanded && !isAgentCall && result !== undefined && (
Expand Down Expand Up @@ -763,10 +772,10 @@ function AssistantMessage() {
// ─── Thinking Indicator ─────────────────────────────────────────────────────

function ThinkingIndicator() {
const [dots, setDots] = useState(1);
const [dots, setDots] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setDots((d) => (d % 3) + 1);
setDots((d) => (d + 1) % 4);
}, 400);
return () => clearInterval(interval);
}, []);
Expand Down Expand Up @@ -986,7 +995,7 @@ const AssistantChatInner = forwardRef<
const scrollRef = useRef<HTMLDivElement>(null);
const thread = useThread();
const threadRuntime = useThreadRuntime();
const isRunning = thread.isRunning;
const isRuntimeRunning = thread.isRunning;
const messages = thread.messages;
const [missingApiKey, setMissingApiKey] = useState(false);
const [queuedMessages, setQueuedMessages] = useState<
Expand All @@ -995,6 +1004,9 @@ const AssistantChatInner = forwardRef<
const [showContinue, setShowContinue] = useState(false);
const [isReconnecting, setIsReconnecting] = useState(false);
const [reconnectContent, setReconnectContent] = useState<ContentPart[]>([]);
const reconnectRunIdRef = useRef<string | null>(null);
// Treat reconnecting to an active run the same as running for UI purposes
const isRunning = isRuntimeRunning || isReconnecting;
const wasRunningRef = useRef(false);
const tiptapRef = useRef<TiptapComposerHandle>(null);

Expand Down Expand Up @@ -1044,8 +1056,15 @@ const AssistantChatInner = forwardRef<
if (runRes.ok) {
const runInfo = await runRes.json();
// Agent is still running — subscribe to live SSE stream
reconnectRunIdRef.current = runInfo.runId;
setIsReconnecting(true);
setReconnectContent([]);
// Signal tab running indicator
window.dispatchEvent(
new CustomEvent("builder.chatRunning", {
detail: { isRunning: true, tabId: tabId || threadId },
}),
);

const streamReconnect = async () => {
try {
Expand Down Expand Up @@ -1106,6 +1125,13 @@ const AssistantChatInner = forwardRef<
} catch {}
setReconnectContent([]);
setIsReconnecting(false);
reconnectRunIdRef.current = null;
// Signal tab stopped
window.dispatchEvent(
new CustomEvent("builder.chatRunning", {
detail: { isRunning: false, tabId: tabId || threadId },
}),
);
};
streamReconnect();
}
Expand Down Expand Up @@ -1464,11 +1490,10 @@ const AssistantChatInner = forwardRef<
</button>
</div>
)}
{isRunning && <ThinkingIndicator />}
{isReconnecting && !isRunning && reconnectContent.length > 0 && (
{isReconnecting && reconnectContent.length > 0 && (
<ReconnectStreamMessage content={reconnectContent} />
)}
{isReconnecting && !isRunning && reconnectContent.length === 0 && (
{isRunning && !(isReconnecting && reconnectContent.length > 0) && (
<ThinkingIndicator />
)}
{queuedMessages.map((msg, i) => (
Expand Down Expand Up @@ -1539,7 +1564,17 @@ const AssistantChatInner = forwardRef<
actionButton={
isRunning ? (
<button
onClick={() => threadRuntime.cancelRun()}
onClick={() => {
if (isReconnecting && reconnectRunIdRef.current) {
// Abort the server-side run directly
fetch(
`${apiUrl}/runs/${encodeURIComponent(reconnectRunIdRef.current)}/abort`,
{ method: "POST" },
);
} else {
threadRuntime.cancelRun();
}
}}
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-md bg-primary text-primary-foreground hover:opacity-90"
title="Stop generating"
>
Expand Down
32 changes: 23 additions & 9 deletions packages/core/src/client/MultiTabAssistantChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,31 +305,45 @@ export function MultiTabAssistantChat({
});
const initializedRef = useRef(false);

// Persist open tab IDs to localStorage
// Persist open tab IDs to localStorage (exclude sub-agent tabs — they're session-only)
useEffect(() => {
if (openTabIds.length > 0) {
const mainTabs = openTabIds.filter((id) => !parentMap[id]);
if (mainTabs.length > 0) {
try {
localStorage.setItem(OPEN_TABS_KEY, JSON.stringify(openTabIds));
localStorage.setItem(OPEN_TABS_KEY, JSON.stringify(mainTabs));
} catch {}
}
}, [openTabIds]);
}, [openTabIds, parentMap]);

// Initialize open tabs once threads load — validate saved tabs still exist
useEffect(() => {
if (initializedRef.current || !activeThreadId || threads.length === 0)
return;
initializedRef.current = true;
const threadIds = new Set(threads.map((t) => t.id));

// If the active thread is a sub-agent, switch to its parent or the most recent main thread
if (parentMap[activeThreadId]) {
const parent = parentMap[activeThreadId];
if (parent && threadIds.has(parent)) {
switchThread(parent);
} else {
// Fall back to most recent main thread
const mainThread = threads.find((t) => !parentMap[t.id]);
if (mainThread) switchThread(mainThread.id);
}
}

setOpenTabIds((prev) => {
// Filter out any saved tabs that no longer exist
const valid = prev.filter((id) => threadIds.has(id));
// Ensure active thread is included
if (!valid.includes(activeThreadId)) {
// Filter out any saved tabs that no longer exist, and any sub-agent tabs
const valid = prev.filter((id) => threadIds.has(id) && !parentMap[id]);
// Ensure active thread is included (only if it's not a sub-agent)
if (!parentMap[activeThreadId] && !valid.includes(activeThreadId)) {
valid.push(activeThreadId);
}
return valid.length > 0 ? valid : [activeThreadId];
});
}, [activeThreadId, threads]);
}, [activeThreadId, threads, parentMap, switchThread]);

// Ensure active thread is always in open tabs.
// Use functional update to check inside the setter — avoids race with the
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/client/composer/TiptapComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ export function TiptapComposer({
popoverStateRef.current = null;
}, []);

// Persist draft to localStorage so hot-reloads don't lose the prompt
const DRAFT_KEY = "an-composer-draft";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Hardcoded draft key causes cross-tab draft contamination

DRAFT_KEY = "an-composer-draft" is shared by all TiptapComposer instances. MultiTabAssistantChat keeps inactive tabs mounted via display:none, so when a new tab's composer mounts its onCreate restores whatever the last active tab was typing — bleeding drafts across threads. Fix: accept a draftKey prop (e.g. threadId) and derive the key from it.


React with 👍 or 👎 to help me improve.

const draftTimerRef = useRef<ReturnType<typeof setTimeout>>();

const editor = useEditor({
extensions: [
StarterKit.configure({
Expand All @@ -124,6 +128,32 @@ export function TiptapComposer({
MentionReference,
],
editable: !disabled,
onCreate: ({ editor: ed }) => {
// Restore draft on mount
try {
const saved = localStorage.getItem(DRAFT_KEY);
if (saved) {
ed.commands.setContent(saved);
// Move cursor to end
ed.commands.focus("end");
}
} catch {}
},
onUpdate: ({ editor: ed }) => {
// Debounce-save draft to localStorage
clearTimeout(draftTimerRef.current);
draftTimerRef.current = setTimeout(() => {
try {
const html = ed.getHTML();
const isEmpty = !ed.state.doc.textContent.trim();
if (isEmpty) {
localStorage.removeItem(DRAFT_KEY);
} else {
localStorage.setItem(DRAFT_KEY, html);
}
} catch {}
}, 300);
},
editorProps: {
attributes: {
class:
Expand Down Expand Up @@ -345,6 +375,9 @@ export function TiptapComposer({
composerRuntime.send();
}
ed.commands.clearContent();
try {
localStorage.removeItem(DRAFT_KEY);
} catch {}
closePopover();
}, [closePopover, composerRuntime, editor, onSubmit, syncComposerState]);

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/client/resources/use-resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function useResources(scope: ResourceScope = "personal") {
const data = await fetchJson<{ resources: ResourceMeta[] }>(
`/_agent-native/resources?scope=${scope}`,
);
return data.resources;
return data.resources ?? [];
},
});
}
Expand All @@ -67,7 +67,7 @@ export function useResourceTree(scope: ResourceScope = "personal") {
const data = await fetchJson<{ tree: TreeNode[] }>(
`/_agent-native/resources/tree?scope=${scope}`,
);
return data.tree;
return data.tree ?? [];
},
});
}
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/client/use-chat-threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ export function useChatThreads(apiUrl = "/_agent-native/agent-chat") {
...t,
title: data.title,
preview: data.preview,
messageCount: data.messageCount,
...(data.messageCount != null && {
messageCount: data.messageCount,
}),
updatedAt: Date.now(),
}
: t,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export {
getDbExec,
getDialect,
isPostgres,
intType,
closeDbExec,
type DbExec,
type Dialect,
Expand Down
Loading
Loading