Merge remote-tracking branch 'upstream/dev' into feat/ui-revamp

This commit is contained in:
Anish Sarkar 2026-05-13 03:02:23 +05:30
commit 481bb406b6
757 changed files with 70989 additions and 4089 deletions

View file

@ -5,6 +5,8 @@ import {
type CreateTokenCheckoutSessionResponse,
createCheckoutSessionResponse,
createTokenCheckoutSessionResponse,
type FinalizeCheckoutResponse,
finalizeCheckoutResponse,
type GetPagePurchasesResponse,
type GetTokenPurchasesResponse,
getPagePurchasesResponse,
@ -54,6 +56,20 @@ class StripeApiService {
getTokenPurchases = async (): Promise<GetTokenPurchasesResponse> => {
return baseApiService.get("/api/v1/stripe/token-purchases", getTokenPurchasesResponse);
};
/**
* Synchronously fulfil a checkout session from the success page.
*
* Solves the race where the user lands on /purchase-success before
* Stripe's checkout.session.completed webhook arrives. Idempotent
* safe to call concurrently with the webhook.
*/
finalizeCheckout = async (sessionId: string): Promise<FinalizeCheckoutResponse> => {
return baseApiService.get(
`/api/v1/stripe/finalize-checkout?session_id=${encodeURIComponent(sessionId)}`,
finalizeCheckoutResponse
);
};
}
export const stripeApiService = new StripeApiService();

View file

@ -60,12 +60,20 @@ export function handleUnauthorized(): void {
const currentPath = pathname + window.location.search + window.location.hash;
const excludedPaths = ["/auth", "/auth/callback", "/"];
if (!excludedPaths.includes(pathname)) {
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
setRedirectPath(currentPath);
}
window.location.href = getLoginPath();
}
}
/**
* Stores the path to redirect to after successful authentication.
*/
export function setRedirectPath(path: string): void {
if (typeof window === "undefined") return;
localStorage.setItem(REDIRECT_PATH_KEY, path);
}
/**
* Gets the stored redirect path and clears it from storage
* Call this after successful login to redirect the user back
@ -230,7 +238,7 @@ export function redirectToLogin(): void {
// Don't save auth-related paths or home page
const excludedPaths = ["/auth", "/auth/callback", "/", "/login", "/register", "/desktop/login"];
if (!excludedPaths.includes(window.location.pathname)) {
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
setRedirectPath(currentPath);
}
window.location.href = getLoginPath();

View file

@ -1,8 +1,18 @@
type MentionKeyInput = {
id: number;
document_type?: string | null;
kind?: "doc" | "folder";
};
/**
* Build a stable dedup key for a mention chip.
*
* The ``kind:document_type:id`` shape prevents a document and a folder
* with the same integer id from colliding in the chip array (folders
* use the ``FOLDER`` sentinel ``document_type``; the ``kind`` prefix
* is the belt-and-braces guard).
*/
export function getMentionDocKey(doc: MentionKeyInput): string {
return `${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
const kind = doc.kind ?? "doc";
return `${kind}:${doc.document_type ?? "UNKNOWN"}:${doc.id}`;
}

View file

@ -1,9 +1,187 @@
import type { ThreadMessageLike } from "@assistant-ui/react";
import type { MessageRecord } from "./thread-persistence";
/** Minimal shape used by the interrupt/resume reconciler. */
interface AbortableMessage {
id: number;
role: string;
content: unknown;
turn_id?: string | null;
}
function isAssistant(msg: AbortableMessage): boolean {
return msg.role.toLowerCase() === "assistant";
}
/** True when the row carries at least one tool-call with ``state: "aborted"``. */
function hasAbortedToolCall(msg: AbortableMessage): boolean {
if (!isAssistant(msg) || !Array.isArray(msg.content)) return false;
for (const part of msg.content) {
if (typeof part !== "object" || part === null) continue;
if ((part as { type?: string }).type !== "tool-call") continue;
if ((part as { state?: unknown }).state === "aborted") return true;
}
return false;
}
/**
* Convert backend message to assistant-ui ThreadMessageLike format.
* Migrates legacy `thinking-steps` parts to `data-thinking-steps` (assistant-ui data parts).
* True when EVERY tool-call on the row is aborted. The row is then a
* frozen interrupt frame with no salvageable activity safe to drop
* outright.
*/
function isFullyAbortedAssistantMessage(msg: AbortableMessage): boolean {
if (!isAssistant(msg) || !Array.isArray(msg.content)) return false;
let hasToolCalls = false;
for (const part of msg.content) {
if (typeof part !== "object" || part === null) continue;
if ((part as { type?: string }).type !== "tool-call") continue;
hasToolCalls = true;
if ((part as { state?: unknown }).state !== "aborted") return false;
}
return hasToolCalls;
}
/**
* Locate the resume row that supersedes ``messages[idx]``. The
* ``stream_resume_chat`` flow allocates a fresh ``turn_id`` so we
* can't pair on it; conversational adjacency (assistant assistant
* with no user row between) is the unique signature. Skips already-
* dropped indices so chained interrupt-resumes still pair cleanly.
*/
function findResumeSuccessorIdx<T extends AbortableMessage>(
messages: readonly T[],
idx: number,
dropped: ReadonlySet<number>
): number | null {
for (let i = idx + 1; i < messages.length; i++) {
if (dropped.has(i)) continue;
const role = messages[i].role.toLowerCase();
if (role === "user") return null;
if (role === "assistant") return i;
}
return null;
}
/** Read ``data.steps`` from either ``data-thinking-steps`` (modern) or ``thinking-steps`` (legacy). */
function extractStepsFromPart(part: unknown): unknown[] | null {
if (typeof part !== "object" || part === null) return null;
const p = part as { type?: unknown; data?: unknown; steps?: unknown };
if (p.type === "data-thinking-steps") {
const data = p.data as { steps?: unknown } | undefined;
return Array.isArray(data?.steps) ? data.steps : [];
}
if (p.type === "thinking-steps") {
return Array.isArray(p.steps) ? (p.steps as unknown[]) : [];
}
return null;
}
/** Split a content array into (combined steps, all other parts in order). */
function partitionContent(content: unknown): { steps: unknown[]; others: unknown[] } {
if (!Array.isArray(content)) return { steps: [], others: [] };
const steps: unknown[] = [];
const others: unknown[] = [];
for (const part of content) {
const partSteps = extractStepsFromPart(part);
if (partSteps !== null) {
steps.push(...partSteps);
continue;
}
others.push(part);
}
return { steps, others };
}
/**
* Fold an interrupt-frame row's content into its resume successor so
* the user sees one assistant turn instead of two stacked bubbles.
* Successor's metadata wins (id, created_at, turn_id, token_usage,
* author) that's the row the per-turn revert button keys to.
*
* Order: combined ``data-thinking-steps`` (older steps then newer) at
* index 0, followed by older's other parts in order, then newer's. The
* older row's aborted ``task`` wrapper is preserved so the rejected
* attempt remains visible alongside the successful retry; both spans
* survive and ``groupItems`` renders them as sibling task branches in
* one timeline.
*/
function mergeInterruptedIntoResume<T extends AbortableMessage>(older: T, newer: T): T {
const olderParts = partitionContent(older.content);
const newerParts = partitionContent(newer.content);
const mergedSteps = [...olderParts.steps, ...newerParts.steps];
const mergedContent: unknown[] = [];
if (mergedSteps.length > 0) {
mergedContent.push({ type: "data-thinking-steps", data: { steps: mergedSteps } });
}
mergedContent.push(...olderParts.others, ...newerParts.others);
return { ...newer, content: mergedContent };
}
/**
* Reconcile interrupt-frame and resume rows so the UI shows one
* assistant turn per user turn even when the backend persists them as
* separate ``new_chat_messages`` rows.
*
* Two cases, both keyed on conversational adjacency (assistant
* assistant with no user row between):
*
* - **Fully aborted older row** (every tool-call ``state: "aborted"``,
* no salvageable activity) drop the older row.
* - **Partially aborted older row** (mixed completed + aborted, e.g.
* inner subagent tools ran before the interrupt) fold its content
* into the successor. Successor metadata wins.
*
* Never-resumed aborts (user navigated away mid-decision) survive so
* the user still sees what happened.
*
* Pure: returns a new array with new merged objects when needed.
* Caller passes messages in chronological order.
*/
export function reconcileInterruptedAssistantMessages<T extends AbortableMessage>(
messages: readonly T[]
): T[] {
const dropped = new Set<number>();
const mergeInto = new Map<number, number[]>();
for (let i = 0; i < messages.length; i++) {
if (dropped.has(i)) continue;
const msg = messages[i];
if (!hasAbortedToolCall(msg)) continue;
const successorIdx = findResumeSuccessorIdx(messages, i, dropped);
if (successorIdx === null) continue;
dropped.add(i);
if (!isFullyAbortedAssistantMessage(msg)) {
const list = mergeInto.get(successorIdx) ?? [];
list.push(i);
mergeInto.set(successorIdx, list);
}
}
const result: T[] = [];
for (let i = 0; i < messages.length; i++) {
if (dropped.has(i)) continue;
const olderIdxs = mergeInto.get(i);
if (olderIdxs && olderIdxs.length > 0) {
let merged = messages[i];
for (const olderIdx of olderIdxs) {
merged = mergeInterruptedIntoResume(messages[olderIdx], merged);
}
result.push(merged);
continue;
}
result.push(messages[i]);
}
return result;
}
/**
* Convert a backend ``MessageRecord`` to assistant-ui's
* ``ThreadMessageLike``. Also migrates legacy ``thinking-steps`` parts
* to ``data-thinking-steps``.
*/
export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
let content: ThreadMessageLike["content"];
@ -24,9 +202,10 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
"type" in part &&
(part as { type: string }).type === "thinking-steps"
) {
const steps = (part as unknown as { steps?: unknown[] }).steps;
return {
type: "data-thinking-steps",
data: { steps: (part as { steps: unknown[] }).steps ?? [] },
data: { steps: Array.isArray(steps) ? steps : [] },
};
}
return part;
@ -50,9 +229,8 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
},
}),
...(msg.token_usage && { usage: msg.token_usage }),
// Surface ``chat_turn_id`` so the assistant message
// footer can scope its "Revert turn" button to just
// this turn's actions. Null on legacy rows.
// Surfaced for the assistant footer's per-turn
// "Revert turn" button. Null on legacy rows.
...(msg.turn_id && { chatTurnId: msg.turn_id }),
},
}

View file

@ -112,7 +112,8 @@ export function processSharedStreamEvent(
parsed.toolName,
{},
false,
parsed.langchainToolCallId
parsed.langchainToolCallId,
parsed.metadata
);
forceFlush();
return true;
@ -131,6 +132,7 @@ export function processSharedStreamEvent(
args: parsed.input || {},
argsText: finalArgsText,
langchainToolCallId: parsed.langchainToolCallId,
metadata: parsed.metadata,
});
} else {
addToolCall(
@ -140,7 +142,8 @@ export function processSharedStreamEvent(
parsed.toolName,
parsed.input || {},
false,
parsed.langchainToolCallId
parsed.langchainToolCallId,
parsed.metadata
);
// addToolCall doesn't accept argsText today; backfill via
// updateToolCall so the new card renders pretty-printed JSON.
@ -156,6 +159,7 @@ export function processSharedStreamEvent(
updateToolCall(contentPartsState, parsed.toolCallId, {
result: parsed.output,
langchainToolCallId: parsed.langchainToolCallId,
metadata: parsed.metadata,
});
markInterruptsCompleted(contentParts);
context.onToolOutputAvailable?.(parsed, { contentPartsState, toolCallIndices });

View file

@ -1,91 +1,4 @@
import type { ThreadMessageLike } from "@assistant-ui/react";
import {
addToolCall,
type ContentPartsState,
type ToolUIGate,
updateToolCall,
} from "@/lib/chat/streaming-state";
type InterruptActionRequest = {
name: string;
args: Record<string, unknown>;
};
export type EditedInterruptAction = {
name: string;
args: Record<string, unknown>;
};
function readInterruptActions(interruptData: Record<string, unknown>): InterruptActionRequest[] {
return (interruptData.action_requests ?? []) as InterruptActionRequest[];
}
/**
* Applies an interrupt request payload to tool-call parts. Existing tool cards
* are updated in-place; missing ones are upserted so approval UI always shows.
*/
export function applyInterruptRequestToContentParts(
contentPartsState: ContentPartsState,
toolsWithUI: ToolUIGate,
interruptData: Record<string, unknown>
): void {
const { contentParts, toolCallIndices } = contentPartsState;
const actionRequests = readInterruptActions(interruptData);
for (const action of actionRequests) {
const existingEntry = Array.from(toolCallIndices.entries()).find(([, idx]) => {
const part = contentParts[idx];
return part?.type === "tool-call" && part.toolName === action.name;
});
if (existingEntry) {
updateToolCall(contentPartsState, existingEntry[0], {
result: { __interrupt__: true, ...interruptData },
});
} else {
const toolCallId = `interrupt-${action.name}`;
addToolCall(contentPartsState, toolsWithUI, toolCallId, action.name, action.args, true);
updateToolCall(contentPartsState, toolCallId, {
result: { __interrupt__: true, ...interruptData },
});
}
}
}
export function mergeEditedInterruptAction(
contentParts: ContentPartsState["contentParts"],
editedAction: EditedInterruptAction | undefined
): void {
if (!editedAction) return;
for (const part of contentParts) {
if (part.type === "tool-call" && part.toolName === editedAction.name) {
const mergedArgs = { ...part.args, ...editedAction.args };
part.args = mergedArgs;
// assistant-ui prefers argsText over JSON.stringify(args)
part.argsText = JSON.stringify(mergedArgs, null, 2);
break;
}
}
}
export function markInterruptDecisionOnContentParts(
contentParts: ContentPartsState["contentParts"],
decisionType: "approve" | "reject" | undefined
): void {
if (!decisionType) return;
for (const part of contentParts) {
if (
part.type === "tool-call" &&
typeof part.result === "object" &&
part.result !== null &&
"__interrupt__" in (part.result as Record<string, unknown>)
) {
part.result = {
...(part.result as Record<string, unknown>),
__decided__: decisionType,
};
}
}
}
/**
* When a streamed message is persisted, the backend returns the durable
@ -114,6 +27,29 @@ export function readStreamedChatTurnId(data: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
/**
* Parse the payload of `data-user-message-id` / `data-assistant-message-id`
* SSE events emitted by `stream_new_chat` and `stream_resume_chat` after
* `persist_user_turn` / `persist_assistant_shell` resolve a canonical
* `new_chat_messages.id`. Mirrors {@link readStreamedChatTurnId}.
*
* Returns `null` when the payload is malformed (missing or non-numeric
* `message_id`); callers should treat this as "ignore the event" so a
* malformed BE payload never overwrites the optimistic id with a bogus
* value.
*/
export function readStreamedMessageId(
data: unknown
): { messageId: number; turnId: string | null } | null {
if (typeof data !== "object" || data === null) return null;
const obj = data as { message_id?: unknown; turn_id?: unknown };
if (typeof obj.message_id !== "number" || !Number.isFinite(obj.message_id)) {
return null;
}
const turnId = typeof obj.turn_id === "string" && obj.turn_id.length > 0 ? obj.turn_id : null;
return { messageId: obj.message_id, turnId };
}
export function applyTurnIdToAssistantMessageList(
messages: ThreadMessageLike[],
assistantMsgId: string,

View file

@ -5,6 +5,11 @@ export interface ThinkingStepData {
title: string;
status: "pending" | "in_progress" | "completed";
items: string[];
/**
* Optional relay fields from ``data-thinking-step`` when present on the wire
* (e.g. ``spanId``). Populated in a later slice; equality helpers ignore until wired.
*/
metadata?: Record<string, unknown>;
}
export type ContentPart =
@ -42,6 +47,11 @@ export type ContentPart =
* ``data-action-log`` events.
*/
langchainToolCallId?: string;
/**
* Relay correlation from tool SSE (e.g. ``spanId``, ``thinkingStepId``).
* Merged by ``mergeToolPartMetadata`` when events carry ``metadata``.
*/
metadata?: Record<string, unknown>;
}
| {
type: "data-thinking-steps";
@ -63,6 +73,18 @@ export interface ContentPartsState {
currentTextPartIndex: number;
currentReasoningPartIndex: number;
toolCallIndices: Map<string, number>;
/**
* Set by the resume flow's rehydration to suppress
* ``data-step-separator`` for the rest of this turn. Without it,
* the resume stream's first ``start-step`` fires
* ``addStepSeparator`` while rehydrated OLD content already makes
* ``hasContent`` true a divider lands between OLD and NEW
* content with no semantic value (OLD content is folded by
* ``buildTimeline`` + ``reconcileInterruptedAssistantMessages``,
* persisted state carries no separator, so the line vanishes on
* reload).
*/
suppressStepSeparators?: boolean;
}
function areThinkingStepsEqual(current: ThinkingStepData[], next: ThinkingStepData[]): boolean {
@ -224,7 +246,9 @@ export function addStepSeparator(state: ContentPartsState): void {
// non-step content (so the FIRST step of a turn doesn't
// generate a leading separator) and when the previous part isn't
// itself a separator (defensive against duplicate `start-step`
// events).
// events). Also skipped during a resume turn (see
// ``suppressStepSeparators`` on ``ContentPartsState``).
if (state.suppressStepSeparators) return;
const hasContent = state.contentParts.some(
(p) => p.type === "text" || p.type === "reasoning" || p.type === "tool-call"
);
@ -252,6 +276,23 @@ function _toolPasses(gate: ToolUIGate, toolName: string): boolean {
return gate === "all" || gate.has(toolName);
}
/**
* Shallow-merge relay ``metadata`` into a tool-call part (SSE content part).
* Keys already set on ``into`` are left unchanged so chunk vs canonical tool
* events cannot reorder or overwrite ``spanId`` / ``thinkingStepId``.
* Matches server ``AssistantContentBuilder`` merge semantics.
*/
function mergeToolPartMetadata(
into: Record<string, unknown>,
incoming: Record<string, unknown> | undefined
): void {
if (!incoming) return;
for (const [k, v] of Object.entries(incoming)) {
if (k === "__proto__" || k === "constructor") continue;
if (!(k in into)) into[k] = v;
}
}
export function addToolCall(
state: ContentPartsState,
toolsWithUI: ToolUIGate,
@ -259,15 +300,19 @@ export function addToolCall(
toolName: string,
args: Record<string, unknown>,
force = false,
langchainToolCallId?: string
langchainToolCallId?: string,
metadata?: Record<string, unknown>
): void {
if (force || _toolPasses(toolsWithUI, toolName)) {
const relayMeta: Record<string, unknown> = {};
mergeToolPartMetadata(relayMeta, metadata);
state.contentParts.push({
type: "tool-call",
toolCallId,
toolName,
args,
...(langchainToolCallId ? { langchainToolCallId } : {}),
...(Object.keys(relayMeta).length > 0 ? { metadata: relayMeta } : {}),
});
state.toolCallIndices.set(toolCallId, state.contentParts.length - 1);
state.currentTextPartIndex = -1;
@ -304,6 +349,7 @@ export function updateToolCall(
argsText?: string;
result?: unknown;
langchainToolCallId?: string;
metadata?: Record<string, unknown>;
}
): void {
const index = state.toolCallIndices.get(toolCallId);
@ -323,6 +369,11 @@ export function updateToolCall(
if (update.langchainToolCallId && !tc.langchainToolCallId) {
tc.langchainToolCallId = update.langchainToolCallId;
}
if (update.metadata && Object.keys(update.metadata).length > 0) {
const md = (tc.metadata ?? {}) as Record<string, unknown>;
mergeToolPartMetadata(md, update.metadata);
tc.metadata = md;
}
}
}
@ -416,14 +467,15 @@ export type SSEEvent =
toolName: string;
/** Authoritative LangChain ``tool_call.id``. Optional. */
langchainToolCallId?: string;
/** Optional JSON object from tool SSE (same keys as persisted tool-call metadata). */
metadata?: Record<string, unknown>;
}
| {
/**
* Live tool-call argument delta. Concatenated into
* ``argsText`` on the matching ``tool-call`` content part
* by ``appendToolInputDelta``. parity_v2 only the legacy
* code path emits ``tool-input-available`` without prior
* deltas.
* by ``appendToolInputDelta``. Some providers emit
* ``tool-input-available`` without prior deltas.
*/
type: "tool-input-delta";
toolCallId: string;
@ -435,6 +487,7 @@ export type SSEEvent =
toolName: string;
input: Record<string, unknown>;
langchainToolCallId?: string;
metadata?: Record<string, unknown>;
}
| {
type: "tool-output-available";
@ -444,6 +497,7 @@ export type SSEEvent =
* ``ToolMessage.tool_call_id`` at on_tool_end. Backfills cards
* that didn't get the id at tool-input-start time. */
langchainToolCallId?: string;
metadata?: Record<string, unknown>;
}
| { type: "data-thinking-step"; data: ThinkingStepData }
| { type: "data-thread-title-update"; data: { threadId: number; title: string } }
@ -487,6 +541,37 @@ export type SSEEvent =
type: "data-turn-info";
data: { chat_turn_id: string };
}
| {
/**
* Emitted by ``stream_new_chat`` AFTER ``data-turn-info`` /
* ``data-turn-status`` and BEFORE any LLM streaming events,
* once ``persist_user_turn`` has resolved the canonical
* ``new_chat_messages.id`` for the user-side row of the
* current turn. The frontend renames its optimistic
* ``msg-user-XXX`` placeholder id to ``msg-{message_id}``
* so DB-id-gated UI (comments, edit-from-this-message)
* unlocks immediately. Not emitted by ``stream_resume_chat``
* (resume reuses the original turn's user message).
*/
type: "data-user-message-id";
data: { message_id: number; turn_id: string };
}
| {
/**
* Emitted by ``stream_new_chat`` AND ``stream_resume_chat``
* AFTER ``data-turn-info`` / ``data-turn-status`` and BEFORE
* any LLM streaming events, once ``persist_assistant_shell``
* has resolved the canonical ``new_chat_messages.id`` for
* 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
* mutable ``assistantMsgId`` closure variable to the
* canonical id for the rest of the stream.
*/
type: "data-assistant-message-id";
data: { message_id: number; turn_id: string };
}
| {
/**
* Best-effort revert pass that ran BEFORE this regeneration.

View file

@ -144,6 +144,17 @@ export async function getThreadMessages(threadId: number): Promise<ThreadHistory
* via ``data-turn-info``. Persisting it lets later edits locate the
* matching LangGraph checkpoint without HumanMessage scanning. Older
* callers can still omit it for back-compat.
*
* @deprecated Replaced by the SSE-based message ID handshake. The
* streaming generator (`stream_new_chat` / `stream_resume_chat`) now
* persists both the user and assistant rows server-side via
* `persist_user_turn` / `persist_assistant_shell` and emits
* `data-user-message-id` / `data-assistant-message-id` SSE events so
* the UI renames its optimistic IDs in real time. The only remaining
* caller is `persistAssistantErrorMessage` (pre-stream error fallback
* for requests the server never accepted the server has nothing to
* persist in that case). After the legacy route is removed in a
* follow-up PR this function will be deleted entirely.
*/
export async function appendMessage(
threadId: number,

View file

@ -0,0 +1,55 @@
/**
* Pure helpers for turning agent-facing virtual paths into human-friendly
* chip labels.
*
* Why this lives in `lib` and not in the UI component:
* - Pure function = trivial to unit-test (no React, no DOM).
* - Used in two render sites today (the user-message chip and the AI-answer
* `MentionChip`) and likely more (history search, share-card previews, etc).
* Centralising the rules keeps the agent's path encoding and the UI's
* decoding from drifting apart.
*
* The agent emits paths under `/documents/...` with two encoding rules
* applied by `surfsense_backend/app/agents/new_chat/path_resolver.py`:
*
* 1. Every basename ends with `.xml` (so the LLM treats KB documents as XML
* files). Display layer strips this users think of the underlying
* filename, not the LLM's wrapper.
* 2. Title collisions get a ` (<doc_id>).xml` disambiguation suffix.
* Display layer strips the parenthesised id since it's an implementation
* detail, not user-facing identity.
*/
const XML_EXTENSION_RE = /\.xml$/i;
const DOC_ID_DISAMBIG_RE = /\s\(\d+\)$/;
export interface VirtualPathDisplay {
/** Human-friendly leaf name with `.xml` and ` (<doc_id>)` suffixes stripped. */
displayName: string;
/** Whether the path points to a folder (trailing slash) rather than a file. */
isFolder: boolean;
}
/**
* Decode a virtual path into the label that should appear in chip UI.
*
* Folder detection uses the trailing-slash convention the agent already
* follows in `<priority_documents>` and `KnowledgeTreeMiddleware`. Falls
* back to the raw path if nothing else can be extracted (defensive the
* caller will at least show *something*).
*/
export function getVirtualPathDisplay(path: string): VirtualPathDisplay {
const trimmed = (path ?? "").trim();
if (!trimmed) return { displayName: "", isFolder: false };
const isFolder = trimmed.endsWith("/");
const normalized = trimmed.replace(/\/+$/, "");
const segments = normalized.split("/").filter(Boolean);
const leaf = segments.at(-1);
if (!leaf) return { displayName: trimmed, isFolder };
const withoutXml = leaf.replace(XML_EXTENSION_RE, "");
const displayName = withoutXml.replace(DOC_ID_DISAMBIG_RE, "");
return { displayName: displayName || leaf, isFolder };
}

View file

@ -1,8 +0,0 @@
export type {
HitlDecision,
InterruptActionRequest,
InterruptResult,
InterruptReviewConfig,
} from "./types";
export { isInterruptResult } from "./types";
export { useHitlDecision } from "./use-hitl-decision";

View file

@ -1,45 +0,0 @@
/**
* Shared types for Human-in-the-Loop (HITL) approval across all tools.
*
* Every tool-ui component that handles interrupts should import from here
* instead of defining its own `InterruptResult` / `isInterruptResult`.
*/
export interface InterruptActionRequest {
name: string;
args: Record<string, unknown>;
}
export interface InterruptReviewConfig {
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}
export interface InterruptResult<C extends Record<string, unknown> = Record<string, unknown>> {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: InterruptActionRequest[];
review_configs: InterruptReviewConfig[];
interrupt_type?: string;
context?: C;
message?: string;
}
export function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
export interface HitlDecision {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: {
name: string;
args: Record<string, unknown>;
};
}

View file

@ -1,17 +0,0 @@
/**
* Shared hook for dispatching HITL decisions.
*
* All tool-ui components that handle approve/reject/edit should use this
* instead of manually constructing `CustomEvent("hitl-decision", ...)`.
*/
import { useCallback } from "react";
import type { HitlDecision } from "./types";
export function useHitlDecision() {
const dispatch = useCallback((decisions: HitlDecision[]) => {
window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } }));
}, []);
return { dispatch };
}