Skip to content
Open
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
3 changes: 3 additions & 0 deletions apps/mesh/src/storage/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,9 @@ export class SqlThreadStorage implements ThreadStoragePort {
if (data.branch !== undefined) {
updateData.branch = data.branch;
}
if (data.virtual_mcp_id !== undefined) {
updateData.virtual_mcp_id = data.virtual_mcp_id;
}
if (data.sandbox_provider_kind !== undefined) {
updateData.sandbox_provider_kind = data.sandbox_provider_kind;
}
Expand Down
6 changes: 6 additions & 0 deletions apps/mesh/src/tools/thread/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ export const ThreadUpdateDataSchema = z.object({
"Full replacement of the thread's metadata object",
),
branch: z.string().nullish().describe("New git branch for this thread"),
virtual_mcp_id: z
.string()
.optional()
.describe(
"Re-point the thread at a different agent. Only meaningful for an unsent thread (changing the recipient of a new chat); the UI restricts it accordingly.",
),
});

export type ThreadUpdateData = z.infer<typeof ThreadUpdateDataSchema>;
4 changes: 4 additions & 0 deletions apps/mesh/src/tools/thread/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ export const COLLECTION_THREADS_UPDATE = defineTool({
updateData.branch = data.branch;
}

if (data.virtual_mcp_id !== undefined) {
updateData.virtual_mcp_id = data.virtual_mcp_id;
}

const thread = await ctx.storage.threads.update(id, updateData);

// Fire chat_archived / chat_unarchived when the hidden flag flips. Only
Expand Down
1 change: 1 addition & 0 deletions apps/mesh/src/web/components/chat/store/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export function useThreadActions() {
hide: m.hide.bind(m),
setStatus: m.setStatus.bind(m),
setBranch: m.setBranch.bind(m),
setAgent: m.setAgent.bind(m),
setActive: m.setActive.bind(m),
closeActive: m.closeActive.bind(m),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ export class ThreadManagerStore {
return this.optimisticUpdate(id, { branch });
}

/** Re-point an (unsent) thread at a different agent — updates the row's
* virtual_mcp_id so the sidebar icon follows the selected agent. */
setAgent(id: string, virtualMcpId: string): Promise<void> {
return this.optimisticUpdate(id, { virtual_mcp_id: virtualMcpId });
}

/**
* Local-only patch: apply a partial Task patch in-place. No server round-trip.
* Used for live signals that don't flow through `/watch` (e.g. titles emitted
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ import {
useVirtualMCPActions,
useVirtualMCPs,
} from "@decocms/mesh-sdk";
import { useNavigate, useParams, useRouterState } from "@tanstack/react-router";
import {
useNavigate,
useParams,
useRouterState,
useSearch,
} from "@tanstack/react-router";
import { authClient } from "@/web/lib/auth-client";
import {
useThreadActions,
Expand Down Expand Up @@ -147,21 +152,27 @@ export function TaskGroupsList({
fetchNextPage,
} = useThreads();
const visibleThreads = allThreads.filter((thread) => !thread.hidden);
const { hide } = useThreadActions();
const { hide, setAgent } = useThreadActions();

const navigate = useNavigate();
const navigateToAgent = useNavigateToAgent();
const { setTaskId, createNewTask } = usePanelActions();
const params = useParams({ strict: false }) as {
taskId?: string;
};
const search = useSearch({ strict: false }) as { virtualmcpid?: string };
const pathname = useRouterState({
select: (s) => s.location.pathname,
});
const isOnHome = pathname === `/${org.slug}` || pathname === `/${org.slug}/`;
const activeTaskId = params.taskId ?? null;
// The recipient is the URL's `virtualmcpid` (what the composer sends to),
// falling back to the thread row's agent. Preferring the param keeps the
// active-agent highlight in sync when a new chat is retargeted in place.
const activeAgentId =
allThreads.find((t) => t.id === activeTaskId)?.virtual_mcp_id ?? null;
search.virtualmcpid ??
allThreads.find((t) => t.id === activeTaskId)?.virtual_mcp_id ??
null;
const closeAfterNavigation = () => {
onNavigate?.();
};
Expand Down Expand Up @@ -297,6 +308,22 @@ export function TaskGroupsList({
createNewTask(virtualMcpId);
};

// Verify a thread has no messages (an unsent "New chat"), so we can reuse it
// instead of spawning another empty one. Failure → treat as non-empty.
const isThreadEmpty = async (threadId: string): Promise<boolean> => {
try {
const res = await client.callTool({
name: "COLLECTION_THREAD_MESSAGES_LIST",
arguments: { thread_id: threadId, limit: 1, offset: 0 },
});
const payload = ((res as { structuredContent?: unknown })
.structuredContent ?? res) as { items?: unknown[] };
return (payload.items?.length ?? 0) === 0;
} catch {
return false;
}
};

// New thread: always target the currently selected agent (the active
// thread's agent, else decopilot). To avoid piling up empties, focus an
// existing empty "New chat" for that agent (verified to have no messages)
Expand All @@ -307,38 +334,41 @@ export function TaskGroupsList({
const candidate = myThreadsAll.find(
(t) => t.virtual_mcp_id === currentAgentId && t.title === "New chat",
);
if (candidate) {
let isEmpty = false;
try {
const res = await client.callTool({
name: "COLLECTION_THREAD_MESSAGES_LIST",
arguments: { thread_id: candidate.id, limit: 1, offset: 0 },
});
const payload = ((res as { structuredContent?: unknown })
.structuredContent ?? res) as { items?: unknown[] };
isEmpty = (payload.items?.length ?? 0) === 0;
} catch {
// On lookup failure, fall through and create a fresh thread.
}
if (isEmpty) {
closeAfterNavigation();
setTaskId(candidate.id, currentAgentId);
return;
}
if (candidate && (await isThreadEmpty(candidate.id))) {
closeAfterNavigation();
setTaskId(candidate.id, currentAgentId);
return;
}
closeAfterNavigation();
createNewTask(currentAgentId);
};

// Click an agent → show the agent home (threads list + new chat input).
// Click an agent. There is only ever ONE new chat: if you're already sitting
// in an empty "New chat", clicking a different agent just re-points that chat
// at the new recipient (same thread, swap the agent) instead of spawning yet
// another empty thread. Otherwise it starts the one new chat with that agent.
// Decopilot is the product home — clicking it navigates to /$org directly.
const handleOpenAgent = (virtualMcpId: string) => {
const handleOpenAgent = async (virtualMcpId: string) => {
if (virtualMcpId === decopilotId) {
closeAfterNavigation();
navigate({ to: "/$org", params: { org: org.slug } });
return;
}
track("sidebar_agent_opened", { virtual_mcp_id: virtualMcpId });

// Retarget the current empty new chat in place.
if (activeTaskId && activeAgentId !== virtualMcpId) {
const active = allThreads.find((t) => t.id === activeTaskId);
if (active?.title === "New chat" && (await isThreadEmpty(activeTaskId))) {
closeAfterNavigation();
// Re-point the row at the new agent (icon follows) and swap the
// recipient — same thread, no new empty chat.
void setAgent(activeTaskId, virtualMcpId);
setTaskId(activeTaskId, virtualMcpId);
return;
}
}

closeAfterNavigation();
navigateToAgent(virtualMcpId);
};
Expand Down
Loading