This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-15 11:55:40 -07:00
commit dc88ce0277
193 changed files with 6934 additions and 2192 deletions

View file

@ -49,7 +49,11 @@ import {
type TokenUsageData,
TokenUsageProvider,
} from "@/components/assistant-ui/token-usage-context";
import { type HitlDecision, PendingInterruptProvider } from "@/features/chat-messages/hitl";
import {
type HitlDecision,
PendingInterruptProvider,
type PendingInterruptState,
} from "@/features/chat-messages/hitl";
import { TimelineDataUI } from "@/features/chat-messages/timeline";
import {
applyActionLogSse,
@ -272,12 +276,16 @@ export default function NewChatPage() {
const [tokenUsageStore] = useState(() => createTokenUsageStore());
const abortControllerRef = useRef<AbortController | null>(null);
const recentCancelRequestedAtRef = useRef(0);
const [pendingInterrupt, setPendingInterrupt] = useState<{
threadId: number;
assistantMsgId: string;
interruptData: Record<string, unknown>;
bundleToolCallIds: string[];
} | null>(null);
// One entry per paused subagent, in receipt order (which matches the
// backend's ``state.interrupts`` traversal — and therefore the order
// ``slice_decisions_by_tool_call`` consumes on resume). Cleared on submit
// or on a fresh user turn.
const [pendingInterrupts, setPendingInterrupts] = useState<PendingInterruptState[]>([]);
// Per-card staged decisions held until every pending card has submitted,
// at which point we batch them into one ``hitl-decision`` event in the
// same order as ``pendingInterrupts``. Using a ref because partial
// progress should not re-render the page.
const stagedDecisionsByInterruptIdRef = useRef<Map<string, HitlDecision[]>>(new Map());
const toolsWithUI = TOOLS_WITH_UI_ALL;
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
@ -1194,12 +1202,24 @@ export default function NewChatPage() {
)
);
if (currentThreadId) {
setPendingInterrupt({
threadId: currentThreadId,
assistantMsgId,
interruptData,
bundleToolCallIds,
});
// ``tool_call_id`` is stamped on the backend by
// ``checkpointed_subagent_middleware``. Without it we
// can't address the paused subagent on resume — skip
// rather than fabricate a synthetic key.
const interruptId = String(interruptData.tool_call_id ?? "");
if (interruptId) {
const incoming: PendingInterruptState = {
interruptId,
threadId: currentThreadId,
assistantMsgId,
interruptData,
bundleToolCallIds,
};
setPendingInterrupts((prev) => {
const without = prev.filter((p) => p.interruptId !== interruptId);
return [...without, incoming];
});
}
}
break;
}
@ -1275,7 +1295,7 @@ export default function NewChatPage() {
// by ``persist_assistant_shell``. Rename the optimistic
// id, migrate ``tokenUsageStore`` so any pending
// ``data-token-usage`` payload binds to the new id,
// remap any in-flight ``pendingInterrupt`` reference,
// remap any in-flight ``pendingInterrupts`` entries,
// and reassign the closure variable so the in-stream
// flush callback (line ~1074) keeps writing to the
// renamed message.
@ -1291,10 +1311,12 @@ export default function NewChatPage() {
: m
)
);
setPendingInterrupt((prev) =>
prev && prev.assistantMsgId === oldAssistantMsgId
? { ...prev, assistantMsgId: newAssistantMsgId }
: prev
setPendingInterrupts((prev) =>
prev.map((p) =>
p.assistantMsgId === oldAssistantMsgId
? { ...p, assistantMsgId: newAssistantMsgId }
: p
)
);
assistantMsgId = newAssistantMsgId;
break;
@ -1381,14 +1403,23 @@ export default function NewChatPage() {
edited_action?: { name: string; args: Record<string, unknown> };
}>
) => {
if (!pendingInterrupt) return;
const { threadId: resumeThreadId } = pendingInterrupt;
if (pendingInterrupts.length === 0) return;
// All cards in this turn share the same threadId/assistantMsgId
// (they're siblings of one parent agent step), so reading from
// the first entry is safe.
const resumeThreadId = pendingInterrupts[0].threadId;
// Destructured separately as ``let`` so the SSE
// ``data-assistant-message-id`` handler (resume always
// allocates a fresh server-side row) can rename it to
// the canonical ``msg-{db_id}`` mid-stream.
let assistantMsgId = pendingInterrupt.assistantMsgId;
setPendingInterrupt(null);
let assistantMsgId = pendingInterrupts[0].assistantMsgId;
// Concatenate every card's tool-call ids in pendingInterrupts order;
// this matches the ``decisions`` ordering produced by
// ``handleApprovalSubmit`` and the backend slicer's traversal of
// ``state.interrupts``.
const allBundleToolCallIds = pendingInterrupts.flatMap((p) => p.bundleToolCallIds);
setPendingInterrupts([]);
stagedDecisionsByInterruptIdRef.current.clear();
setIsRunning(true);
const token = getBearerToken();
@ -1465,7 +1496,7 @@ export default function NewChatPage() {
// collapse onto ``decisions[0]``. Cards outside the bundle are
// untouched. Mirrors the host ``hitl-decision`` handler.
const decisionByTcId = new Map<string, (typeof decisions)[number]>();
const tcIds = pendingInterrupt.bundleToolCallIds;
const tcIds = allBundleToolCallIds;
if (decisions.length === tcIds.length) {
for (let i = 0; i < tcIds.length; i++) decisionByTcId.set(tcIds[i], decisions[i]);
}
@ -1477,7 +1508,7 @@ export default function NewChatPage() {
if (!d) continue;
if (typeof part.result !== "object" || part.result === null) continue;
if (!("__interrupt__" in (part.result as Record<string, unknown>))) continue;
const decided = d.type as "approve" | "reject" | "edit";
const decided = d.type;
if (decided === "edit" && d.edited_action) {
const mergedArgs = { ...part.args, ...d.edited_action.args };
part.args = mergedArgs;
@ -1597,12 +1628,22 @@ export default function NewChatPage() {
: m
)
);
setPendingInterrupt({
threadId: resumeThreadId,
assistantMsgId,
interruptData,
bundleToolCallIds,
});
{
const interruptId = String(interruptData.tool_call_id ?? "");
if (interruptId) {
const incoming: PendingInterruptState = {
interruptId,
threadId: resumeThreadId,
assistantMsgId,
interruptData,
bundleToolCallIds,
};
setPendingInterrupts((prev) => {
const without = prev.filter((p) => p.interruptId !== interruptId);
return [...without, incoming];
});
}
}
break;
}
@ -1680,7 +1721,7 @@ export default function NewChatPage() {
}
},
[
pendingInterrupt,
pendingInterrupts,
messages,
searchSpaceId,
localFilesystemEnabled,
@ -1701,17 +1742,19 @@ export default function NewChatPage() {
edited_action?: { name: string; args: Record<string, unknown> };
}>;
};
if (!detail?.decisions || !pendingInterrupt) return;
if (!detail?.decisions || pendingInterrupts.length === 0) return;
const incoming = detail.decisions;
if (incoming.length === 0) return;
const tcIds = pendingInterrupt.bundleToolCallIds;
// Concatenated tool-call ids across every pending card, in the
// order ``handleApprovalSubmit`` produced ``incoming``.
const tcIds = pendingInterrupts.flatMap((p) => p.bundleToolCallIds);
const N = tcIds.length;
// Bundles must submit exactly one decision per action_request.
// Refuse rather than silently broadcast a single decision across
// the bundle (would mis-apply rejects/edits and diverge from
// what handleResume sends to /resume).
if (N > 1 && incoming.length !== N) {
// Refuse rather than silently broadcast or drop. The orchestrator
// only fires ``hitl-decision`` once every pending card has
// submitted, so a count mismatch indicates a contract drift
// (and would later make the backend slicer raise).
if (incoming.length !== N) {
toast.error(
`Cannot resume: ${incoming.length} decision(s) submitted for ${N} pending actions.`
);
@ -1722,9 +1765,12 @@ export default function NewChatPage() {
for (let i = 0; i < tcIds.length; i++) byTcId.set(tcIds[i], incoming[i]);
const submittedDecisions = tcIds.map((id) => byTcId.get(id)!);
// All pending cards belong to the same assistant message, so a
// single content-update pass suffices.
const targetAssistantMsgId = pendingInterrupts[0].assistantMsgId;
setMessages((prev) =>
prev.map((m) => {
if (m.id !== pendingInterrupt.assistantMsgId) return m;
if (m.id !== targetAssistantMsgId) return m;
const parts = m.content as unknown as Array<Record<string, unknown>>;
const newContent = parts.map((part) => {
const tcId = part.toolCallId as string | undefined;
@ -1732,7 +1778,7 @@ export default function NewChatPage() {
if (!d || part.type !== "tool-call") return part;
if (typeof part.result !== "object" || part.result === null) return part;
if (!("__interrupt__" in (part.result as Record<string, unknown>))) return part;
const decided = d.type as "approve" | "reject" | "edit";
const decided = d.type;
if (decided === "edit" && d.edited_action) {
return {
...part,
@ -1761,7 +1807,7 @@ export default function NewChatPage() {
};
window.addEventListener("hitl-decision", handler);
return () => window.removeEventListener("hitl-decision", handler);
}, [handleResume, pendingInterrupt]);
}, [handleResume, pendingInterrupts]);
// Convert message (pass through since already in correct format)
const convertMessage = useCallback(
@ -2283,11 +2329,32 @@ export default function NewChatPage() {
[handleRegenerate, messages, agentActionItems]
);
const handleApprovalSubmit = useCallback((orderedDecisions: HitlDecision[]) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: orderedDecisions } })
);
}, []);
const handleApprovalSubmit = useCallback(
(interruptId: string, decisions: HitlDecision[]) => {
// Stage this card's decisions; only fire the resume once every
// pending card in the current turn has submitted, so the
// backend slicer sees a single concatenated decisions list
// whose total matches the parent state's pending action count.
stagedDecisionsByInterruptIdRef.current.set(interruptId, decisions);
if (stagedDecisionsByInterruptIdRef.current.size < pendingInterrupts.length) {
return;
}
const ordered: HitlDecision[] = [];
for (const pi of pendingInterrupts) {
const staged = stagedDecisionsByInterruptIdRef.current.get(pi.interruptId);
if (!staged) {
// Defensive: a missing entry means the staging map and
// the pending list disagreed for one cycle. Bail rather
// than dispatch a count-mismatched batch.
return;
}
ordered.push(...staged);
}
stagedDecisionsByInterruptIdRef.current.clear();
window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions: ordered } }));
},
[pendingInterrupts]
);
const handleEditDialogChoice = useCallback(
async (choice: EditMessageDialogChoice) => {
@ -2360,7 +2427,7 @@ export default function NewChatPage() {
<TimelineDataUI />
<StepSeparatorDataUI />
<PendingInterruptProvider
pendingInterrupt={pendingInterrupt}
pendingInterrupts={pendingInterrupts}
onSubmit={handleApprovalSubmit}
>
<div key={searchSpaceId} className="flex h-full overflow-hidden">

View file

@ -11,6 +11,7 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
export const createCommentMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: CreateCommentRequest) => {
return chatCommentsApiService.createComment(request);
},
@ -26,6 +27,7 @@ export const createCommentMutationAtom = atomWithMutation(() => ({
}));
export const createReplyMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: CreateReplyRequest & { message_id: number }) => {
return chatCommentsApiService.createReply(request);
},
@ -41,6 +43,7 @@ export const createReplyMutationAtom = atomWithMutation(() => ({
}));
export const updateCommentMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: UpdateCommentRequest & { message_id: number }) => {
return chatCommentsApiService.updateComment(request);
},
@ -56,6 +59,7 @@ export const updateCommentMutationAtom = atomWithMutation(() => ({
}));
export const deleteCommentMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: DeleteCommentRequest & { message_id: number }) => {
return chatCommentsApiService.deleteComment(request);
},

