+ );
+ }
+
+ // A question carries no decision to fabricate, so it renders only what the
+ // request declared, even when that leaves nothing to act on here.
+ if (question) {
+ return (
+
+ );
+}
diff --git a/app/src/components/assistant-ui/follow-up-suggestions.test.tsx b/app/src/components/assistant-ui/follow-up-suggestions.test.tsx
new file mode 100644
index 0000000000..9f0bd1b09b
--- /dev/null
+++ b/app/src/components/assistant-ui/follow-up-suggestions.test.tsx
@@ -0,0 +1,74 @@
+import {
+ AppendMessage,
+ AssistantRuntimeProvider,
+ type ThreadMessageLike,
+ type ThreadSuggestion,
+ useExternalStoreRuntime,
+} from '@assistant-ui/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { ThreadFollowupSuggestions } from './follow-up-suggestions';
+
+const settled: ThreadMessageLike[] = [
+ { role: 'user', content: [{ type: 'text', text: 'What is on today?' }] },
+ { role: 'assistant', content: [{ type: 'text', text: 'Two meetings.' }] },
+];
+
+const SUGGESTIONS: ThreadSuggestion[] = [
+ { prompt: 'Move the second meeting to Friday', title: 'Reschedule' },
+ { prompt: 'Who is attending?' },
+];
+
+function Harness({
+ messages = settled,
+ isRunning = false,
+ onNew = async () => {},
+}: {
+ messages?: ThreadMessageLike[];
+ isRunning?: boolean;
+ onNew?: (m: AppendMessage) => Promise;
+}) {
+ const runtime = useExternalStoreRuntime({
+ messages,
+ isRunning,
+ suggestions: SUGGESTIONS,
+ convertMessage: (m: ThreadMessageLike) => m,
+ onNew,
+ });
+ return (
+
+
+
+ );
+}
+
+describe('ThreadFollowupSuggestions', () => {
+ it('renders one chip per suggestion, titled by its title or else its prompt', () => {
+ render();
+
+ expect(screen.getByRole('button', { name: 'Reschedule' })).toBeTruthy();
+ expect(screen.getByRole('button', { name: 'Who is attending?' })).toBeTruthy();
+ });
+
+ it('sends the chip prompt, not its title, on click', async () => {
+ const onNew = vi.fn(async (_m: AppendMessage) => {});
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Reschedule' }));
+
+ await waitFor(() => expect(onNew).toHaveBeenCalledTimes(1));
+ expect(onNew.mock.calls[0][0].content).toEqual([
+ { type: 'text', text: 'Move the second meeting to Friday' },
+ ]);
+ });
+
+ it('renders nothing while a turn runs or on an empty thread', () => {
+ const running = render();
+ expect(running.queryAllByRole('button')).toHaveLength(0);
+ running.unmount();
+
+ const empty = render();
+ expect(empty.queryAllByRole('button')).toHaveLength(0);
+ });
+});
diff --git a/app/src/components/assistant-ui/follow-up-suggestions.tsx b/app/src/components/assistant-ui/follow-up-suggestions.tsx
index 3746a11059..e89191f7df 100644
--- a/app/src/components/assistant-ui/follow-up-suggestions.tsx
+++ b/app/src/components/assistant-ui/follow-up-suggestions.tsx
@@ -1,5 +1,23 @@
'use client';
+/**
+ * assistant-ui's follow-up-suggestions element: a single scrollable row of
+ * chips under a settled turn, with edge fades once the row overflows. Each
+ * chip sends its prompt on click.
+ *
+ * Reads `s.thread.suggestions`, the same field as the welcome chips in
+ * `thread.tsx`. `useOpenHumanExternalStore` fills that field with the core's
+ * `chat_suggestions` set only after a settled turn, so the two never render
+ * together (see `useWelcomeSuggestions` there).
+ *
+ * Vendored from the assistant-ui `follow-up-suggestions` registry item
+ * (https://r.assistant-ui.com/styles/base-nova/follow-up-suggestions.json).
+ * Changes from upstream:
+ * - `window.getComputedStyle`, because the app's ESLint browser globals are
+ * hand-listed and a bare `getComputedStyle` fails `no-undef`.
+ *
+ * No user-facing strings: chip text is the suggestion's own `title`/`prompt`.
+ */
import { AuiIf, ThreadPrimitive, useAuiState } from '@assistant-ui/react';
import { type FC, useCallback, useEffect, useRef, useState } from 'react';
@@ -45,16 +63,15 @@ const FollowupSuggestionsRow: FC = () => {
ref={scrollRef}
onScroll={updateFades}
// overflow-x clips both axes; py-1/-my-1 gives focus rings vertical room without changing outer height.
- className="aui-thread-followup-suggestions -my-1 w-full overflow-x-auto py-1 [-ms-overflow-style:none] scrollbar-none [&::-webkit-scrollbar]:hidden"
+ className="aui-thread-followup-suggestions -my-1 w-full [scrollbar-width:none] overflow-x-auto py-1 [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
style={{ maskImage, WebkitMaskImage: maskImage }}>
{suggestions.map((suggestion, idx) => (
+ send>
{suggestion.title ?? suggestion.prompt}
{suggestion.label && (
diff --git a/app/src/components/assistant-ui/markdown-text.tsx b/app/src/components/assistant-ui/markdown-text.tsx
index 1252d1d1d9..e87fa238d6 100644
--- a/app/src/components/assistant-ui/markdown-text.tsx
+++ b/app/src/components/assistant-ui/markdown-text.tsx
@@ -2,7 +2,7 @@
import { cn } from '@/components/assistant-ui/lib/utils';
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button';
-import { useMessagePartText } from '@assistant-ui/react';
+import { type AssistantState, useAuiState, useMessagePartText } from '@assistant-ui/react';
import {
type CodeHeaderProps,
MarkdownTextPrimitive,
@@ -11,7 +11,16 @@ import {
} from '@assistant-ui/react-markdown';
import '@assistant-ui/react-markdown/styles/dot.css';
import { CheckIcon, CopyIcon } from 'lucide-react';
-import { type ComponentPropsWithoutRef, type FC, isValidElement, memo, useState } from 'react';
+import {
+ type ComponentPropsWithoutRef,
+ createContext,
+ type FC,
+ isValidElement,
+ memo,
+ useContext,
+ useMemo,
+ useState,
+} from 'react';
import rehypeHighlight from 'rehype-highlight';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
@@ -19,6 +28,52 @@ import remarkMath from 'remark-math';
import { hasLatexContent, normalizeLatexDelimiters } from '../../utils/latex';
import { extractLanguage, extractTextContent } from '../markdown/CodeBlock';
+import { CitationMarker, type CitationSource } from './elements/inline-citation';
+
+/**
+ * This message's `source` parts (`SourceGroupSlot` in `thread.tsx` reads the
+ * same parts for the disclosure under the answer), reduced to the vendored
+ * `inline-citation` element's `CitationSource` shape and made available to
+ * the `a` node override below — `defaultComponents` is a module-level,
+ * memoized map (`memoizeMarkdownComponents`), so a per-message value has to
+ * reach its components through context rather than a closure.
+ */
+const CitationSourcesContext = createContext([]);
+const EMPTY_MESSAGE_PARTS: AssistantState['message']['parts'] = [];
+
+function sourcePartsToCitations(parts: AssistantState['message']['parts']): CitationSource[] {
+ return parts.flatMap((part): CitationSource[] => {
+ if (part.type !== 'source') return [];
+ if (part.sourceType === 'url') {
+ let domain = part.url;
+ try {
+ domain = new URL(part.url).hostname.replace(/^www\./, '');
+ } catch {
+ // Keep the raw value; a malformed URL still names its own citation.
+ }
+ return [{ domain, title: part.title ?? domain, snippet: part.url }];
+ }
+ return [{ domain: 'memory', title: part.title ?? 'memory', snippet: part.title ?? '' }];
+ });
+}
+
+/**
+ * `[n]` / `[^n]` in the model's own text, for `n` within the message's
+ * source count, become a real markdown link to a `#citation-n` fragment —
+ * a relative ref, so react-markdown's default `urlTransform` allowlist
+ * (which blanks any URL scheme it does not recognize, e.g. a `citation:`
+ * one) leaves it alone. The `a` node override below recognizes that
+ * fragment shape and swaps in `CitationMarker` instead of an anchor.
+ * Everything else (an ordinary bracketed aside, a footnote number past
+ * the source list) is left alone.
+ */
+function linkifyCitationMarkers(text: string, sourceCount: number): string {
+ if (sourceCount === 0) return text;
+ return text.replace(/\[\^?(\d+)\]/g, (match, digits: string) => {
+ const n = Number.parseInt(digits, 10);
+ return n >= 1 && n <= sourceCount ? `[${digits}](#citation-${digits})` : match;
+ });
+}
/**
* Plugin sets, matched to `AgentMessageBubble`'s so the two markdown surfaces
@@ -46,19 +101,49 @@ const MarkdownTextImpl = () => {
// renders: the gate must not flip mid-reveal.
const { text } = useMessagePartText();
const hasMath = hasLatexContent(text);
+ // Some callers (e.g. a bare `TextMessagePartProvider` in tests, or a tool
+ // result rendered through `MarkdownText` outside a full message scope)
+ // provide a message-PART scope with no message-level `state.message` — the
+ // proxy throws reading it. No sources to linkify is the correct fallback,
+ // not a crash.
+ //
+ // The selector returns the raw `parts` array rather than a derived
+ // `CitationSource[]` on purpose: `useAuiState` runs this through
+ // `useSyncExternalStore`, which requires a snapshot-stable result — a fresh
+ // `.flatMap()` array on every call sends it into a render loop ("Maximum
+ // update depth exceeded"). Deriving `sources` in a `useMemo` below, keyed
+ // on this array's own identity, keeps the selector pure and the derived
+ // value stable across renders that don't change the underlying parts.
+ const parts = useAuiState(state => {
+ try {
+ return state.message.parts;
+ } catch {
+ return EMPTY_MESSAGE_PARTS;
+ }
+ });
+ const sources = useMemo(() => sourcePartsToCitations(parts), [parts]);
+
+ const preprocess = (input: string): string => {
+ const withCitations = linkifyCitationMarkers(input, sources.length);
+ return hasMath ? normalizeLatexDelimiters(withCitations) : withCitations;
+ };
return (
-
+
+
+
);
};
@@ -206,15 +291,24 @@ const defaultComponents = memoizeMarkdownComponents({
p: ({ className, ...props }) => (
),
- a: ({ className, ...props }) => (
-
- ),
+ a: function MarkdownLink({ className, href, children, ...props }) {
+ const sources = useContext(CitationSourcesContext);
+ const citationMatch = href?.match(/^#citation-(\d+)$/);
+ const citationIndex = citationMatch ? Number.parseInt(citationMatch[1], 10) - 1 : -1;
+ const source = citationIndex >= 0 ? sources[citationIndex] : undefined;
+ if (source) return ;
+ return (
+
+ {children}
+
+ );
+ },
blockquote: ({ className, ...props }) => (
[0]['components'] }) {
+ const messages: ThreadMessageLike[] = [];
+ const runtime = useExternalStoreRuntime({
+ messages,
+ convertMessage: (m: ThreadMessageLike) => m,
+ onNew: async () => {},
+ });
+ return (
+
+
+
+ );
+}
+
+function HostTriggers() {
+ const root = unstable_useTriggerPopoverRootContextOptional();
+ return ;
+}
+
+describe('thread composer triggers slot', () => {
+ it('mounts the host triggers inside the composer trigger-popover root', () => {
+ render();
+ expect(screen.getByTestId('host-triggers')).toHaveAttribute('data-in-root', 'yes');
+ });
+
+ it('renders nothing extra without the slot', () => {
+ render();
+ expect(screen.queryByTestId('host-triggers')).toBeNull();
+ });
+});
diff --git a/app/src/components/assistant-ui/thread.connectionState.test.tsx b/app/src/components/assistant-ui/thread.connectionState.test.tsx
new file mode 100644
index 0000000000..6eb9dd5719
--- /dev/null
+++ b/app/src/components/assistant-ui/thread.connectionState.test.tsx
@@ -0,0 +1,51 @@
+import {
+ AssistantRuntimeProvider,
+ type ThreadMessageLike,
+ useExternalStoreRuntime,
+} from '@assistant-ui/react';
+import { act, render, screen } from '@testing-library/react';
+import { Provider } from 'react-redux';
+import { describe, expect, it, vi } from 'vitest';
+
+import { setStatusForUser } from '../../store/socketSlice';
+import { createTestStore } from '../../test/test-utils';
+import { Thread } from './thread';
+
+vi.mock('../../services/socketService', () => ({ socketService: { connect: vi.fn() } }));
+
+/** The connection banner sits in the viewport footer, directly above the composer. */
+function Harness() {
+ const messages: ThreadMessageLike[] = [];
+ const runtime = useExternalStoreRuntime({
+ messages,
+ convertMessage: (m: ThreadMessageLike) => m,
+ onNew: async () => {},
+ });
+ return (
+
+
+
+ );
+}
+
+describe('thread connection-state banner', () => {
+ it('renders above the composer once the socket drops', () => {
+ const store = createTestStore();
+ store.dispatch(setStatusForUser({ userId: '__pending__', status: 'connected' }));
+ const { container } = render(
+
+
+
+ );
+ expect(screen.queryByTestId('connection-state-banner')).toBeNull();
+
+ act(() => {
+ store.dispatch(setStatusForUser({ userId: '__pending__', status: 'disconnected' }));
+ });
+
+ const banner = screen.getByTestId('connection-state-banner');
+ const composer = container.querySelector('.aui-composer-root');
+ expect(composer).not.toBeNull();
+ expect(banner.compareDocumentPosition(composer as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
+ });
+});
diff --git a/app/src/components/assistant-ui/thread.tsx b/app/src/components/assistant-ui/thread.tsx
index 0252237a61..e3637fbeec 100644
--- a/app/src/components/assistant-ui/thread.tsx
+++ b/app/src/components/assistant-ui/thread.tsx
@@ -8,23 +8,31 @@ import {
} from '@/components/assistant-ui/attachment';
import { ComposerTriggerPopover } from '@/components/assistant-ui/composer-trigger-popover';
import { DirectiveText } from '@/components/assistant-ui/directive-text';
+import { EditMessage } from '@/components/assistant-ui/elements/edit-message';
+import { ErrorState } from '@/components/assistant-ui/elements/error-state';
+import { Image } from '@/components/assistant-ui/elements/image';
+import { MessageTiming } from '@/components/assistant-ui/elements/message-timing.aui';
+import { StoppedRun } from '@/components/assistant-ui/elements/stopped-run';
+import { ToolFallback } from '@/components/assistant-ui/elements/tool-fallback';
import { File } from '@/components/assistant-ui/file';
import { ThreadFollowupSuggestions } from '@/components/assistant-ui/follow-up-suggestions';
-import { Image } from '@/components/assistant-ui/image';
import { cn } from '@/components/assistant-ui/lib/utils';
import { MarkdownText } from '@/components/assistant-ui/markdown-text';
import { ComposerQuotePreview, SelectionToolbar } from '@/components/assistant-ui/quote';
import { Reasoning } from '@/components/assistant-ui/reasoning';
-import { ToolFallback } from '@/components/assistant-ui/tool-fallback';
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button';
import { Button } from '@/components/assistant-ui/ui/button';
import { Skeleton } from '@/components/assistant-ui/ui/skeleton';
-import ModelQualityPill from '@/components/chat/ModelQualityPill';
+import { ChatErrorNotice } from '@/features/conversations/aui/ChatErrorNotice';
+import { ChatSettingsPanel } from '@/features/conversations/aui/ChatSettingsPanel';
+import { ConnectionStateBanner } from '@/features/conversations/aui/ConnectionStateBanner';
import {
useAuiEditCapabilities,
useAuiReloadCapability,
} from '@/features/conversations/components/aui/auiThreadState';
+import { useT } from '@/lib/i18n/I18nContext';
import { useAuiThreadId } from '@/providers/AssistantUiRuntimeProvider';
+import { useActionBarReload, useMessageError } from '@assistant-ui/core/react';
import {
ActionBarMorePrimitive,
ActionBarPrimitive,
@@ -32,7 +40,6 @@ import {
AuiIf,
BranchPickerPrimitive,
ComposerPrimitive,
- ErrorPrimitive,
type FileMessagePartComponent,
groupPartByType,
type ImageMessagePartComponent,
@@ -76,6 +83,7 @@ import {
useContext,
useEffect,
useLayoutEffect,
+ useMemo,
useRef,
useState,
} from 'react';
@@ -98,8 +106,12 @@ export type ThreadComponents = {
* and the answer — as a single group. Defaults to `ActivityGroup`.
*/
ActivityGroup?: ComponentType> | undefined;
- /** Host-owned disclosure for the URL source parts emitted after an answer. */
- SourceGroup?: ComponentType<{ sources: readonly SourceUrlPart[] }> | undefined;
+ /**
+ * Host-owned disclosure for the source parts emitted after an answer:
+ * `url` sources (web fetch/search) and `document` sources (memory
+ * citations, `sourceType: 'document'`).
+ */
+ SourceGroup?: ComponentType<{ sources: readonly SourceItemPart[] }> | undefined;
/**
* Extra controls in the composer's action row, to the right of the model
* selector. A seam rather than a fixed set because what belongs there is
@@ -165,6 +177,13 @@ export type ThreadComponents = {
* builder turns rather than chat turns.
*/
Composer?: ComponentType | undefined;
+ /**
+ * Host-owned trigger pickers (`/` commands, `@` mentions), mounted inside the
+ * composer's `Unstable_TriggerPopoverRoot` in place of the built-in `/`
+ * popover fed by `slashCommands`. A component for the same reason the other
+ * slots are: its sources are host behaviour this file should not learn.
+ */
+ ComposerTriggers?: ComponentType | undefined;
};
export type ThreadProps = {
@@ -438,6 +457,7 @@ const ThreadRoot: FC<{
)}>
+
{HostComposer ? (
) : (
@@ -892,6 +912,7 @@ const Composer: FC<{
const {
ComposerHeader,
ComposerAttachments: HostComposerAttachments,
+ ComposerTriggers: HostComposerTriggers,
onComposerFiles,
canAcceptComposerFiles,
} = useContext(ThreadComponentsContext);
@@ -1109,8 +1130,12 @@ const Composer: FC<{
@@ -1533,6 +1658,12 @@ const AssistantActionBar: FC = () => {
+ {/*
+ * Renders nothing until the stream completes and `chat_done.timing`
+ * lands on `message.metadata.timing` (`assistantUiMessages.ts`); see
+ * that element's own docstring for why it belongs inside this root.
+ */}
+
);
};
@@ -1625,27 +1756,48 @@ const UserActionBar: FC = () => {
);
};
+/**
+ * How many later turns editing this message would discard — `onEdit`
+ * (`useOpenHumanExternalStore.ts`) truncates the thread's single lineage from
+ * this message on, exactly like `onReload`, so every message after it (not
+ * just its direct reply) is what a Send here throws away. `s.message.index`
+ * is the position `MessageState` already tracks;
+ * `s.thread.messages.length - 1 - index` is everything after it. Kept as its
+ * own primitive-returning selector (a plain number), never combined with
+ * `value` below into one object literal — `useAuiState`'s selector is
+ * compared by `Object.is`, so an object literal differs from itself on every
+ * store tick and free-runs the subscription (the "Maximum update depth
+ * exceeded" loop this file hit once already).
+ */
+const selectDiscardedReplies = (s: AssistantState): number =>
+ Math.max(0, s.thread.messages.length - 1 - s.message.index);
+
const EditComposer: FC = () => {
+ const aui = useAui();
+ const { t } = useT();
+ const value = useAuiState(s => s.composer.text);
+ const discardedReplies = useAuiState(selectDiscardedReplies);
return (
-
-
-
-
-
- Cancel
-
-
-
-
- Update
-
-
-
-
+ aui.message.composer().setText(text)}
+ onSave={() => aui.message.composer().send()}
+ onCancel={() => aui.message.composer().cancel()}
+ cancelLabel={t('common.cancel')}
+ sendLabel={t('chat.elicitation.send')}
+ editAriaLabel={t('conversations.assistantUi.edit.ariaLabel')}
+ discardedRepliesText={count =>
+ t(
+ count === 1
+ ? 'conversations.assistantUi.edit.discardedRepliesOne'
+ : 'conversations.assistantUi.edit.discardedRepliesOther'
+ ).replace('{count}', String(count))
+ }
+ />
);
};
diff --git a/app/src/components/assistant-ui/utils/task.ts b/app/src/components/assistant-ui/utils/task.ts
new file mode 100644
index 0000000000..809a69cd0c
--- /dev/null
+++ b/app/src/components/assistant-ui/utils/task.ts
@@ -0,0 +1,78 @@
+'use client';
+
+/**
+ * Vendored verbatim from the assistant-ui `task-card` registry item's shared
+ * util (https://r.assistant-ui.com/styles/base-nova/task-card.json,
+ * `utils/task.ts` upstream). No local changes.
+ */
+import type { ToolCallMessagePart, ToolCallMessagePartStatus } from '@assistant-ui/react';
+import { useEffect, useState } from 'react';
+
+export type TaskViewState = 'working' | 'waiting' | 'done' | 'failed' | 'cancelled';
+
+export type TaskTiming = NonNullable;
+
+export const TASK_PAGE_SIZE = 4;
+
+const LABEL_KEYS = ['description', 'task', 'title', 'name', 'prompt', 'query', 'instructions'];
+
+const META_KEYS = ['subagent_type', 'subagentType', 'agent', 'model'];
+
+function firstString(args: unknown, keys: readonly string[]) {
+ if (typeof args !== 'object' || args === null) return undefined;
+ const record = args as Record;
+ for (const key of keys) {
+ const value = record[key];
+ if (typeof value === 'string' && value.trim() !== '') return value.trim();
+ }
+ return undefined;
+}
+
+export function taskStateOf(status: ToolCallMessagePartStatus, isError?: boolean): TaskViewState {
+ if (status.type === 'running') return 'working';
+ if (status.type === 'requires-action') return 'waiting';
+ if (status.type === 'incomplete') {
+ return status.reason === 'cancelled' ? 'cancelled' : 'failed';
+ }
+ if (isError) return 'failed';
+ return 'done';
+}
+
+export function taskLabel(toolName: string, args: unknown) {
+ return firstString(args, LABEL_KEYS) ?? toolName;
+}
+
+export function taskMeta(args: unknown) {
+ return firstString(args, META_KEYS);
+}
+
+export function formatElapsed(ms: number) {
+ if (ms < 1000) return '<1s';
+ const seconds = ms / 1000;
+ if (seconds < 10) return `${(Math.floor(seconds * 10) / 10).toFixed(1)}s`;
+ if (seconds < 60) return `${Math.floor(seconds)}s`;
+ return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`;
+}
+
+export function useTaskElapsed(timing: TaskTiming | undefined, running: boolean) {
+ const ticking = timing !== undefined && timing.completedAt === undefined && running;
+ const [now, setNow] = useState(() => Date.now());
+ const [wasTicking, setWasTicking] = useState(ticking);
+ if (wasTicking !== ticking) {
+ setWasTicking(ticking);
+ if (ticking) setNow(Date.now());
+ }
+
+ useEffect(() => {
+ if (!ticking) return undefined;
+ const id = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(id);
+ }, [ticking]);
+
+ if (timing === undefined) return undefined;
+ if (timing.completedAt !== undefined) {
+ return Math.max(0, timing.completedAt - timing.startedAt);
+ }
+ if (!ticking) return undefined;
+ return Math.max(0, now - timing.startedAt);
+}
diff --git a/app/src/components/chat/ApprovalRequestCard.tsx b/app/src/components/chat/ApprovalRequestCard.tsx
deleted file mode 100644
index b10cafa12a..0000000000
--- a/app/src/components/chat/ApprovalRequestCard.tsx
+++ /dev/null
@@ -1,129 +0,0 @@
-import debug from 'debug';
-import React, { useState } from 'react';
-
-import { useT } from '../../lib/i18n/I18nContext';
-import { callCoreRpc } from '../../services/coreRpcClient';
-import { clearPendingApprovalForThread, type PendingApproval } from '../../store/chatRuntimeSlice';
-import { useAppDispatch } from '../../store/hooks';
-import Button from '../ui/Button';
-
-/**
- * Decision surface for a parked tool call. `approve_once` / `deny` decide the
- * current call only; `approve_always_for_tool` additionally persists the tool
- * onto the user's `autonomy.auto_approve` ("Always allow") list so the gate
- * skips prompting for it on future turns (managed/removable in Settings → Agent
- * access). A typed `yes`/`no` chat reply is the equivalent server-side path for
- * the once/deny decisions.
- */
-const log = debug('openhuman:chat:approval-card');
-
-type Decision = 'approve_once' | 'approve_always_for_tool' | 'deny';
-
-interface Props {
- threadId: string;
- approval: PendingApproval;
-}
-
-/**
- * Surfaces a `Prompt`-class tool call parked on the ApprovalGate
- * (`approval_request` socket event) and routes the user's Approve / Deny to the
- * `openhuman.approval_decide` RPC. Rendered above the composer for the active
- * thread; clears itself on a recorded decision (the turn-end handlers in
- * {@link ChatRuntimeProvider} also clear it if the turn is cancelled).
- */
-const ApprovalRequestCard: React.FC = ({ threadId, approval }) => {
- const { t } = useT();
- const dispatch = useAppDispatch();
- const [deciding, setDeciding] = useState(null);
- const [errorMsg, setErrorMsg] = useState(null);
-
- const decide = async (decision: Decision) => {
- if (deciding) return;
- setDeciding(decision);
- setErrorMsg(null);
- try {
- await callCoreRpc({
- method: 'openhuman.approval_decide',
- params: { request_id: approval.requestId, decision },
- });
- // Resolve optimistically; ChatRuntimeProvider also clears on turn end.
- dispatch(clearPendingApprovalForThread({ threadId }));
- } catch (e) {
- // Keep raw RPC error detail in namespaced dev logs only; show the user the
- // localized fallback — never leak internal error text into the UI.
- log('approval_decide failed: %o', e);
- setErrorMsg(t('chat.approval.error'));
- setDeciding(null);
- }
- };
-
- return (
-