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
17 changes: 14 additions & 3 deletions e2e/hitl/auto-approve.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ async function makeInterruptStream(
callCount: { n: number },
page: import('@playwright/test').Page,
): Promise<void> {
await page.route('**/api/proxy/agent/threads/*', (route) => {
const path = new URL(route.request().url()).pathname;
const isThreadRecord = /\/threads\/(?!search$)[^/]+$/.test(path);
if (route.request().method() === 'GET' && isThreadRecord) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'interrupted' }),
});
}
return route.fallback();
});
await page.route('**/api/proxy/agent/v1/stream', (route) => {
callCount.n++;
if (callCount.n === 1) {
Expand Down Expand Up @@ -78,8 +90,7 @@ test.describe('HITL — auto-approve', () => {
const chat = new ChatPage(page);
await chat.expectChatRoute();

// The response should complete without the interrupt banner ever appearing
await chat.waitForAIResponse(15_000);
await expect(page.getByText('Done automatically.')).toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Action Required')).not.toBeVisible();
await expect(page.getByText('Input Required')).not.toBeVisible();
expect(callCount.n).toBeGreaterThanOrEqual(2);
Expand Down Expand Up @@ -129,7 +140,7 @@ test.describe('HITL — auto-approve', () => {
const chat = new ChatPage(page);
await chat.expectChatRoute();

await chat.waitForAIResponse(15_000);
await expect(page.getByText('Done automatically.')).toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Action Required')).not.toBeVisible();
expect(callCount.n).toBeGreaterThanOrEqual(2);
},
Expand Down
74 changes: 55 additions & 19 deletions src/frontend/components/ChatMessagesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@
import type { InterruptInfo } from "../types/deep-agent";
import { InputForm } from "./InputForm";
import { McpStatusPanel } from "./McpStatusPanel";
import { useState, ReactNode, useMemo, useEffect, useRef, useCallback, cloneElement, isValidElement } from "react";
import { useState, ReactNode, useMemo, useEffect, useRef, useCallback, useContext, cloneElement, isValidElement } from "react";
import { cn } from "../lib/utils";
import {
Dropdown,
DropdownItem,
DropdownList,
Label,
MenuToggle,
type MenuToggleElement,
} from "@patternfly/react-core";
Expand All @@ -26,6 +25,9 @@
import { isSubAgentToolCall, type SubagentTranscriptEntry } from "../types/deep-agent";
import { SubAgentIndicator } from "./SubAgentIndicator";
import { ToolCallRow } from "./ToolCallRow";
import { DocumentOpenContext } from "./document-open-context";
import { DocumentChrome } from "./DocumentReferences";
import { fileIdFromCitationHref, isInAppHref, joinSplitCitationLinks } from "../lib/document-references";
import { ChartCard, PlotlyCard, useContentSegments } from "./InlineChart";
import { TodoStrip } from "./TodoStrip";
import { FeedbackButtons, FeedbackCommentBox, useFeedback } from "./FeedbackButtons";
Expand Down Expand Up @@ -287,6 +289,37 @@
);
}

function CitationLink({ className, children, href }: MdComponentProps) {
const openDocument = useContext(DocumentOpenContext);
const link = typeof href === "string" ? href : "";
const fileId = fileIdFromCitationHref(link);
if (fileId) {
return (
<button
type="button"
className={cn("citation-link", className)}
onClick={() => openDocument?.(fileId, link)}
>
{children}
</button>
);
}
if (isInAppHref(link, globalThis.location?.origin ?? "")) {
return <span className={cn("text-primary", className)}>{children}</span>;
}
return (
<a
className={cn("text-primary underline hover:text-primary/80", className)}
href={link}
target="_blank"
rel="noopener noreferrer"
>
{children}
<span className="sr-only"> (opens in new tab)</span>
</a>
);
}

const mdComponents = {
h1: ({ className, children, ...props }: MdComponentProps) => (
<h1 className={cn("text-2xl font-bold mt-4 mb-2 text-foreground", className)} {...props}>
Expand All @@ -308,20 +341,7 @@
{children}
</p>
),
a: ({ className, children, href, ...props }: MdComponentProps) => (
<Label isCompact className="mx-0.5">
<a
className={cn("text-primary hover:text-primary/80 text-xs", className)}
href={href as string}
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
<span className="sr-only"> (opens in new tab)</span>
</a>
</Label>
),
a: CitationLink,
ul: ({ className, children, ...props }: MdComponentProps) => (
<ul className={cn("list-disc pl-6 mb-3 space-y-1", className)} {...props}>
{children}
Expand Down Expand Up @@ -637,7 +657,7 @@
}

/** Sentence stays outside. Later thoughts and tool calls stay in one block, in arrival order. */
export function buildTurnSegments(messages: Message[]): TurnSegment[] {

Check warning on line 660 in src/frontend/components/ChatMessagesView.tsx

View workflow job for this annotation

GitHub Actions / lint

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const pieces: Array<
| { kind: 'thinking'; text: string }
| { kind: 'text'; text: string }
Expand Down Expand Up @@ -690,12 +710,12 @@
return segments;
}

export function turnEndsInThought(messages: Message[]): boolean {

Check warning on line 713 in src/frontend/components/ChatMessagesView.tsx

View workflow job for this annotation

GitHub Actions / lint

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
return buildTurnSegments(messages).at(-1)?.type === 'thought';
}

/** A thought is still open work when a tool has no result yet. A finished subagent card is not. */
export function turnStillRunning(messages: Message[]): boolean {

Check warning on line 718 in src/frontend/components/ChatMessagesView.tsx

View workflow job for this annotation

GitHub Actions / lint

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
if (!turnEndsInThought(messages)) return false;
const last = buildTurnSegments(messages).at(-1);
if (!last || last.type !== 'thought') return false;
Expand Down Expand Up @@ -760,7 +780,7 @@
}

function AnswerStream({ text }: { text: string }) {
const parts = useContentSegments(text);
const parts = useContentSegments(joinSplitCitationLinks(text));
return (
<div className="ai-response-body">
{parts.map((part, index) => {
Expand All @@ -778,6 +798,20 @@

export function AIMessageRenderer({ message, pendingInterrupt, onInterruptResume, onAlwaysAllow, globalApprovalIndex = 0, approvalSlotOffset = 0, totalActionRequests = 0, allDecisionsMade = false, onSingleDecision, thinkingLive = false, questionContent, interruptContent, turnMessages }: AIMessageRendererProps) {
const source = turnMessages && turnMessages.length > 0 ? turnMessages : [message];
const openDocument = useContext(DocumentOpenContext);
const keepDocumentInPage = useCallback((event: React.MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
const anchor = target.closest("a");
if (!(anchor instanceof HTMLAnchorElement)) return;
const href = anchor.getAttribute("href") ?? "";
const fileId = fileIdFromCitationHref(href);
const staysInChat = fileId !== null || isInAppHref(href, globalThis.location?.origin ?? "");
if (!staysInChat) return;
event.preventDefault();
event.stopPropagation();
if (fileId) openDocument?.(fileId, href);
}, [openDocument]);
const [thoughtChoice, setThoughtChoice] = useState<Record<number, boolean>>({});
const messageKey = JSON.stringify(source);

Expand All @@ -790,7 +824,7 @@
[pendingInterrupt],
);

const segments = useMemo(() => buildTurnSegments(source), [messageKey]);

Check warning on line 827 in src/frontend/components/ChatMessagesView.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useMemo has a missing dependency: 'source'. Either include it or remove the dependency array

const approvalSlotByToolCallId = useMemo(() => {
const map = new Map<string, number>();
Expand All @@ -808,7 +842,7 @@
}
}
return map;
}, [messageKey, pendingToolNames, approvalSlotOffset]);

Check warning on line 845 in src/frontend/components/ChatMessagesView.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useMemo has a missing dependency: 'source'. Either include it or remove the dependency array

const renderMessage = useMemo(() => {
const customRaw = (message as Record<string, unknown>).custom_data;
Expand Down Expand Up @@ -932,7 +966,7 @@
if (item.kind === 'thinking') {
return (
<div key={`think-${itemIndex}`} className="text-[13px] leading-relaxed text-muted-foreground/60 italic [&_strong]:font-medium [&_strong]:text-muted-foreground/70 [&_p]:mb-1.5 [&_p:last-child]:mb-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{item.text}</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{ a: CitationLink }}>{joinSplitCitationLinks(item.text)}</ReactMarkdown>
</div>
);
}
Expand Down Expand Up @@ -999,7 +1033,7 @@
}, [messageKey, thoughtChoice, globalApprovalIndex, allDecisionsMade, pendingInterrupt, onInterruptResume, onAlwaysAllow, onSingleDecision, totalActionRequests, approvalSlotByToolCallId, thinkingLive, questionContent, interruptContent, pendingToolNames]);

return (
<div className="space-y-2 w-full">
<div className="space-y-2 w-full" onClickCapture={keepDocumentInPage}>
{renderMessage}
</div>
);
Expand Down Expand Up @@ -1193,6 +1227,7 @@
const showExport = Boolean(onExportMarkdown && onExportJson && messages.length > 0);

return (
<DocumentChrome messages={messages}>
<div className="flex flex-col h-full min-h-0 overflow-hidden">
<div
aria-live="polite"
Expand Down Expand Up @@ -1399,5 +1434,6 @@
</div>
</div>
</div>
</DocumentChrome>
);
}
117 changes: 117 additions & 0 deletions src/frontend/components/DocumentReferences.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { BookOpen, ChevronRight, FileText } from "lucide-react";
import { documentReferences, viewerPages, type DocumentReference } from "../lib/document-references";
import { prefetchCitedPages } from "../lib/processed-document";
import { callMcpAppTool } from "../services/mcp-apps-api";
import { DocumentOpenContext } from "./document-open-context";
import { SourceDocumentViewer } from "./SourceDocumentViewer";

function pageLabel(pages: number[]): string {
if (pages.length === 0) return "";
return `p. ${pages.join(", ")}`;
}

export function ReferencesRail({
references,
activeFileId,
onOpen,
}: {
references: DocumentReference[];
activeFileId: string | null;
onOpen: (fileId: string) => void;
}) {
const [expanded, setExpanded] = useState(true);
if (references.length === 0) return null;
if (!expanded) {
return (
<button
type="button"
aria-label="References"
onClick={() => setExpanded(true)}
className="flex h-full w-11 shrink-0 flex-col items-center justify-center gap-3 border-l border-border bg-card"
>
<BookOpen className="h-4 w-4 text-primary" aria-hidden="true" />
<span className="text-xs font-semibold tracking-wide text-foreground" style={{ writingMode: "vertical-rl" }}>
References
</span>
<span className="rounded-full bg-primary/15 px-1.5 text-[10px] font-bold text-primary">{references.length}</span>
</button>
);
}
return (
<aside aria-label="References" className="flex h-full w-80 shrink-0 flex-col border-l border-border bg-background">
<div className="flex items-center justify-between border-b border-border px-3 py-2.5">
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-primary" aria-hidden="true" />
<span className="text-sm font-semibold text-foreground">References</span>
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{references.length}</span>
</div>
<button type="button" onClick={() => setExpanded(false)} aria-label="Hide references" className="rounded-md p-1 hover:bg-muted">
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</button>
</div>
<div className="flex-1 space-y-2 overflow-auto p-2">
{references.map((reference) => (
<button
key={reference.fileId}
type="button"
onClick={() => onOpen(reference.fileId)}
className={`w-full rounded-lg border px-3 py-2 text-left hover:bg-muted/40 ${reference.fileId === activeFileId ? "border-primary bg-primary/5" : "border-border bg-card"}`}
>
<span className="flex items-start gap-2">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{reference.title}</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{[reference.pipeline, pageLabel(reference.pages)].filter(Boolean).join(", ")}
</span>
</span>
</span>
</button>
))}
</div>
</aside>
);
}

export function DocumentChrome({
messages,
children,
}: {
messages: readonly unknown[];
children: ReactNode;
}) {
const references = useMemo(() => documentReferences(messages), [messages]);
const pages = useMemo(() => viewerPages(references), [references]);
useEffect(() => {
prefetchCitedPages(pages, callMcpAppTool);
}, [pages]);
const [openDoc, setOpenDoc] = useState<{ fileId: string; href?: string } | null>(null);
const [activeFileId, setActiveFileId] = useState<string | null>(null);
const openDocument = useCallback((fileId: string, href?: string) => {
setOpenDoc({ fileId, href });
setActiveFileId(fileId);
}, []);
const showFile = useCallback((fileId: string) => setActiveFileId(fileId), []);
return (
<DocumentOpenContext.Provider value={openDocument}>
<div className="flex h-full min-h-0 overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">{children}</div>
<ReferencesRail references={references} activeFileId={activeFileId} onOpen={openDocument} />
</div>
{openDoc ? (
<SourceDocumentViewer
fileId={openDoc.fileId}
href={openDoc.href}
references={references}
onClose={() => {
setOpenDoc(null);
setActiveFileId(null);
}}
onActiveFile={showFile}
onOpenDocument={openDocument}
/>
) : null}
</DocumentOpenContext.Provider>
);
}
Loading
Loading