View file

@ -21,6 +21,7 @@ export const createImageGenConfigMutationAtom = atomWithMutation((get) => {
return {
mutationKey: ["image-gen-configs", "create"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: CreateImageGenConfigRequest) => {
return imageGenConfigApiService.createConfig(request);
@ -45,6 +46,7 @@ export const updateImageGenConfigMutationAtom = atomWithMutation((get) => {
return {
mutationKey: ["image-gen-configs", "update"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: UpdateImageGenConfigRequest) => {
return imageGenConfigApiService.updateConfig(request);
@ -72,6 +74,7 @@ export const deleteImageGenConfigMutationAtom = atomWithMutation((get) => {
return {
mutationKey: ["image-gen-configs", "delete"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: { id: number; name: string }) => {
return imageGenConfigApiService.deleteConfig(request.id);

View file

@ -14,6 +14,7 @@ import { queryClient } from "@/lib/query-client/client";
* Mutation atom for creating an invite
*/
export const createInviteMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: CreateInviteRequest) => {
return invitesApiService.createInvite(request);
},
@ -33,6 +34,7 @@ export const createInviteMutationAtom = atomWithMutation(() => ({
* Mutation atom for updating an invite
*/
export const updateInviteMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: UpdateInviteRequest) => {
return invitesApiService.updateInvite(request);
},
@ -52,6 +54,7 @@ export const updateInviteMutationAtom = atomWithMutation(() => ({
* Mutation atom for deleting an invite
*/
export const deleteInviteMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: DeleteInviteRequest) => {
return invitesApiService.deleteInvite(request);
},
@ -71,6 +74,7 @@ export const deleteInviteMutationAtom = atomWithMutation(() => ({
* Mutation atom for accepting an invite
*/
export const acceptInviteMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: AcceptInviteRequest) => {
return invitesApiService.acceptInvite(request);
},

View file

@ -14,6 +14,7 @@ import { queryClient } from "@/lib/query-client/client";
export const updateMemberMutationAtom = atomWithMutation(() => {
return {
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: UpdateMembershipRequest) => {
return membersApiService.updateMember(request);
},
@ -31,6 +32,7 @@ export const updateMemberMutationAtom = atomWithMutation(() => {
export const deleteMemberMutationAtom = atomWithMutation(() => {
return {
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: DeleteMembershipRequest) => {
return membersApiService.deleteMember(request);
},
@ -48,6 +50,7 @@ export const deleteMemberMutationAtom = atomWithMutation(() => {
export const leaveSearchSpaceMutationAtom = atomWithMutation(() => {
return {
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: LeaveSearchSpaceRequest) => {
return membersApiService.leaveSearchSpace(request);
},

View file

@ -23,6 +23,7 @@ export const createNewLLMConfigMutationAtom = atomWithMutation((get) => {
return {
mutationKey: ["new-llm-configs", "create"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: CreateNewLLMConfigRequest) => {
return newLLMConfigApiService.createConfig(request);
@ -47,6 +48,7 @@ export const updateNewLLMConfigMutationAtom = atomWithMutation((get) => {
return {
mutationKey: ["new-llm-configs", "update"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: UpdateNewLLMConfigRequest) => {
return newLLMConfigApiService.updateConfig(request);
@ -74,6 +76,7 @@ export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => {
return {
mutationKey: ["new-llm-configs", "delete"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: DeleteNewLLMConfigRequest & { name: string }) => {
return newLLMConfigApiService.deleteConfig({ id: request.id });
@ -105,6 +108,7 @@ export const updateLLMPreferencesMutationAtom = atomWithMutation((get) => {
return {
mutationKey: ["llm-preferences", "update"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: UpdateLLMPreferencesRequest) => {
return newLLMConfigApiService.updateLLMPreferences(request);

View file

@ -11,6 +11,7 @@ import { queryClient } from "@/lib/query-client/client";
export const createPromptMutationAtom = atomWithMutation(() => ({
mutationKey: ["prompts", "create"],
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: PromptCreateRequest) => {
return promptsApiService.create(request);
},
@ -25,6 +26,7 @@ export const createPromptMutationAtom = atomWithMutation(() => ({
export const updatePromptMutationAtom = atomWithMutation(() => ({
mutationKey: ["prompts", "update"],
meta: { suppressGlobalErrorToast: true },
mutationFn: async ({ id, ...data }: PromptUpdateRequest & { id: number }) => {
return promptsApiService.update(id, data);
},
@ -39,6 +41,7 @@ export const updatePromptMutationAtom = atomWithMutation(() => ({
export const deletePromptMutationAtom = atomWithMutation(() => ({
mutationKey: ["prompts", "delete"],
meta: { suppressGlobalErrorToast: true },
mutationFn: async (id: number) => {
return promptsApiService.delete(id);
},
@ -57,6 +60,7 @@ export const deletePromptMutationAtom = atomWithMutation(() => ({
export const copyPromptMutationAtom = atomWithMutation(() => ({
mutationKey: ["prompts", "copy"],
meta: { suppressGlobalErrorToast: true },
mutationFn: async (promptId: number) => {
return promptsApiService.copy(promptId);
},

View file

@ -10,6 +10,7 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
export const createPublicChatSnapshotMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: PublicChatSnapshotCreateRequest) => {
return chatThreadsApiService.createPublicChatSnapshot(request);
},
@ -37,6 +38,7 @@ export const createPublicChatSnapshotMutationAtom = atomWithMutation(() => ({
}));
export const deletePublicChatSnapshotMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: PublicChatSnapshotDeleteRequest) => {
return chatThreadsApiService.deletePublicChatSnapshot(request);
},

View file

@ -14,6 +14,7 @@ import { queryClient } from "@/lib/query-client/client";
export const createRoleMutationAtom = atomWithMutation(() => {
return {
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: CreateRoleRequest) => {
return rolesApiService.createRole(request);
},
@ -31,6 +32,7 @@ export const createRoleMutationAtom = atomWithMutation(() => {
export const updateRoleMutationAtom = atomWithMutation(() => {
return {
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: UpdateRoleRequest) => {
return rolesApiService.updateRole(request);
},
@ -54,6 +56,7 @@ export const updateRoleMutationAtom = atomWithMutation(() => {
export const deleteRoleMutationAtom = atomWithMutation(() => {
return {
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: DeleteRoleRequest) => {
return rolesApiService.deleteRole(request);
},

View file

@ -18,6 +18,7 @@ export const createVisionLLMConfigMutationAtom = atomWithMutation((get) => {
return {
mutationKey: ["vision-llm-configs", "create"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: CreateVisionLLMConfigRequest) => {
return visionLLMConfigApiService.createConfig(request);
@ -39,6 +40,7 @@ export const updateVisionLLMConfigMutationAtom = atomWithMutation((get) => {
return {
mutationKey: ["vision-llm-configs", "update"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: UpdateVisionLLMConfigRequest) => {
return visionLLMConfigApiService.updateConfig(request);
@ -63,6 +65,7 @@ export const deleteVisionLLMConfigMutationAtom = atomWithMutation((get) => {
return {
mutationKey: ["vision-llm-configs", "delete"],
meta: { suppressGlobalErrorToast: true },
enabled: !!searchSpaceId,
mutationFn: async (request: { id: number; name: string }) => {
return visionLLMConfigApiService.deleteConfig(request.id);

View file

@ -124,7 +124,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
handleStartEdit,
handleSaveConnector,
handleDisconnectConnector,
handleDisconnectFromList,
handleBackFromEdit,
handleBackFromConnect,
handleBackFromYouTube,
@ -233,9 +232,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromMCPList}
onManage={handleStartEdit}
onDisconnect={(connector) =>
handleDisconnectFromList(connector, () => refreshConnectors())
}
onAddAccount={handleAddNewMCPFromList}
addButtonText="Add New MCP Server"
/>
@ -247,9 +243,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onDisconnect={(connector) =>
handleDisconnectFromList(connector, () => refreshConnectors())
}
onAddAccount={() => {
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =

View file

@ -3,6 +3,7 @@
import { CheckCircle2 } from "lucide-react";
import type { FC } from "react";
import type { ConnectorConfigProps } from "../index";
import { MCPTrustedTools } from "./mcp-trusted-tools";
export const MCPServiceConfig: FC<ConnectorConfigProps> = ({ connector }) => {
const serviceName = connector.config?.mcp_service as string | undefined;
@ -11,7 +12,7 @@ export const MCPServiceConfig: FC<ConnectorConfigProps> = ({ connector }) => {
: "this service";
return (
<div className="space-y-4">
<div className="space-y-6">
<div className="rounded-xl border border-border bg-emerald-500/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/10 shrink-0 mt-0.5">
<CheckCircle2 className="size-4 text-emerald-500" />
@ -23,6 +24,8 @@ export const MCPServiceConfig: FC<ConnectorConfigProps> = ({ connector }) => {
</p>
</div>
</div>
{connector.id > 0 && <MCPTrustedTools connector={connector} />}
</div>
);
};

View file

@ -0,0 +1,89 @@
"use client";
import { ShieldCheck, Trash2 } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
interface MCPTrustedToolsProps {
connector: SearchSourceConnector;
}
/** Audit + revoke surface for tools promoted via in-chat "Always Allow". */
export const MCPTrustedTools: FC<MCPTrustedToolsProps> = ({ connector }) => {
const trustedTools = readTrustedTools(connector.config);
const [pending, setPending] = useState<Set<string>>(new Set());
const handleRevoke = async (toolName: string) => {
setPending((prev) => new Set(prev).add(toolName));
try {
await connectorsApiService.untrustMCPTool(connector.id, toolName);
toast.success(`Removed ${toolName} from trusted tools`);
} catch {
toast.error(`Failed to remove ${toolName} from trusted tools`);
} finally {
setPending((prev) => {
const next = new Set(prev);
next.delete(toolName);
return next;
});
}
};
return (
<div className="space-y-4">
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
<ShieldCheck className="h-4 w-4" />
Trusted Tools
</h3>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-4">
<p className="text-[10px] sm:text-xs text-muted-foreground">
Tools listed here skip the approval prompt during chat. Trust is granted by clicking
"Always Allow" on an approval card; revoke it here to require approval again.
</p>
{trustedTools.length === 0 ? (
<p className="text-xs text-muted-foreground/70 italic">
No trusted tools yet for this connector.
</p>
) : (
<ul className="space-y-1">
{trustedTools.map((toolName) => {
const isPending = pending.has(toolName);
return (
<li
key={toolName}
className="flex items-center justify-between gap-3 rounded-lg px-3 py-2 hover:bg-muted/40 transition-colors"
>
<span className="text-xs font-mono break-all">{toolName}</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-destructive shrink-0"
onClick={() => handleRevoke(toolName)}
disabled={isPending}
aria-label={`Revoke trust for ${toolName}`}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="ml-1 hidden sm:inline">Revoke</span>
</Button>
</li>
);
})}
</ul>
)}
</div>
</div>
);
};
function readTrustedTools(config: Record<string, unknown> | undefined | null): string[] {
const raw = config?.trusted_tools;
if (!Array.isArray(raw)) return [];
return raw.filter((item): item is string => typeof item === "string");
}

View file

@ -1288,25 +1288,6 @@ export const useConnectorDialog = () => {
[editingConnector, searchSpaceId, deleteConnector, cameFromMCPList, setIsOpen]
);
const handleDisconnectFromList = useCallback(
async (connector: SearchSourceConnector, refreshConnectors: () => void) => {
if (!searchSpaceId) return;
try {
await deleteConnector({ id: connector.id });
trackConnectorDeleted(Number(searchSpaceId), connector.connector_type, connector.id);
toast.success(`${connector.name} disconnected successfully`);
refreshConnectors();
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
} catch (error) {
console.error("Error disconnecting connector:", error);
toast.error("Failed to disconnect connector");
}
},
[searchSpaceId, deleteConnector]
);
// Handle quick index (index with selected date range, or backend defaults if none selected)
const handleQuickIndexConnector = useCallback(
async (
@ -1480,7 +1461,6 @@ export const useConnectorDialog = () => {
handleStartEdit,
handleSaveConnector,
handleDisconnectConnector,
handleDisconnectFromList,
handleBackFromEdit,
handleBackFromConnect,
handleBackFromYouTube,

View file

@ -1,7 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import { ArrowLeft, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
import { ArrowLeft, Plus, RefreshCw, Server } from "lucide-react";
import { type FC, useCallback, useState } from "react";
import { toast } from "sonner";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
@ -24,7 +24,6 @@ interface ConnectorAccountsListViewProps {
indexingConnectorIds: Set<number>;
onBack: () => void;
onManage: (connector: SearchSourceConnector) => void;
onDisconnect?: (connector: SearchSourceConnector) => Promise<void> | void;
onAddAccount: () => void;
isConnecting?: boolean;
addButtonText?: string;
@ -37,15 +36,12 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
indexingConnectorIds,
onBack,
onManage,
onDisconnect,
onAddAccount,
isConnecting = false,
addButtonText,
}) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const [reauthingId, setReauthingId] = useState<number | null>(null);
const [confirmDisconnectId, setConfirmDisconnectId] = useState<number | null>(null);
const [disconnectingId, setDisconnectingId] = useState<number | null>(null);
// Get connector status
const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus();
@ -240,51 +236,6 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
/>
Re-authenticate
</Button>
) : isLive && onDisconnect ? (
confirmDisconnectId === connector.id ? (
<div className="flex items-center gap-1.5 shrink-0">
<Button
variant="destructive"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium shadow-xs"
onClick={async () => {
setDisconnectingId(connector.id);
setConfirmDisconnectId(null);
try {
await onDisconnect(connector);
} finally {
setDisconnectingId(null);
}
}}
disabled={disconnectingId === connector.id}
>
{disconnectingId === connector.id ? (
<RefreshCw className="size-3.5 animate-spin" />
) : (
"Confirm"
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-[11px] px-2 rounded-lg"
onClick={() => setConfirmDisconnectId(null)}
disabled={disconnectingId === connector.id}
>
Cancel
</Button>
</div>
) : (
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-red-50 hover:text-red-700 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-red-950 dark:hover:text-red-400 shrink-0"
onClick={() => setConfirmDisconnectId(connector.id)}
>
<Trash2 className="size-3.5" />
Disconnect
</Button>
)
) : (
<Button
variant="secondary"

View file

@ -42,7 +42,7 @@ export interface PlateEditorProps {
editorVariant?: "default" | "demo" | "fullWidth" | "none";
/** Additional className for the container */
className?: string;
/** Save callback. When provided, ⌘+S / Ctrl+S shortcut is registered and save button appears. */
/** Save callback. When provided, ⌘+Shift+S / Ctrl+Shift+S shortcut is registered (avoiding the browser's ⌘+S / Ctrl+S "Save Page As" conflict) and a save button appears in the toolbar. */
onSave?: () => void;
/** Whether there are unsaved changes */
hasUnsavedChanges?: boolean;

View file

@ -2,13 +2,11 @@
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import type { HitlDecision, InterruptResult, PerToolApprovalCard } from "../types";
import { useHitlDecision } from "../use-hitl-decision";
import { useHitlPhase } from "../use-hitl-phase";
@ -81,12 +79,11 @@ function GenericApprovalCardView({
const mcpServer = interruptData.context?.mcp_server as string | undefined;
const toolDescription = interruptData.context?.tool_description as string | undefined;
const mcpConnectorId = interruptData.context?.mcp_connector_id as number | undefined;
const isMCPTool = mcpConnectorId != null;
const reviewConfig = interruptData.review_configs?.[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit");
const canApproveAlways = allowedDecisions.includes("approve_always");
const hasChanged = useMemo(() => {
return JSON.stringify(editedParams) !== JSON.stringify(args);
@ -113,16 +110,11 @@ function GenericApprovalCardView({
editedParams,
]);
const handleAlwaysAllow = useCallback(() => {
if (phase !== "pending" || !isMCPTool) return;
const handleApproveAlways = useCallback(() => {
if (phase !== "pending" || !canApproveAlways) return;
setProcessing();
onDecision({ type: "approve" });
connectorsApiService.trustMCPTool(mcpConnectorId, toolName).catch(() => {
toast.error(
"Failed to save 'Always Allow' preference. The tool will still require approval next time."
);
});
}, [phase, setProcessing, onDecision, isMCPTool, mcpConnectorId, toolName]);
onDecision({ type: "approve_always" });
}, [phase, setProcessing, onDecision, canApproveAlways]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -214,8 +206,8 @@ function GenericApprovalCardView({
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
)}
{isMCPTool && (
<Button size="sm" className="rounded-lg" onClick={handleAlwaysAllow}>
{canApproveAlways && (
<Button size="sm" className="rounded-lg" onClick={handleApproveAlways}>
Always Allow
</Button>
)}

View file

@ -3,8 +3,10 @@
import { createContext, type ReactNode, useContext } from "react";
import type { HitlDecision } from "../types";
/** Snapshot of one in-flight HITL interrupt; ``null`` when nothing is pending. */
/** One in-flight HITL interrupt (one paused subagent). */
export interface PendingInterruptState {
/** Stable id keyed by the parent ``tool_call_id`` stamped on the interrupt. */
interruptId: string;
threadId: number;
assistantMsgId: string;
interruptData: Record<string, unknown>;
@ -12,8 +14,19 @@ export interface PendingInterruptState {
}
export interface PendingInterruptValue {
pendingInterrupt: PendingInterruptState | null;
onSubmit: (decisions: HitlDecision[]) => void;
/**
* Every paused subagent for the current turn, in the order the SSE stream
* delivered them which matches ``state.interrupts`` traversal on the
* backend, which is the order ``slice_decisions_by_tool_call`` consumes.
*/
pendingInterrupts: PendingInterruptState[];
/**
* Stage one card's decisions. The orchestrator (page-level) batches across
* cards and dispatches the resume only once every pending interrupt has
* submitted, so the backend slicer sees a single concatenated decisions
* list whose total matches the parent state's pending action count.
*/
onSubmit: (interruptId: string, decisions: HitlDecision[]) => void;
}
const PendingInterruptContext = createContext<PendingInterruptValue | null>(null);
@ -24,16 +37,16 @@ const PendingInterruptContext = createContext<PendingInterruptValue | null>(null
* page root.
*/
export function PendingInterruptProvider({
pendingInterrupt,
pendingInterrupts,
onSubmit,
children,
}: {
pendingInterrupt: PendingInterruptState | null;
onSubmit: (decisions: HitlDecision[]) => void;
pendingInterrupts: PendingInterruptState[];
onSubmit: (interruptId: string, decisions: HitlDecision[]) => void;
children: ReactNode;
}) {
return (
<PendingInterruptContext.Provider value={{ pendingInterrupt, onSubmit }}>
<PendingInterruptContext.Provider value={{ pendingInterrupts, onSubmit }}>
{children}
</PendingInterruptContext.Provider>
);

View file

@ -7,12 +7,12 @@ export interface InterruptActionRequest {
export interface InterruptReviewConfig {
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
allowed_decisions: Array<"approve" | "edit" | "reject" | "approve_always">;
}
export interface InterruptResult<C extends Record<string, unknown> = Record<string, unknown>> {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__decided__?: "approve" | "reject" | "edit" | "approve_always";
__completed__?: boolean;
action_requests: InterruptActionRequest[];
review_configs: InterruptReviewConfig[];
@ -31,7 +31,7 @@ export function isInterruptResult(result: unknown): result is InterruptResult {
}
export interface HitlDecision {
type: "approve" | "reject" | "edit";
type: "approve" | "reject" | "edit" | "approve_always";
message?: string;
edited_action?: {
name: string;

View file

@ -11,10 +11,9 @@ const noopSubmit = () => {};
/**
* assistant-ui data UI for the ``thinking-steps`` data-part.
*
* Re-scopes the global ``PendingInterruptProvider`` per message: the
* approval card only mounts under the assistant message that owns
* the interrupt (otherwise every message in scrollback would render
* its own card).
* Re-scopes the global ``PendingInterruptProvider`` per message: approval
* cards only mount under the assistant message that owns the interrupt
* (otherwise every message in scrollback would render its own cards).
*/
function TimelineDataRenderer({ data }: { name: string; data: unknown }) {
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
@ -23,10 +22,10 @@ function TimelineDataRenderer({ data }: { name: string; data: unknown }) {
const content = useAuiState(({ message }) => message?.content);
const messageId = useAuiState(({ message }) => message?.id);
const pendingValue = usePendingInterrupt();
const pendingForThisMessage =
pendingValue?.pendingInterrupt && pendingValue.pendingInterrupt.assistantMsgId === messageId
? pendingValue.pendingInterrupt
: null;
const pendingForThisMessage = useMemo(
() => (pendingValue?.pendingInterrupts ?? []).filter((p) => p.assistantMsgId === messageId),
[pendingValue?.pendingInterrupts, messageId]
);
const onSubmit = pendingValue?.onSubmit ?? noopSubmit;
const steps = useMemo<ThinkingStepInput[]>(
@ -39,11 +38,11 @@ function TimelineDataRenderer({ data }: { name: string; data: unknown }) {
[steps, content]
);
if (items.length === 0 && !pendingForThisMessage) return null;
if (items.length === 0 && pendingForThisMessage.length === 0) return null;
return (
<div className="mb-3 -mx-2 leading-normal">
<PendingInterruptProvider pendingInterrupt={pendingForThisMessage} onSubmit={onSubmit}>
<PendingInterruptProvider pendingInterrupts={pendingForThisMessage} onSubmit={onSubmit}>
<Timeline items={items} isThreadRunning={isMessageStreaming} />
</PendingInterruptProvider>
</div>

View file

@ -32,9 +32,9 @@ export const Timeline: FC<{
isThreadRunning?: boolean;
}> = ({ items, isThreadRunning = true }) => {
const pendingValue = usePendingInterrupt();
const pendingInterrupt = pendingValue?.pendingInterrupt ?? null;
const pendingInterrupts = pendingValue?.pendingInterrupts ?? [];
const onSubmit = pendingValue?.onSubmit;
const hasPending = pendingInterrupt !== null;
const hasPending = pendingInterrupts.length > 0;
// Apply the override here so downstream (grouping, headers, dots)
// sees the corrected status without threading a callback. Keeps
@ -135,9 +135,15 @@ export const Timeline: FC<{
/>
);
})}
{pendingInterrupt && onSubmit && (
<div className="pl-5">
<HitlApprovalCard pendingInterrupt={pendingInterrupt} onSubmit={onSubmit} />
{hasPending && onSubmit && (
<div className="pl-5 space-y-3">
{pendingInterrupts.map((pi) => (
<HitlApprovalCard
key={pi.interruptId}
pendingInterrupt={pi}
onSubmit={(decisions) => onSubmit(pi.interruptId, decisions)}
/>
))}
</div>
)}
</div>

View file

@ -405,35 +405,19 @@ class ConnectorsApiService {
);
};
// =============================================================================
// MCP Tool Trust (Allow-List) Methods
// =============================================================================
/**
* Add a tool to the MCP connector's "Always Allow" list.
* Subsequent calls to this tool will skip HITL approval.
*/
trustMCPTool = async (connectorId: number, toolName: string): Promise<void> => {
await baseApiService.post(`/api/v1/connectors/mcp/${connectorId}/trust-tool`, undefined, {
body: { tool_name: toolName },
});
};
/**
* Remove a tool from the MCP connector's "Always Allow" list.
*/
untrustMCPTool = async (connectorId: number, toolName: string): Promise<void> => {
await baseApiService.post(`/api/v1/connectors/mcp/${connectorId}/untrust-tool`, undefined, {
body: { tool_name: toolName },
});
};
/** Live stats for the Obsidian connector tile. */
getObsidianStats = async (vaultId: string): Promise<ObsidianStats> => {
return baseApiService.get<ObsidianStats>(
`/api/v1/obsidian/stats?vault_id=${encodeURIComponent(vaultId)}`
);
};
/** Revoke a previously-trusted MCP tool so the next call asks again. */
untrustMCPTool = async (connectorId: number, toolName: string): Promise<void> => {
await baseApiService.post(`/api/v1/connectors/mcp/${connectorId}/untrust-tool`, undefined, {
body: { tool_name: toolName },
});
};
}
export interface ObsidianStats {

View file

@ -565,7 +565,7 @@ export type SSEEvent =
* the assistant-side row of the current turn. The frontend
* renames its optimistic ``msg-assistant-XXX`` placeholder
* id, migrates the local ``tokenUsageStore`` and
* ``pendingInterrupt`` references, and binds the running
* ``pendingInterrupts`` entries, and binds the running
* mutable ``assistantMsgId`` closure variable to the
* canonical id for the rest of the stream.
*/