feat: manual approvals (#748)
* manual user approvals * refactor implementation * cleanup * fix lint errors * i18n * remove isLastEntry frontend check * address fe feedback * always run claude plan with approvals * add watchkill script back to plan mode * update timeout * tooltip hover * use response type * put back watchkill append hack
This commit is contained in:
committed by
GitHub
parent
eaff3dee9e
commit
798bcb80a3
@@ -1,8 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MarkdownRenderer from '@/components/ui/markdown-renderer.tsx';
|
||||
import {
|
||||
ActionType,
|
||||
NormalizedEntry,
|
||||
TaskAttempt,
|
||||
ToolStatus,
|
||||
type NormalizedEntryType,
|
||||
} from 'shared/types.ts';
|
||||
import type { ProcessStartPayload } from '@/types/logs';
|
||||
@@ -27,6 +29,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import RawLogText from '../common/RawLogText';
|
||||
import UserMessage from './UserMessage';
|
||||
import PendingApprovalEntry from './PendingApprovalEntry';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Props = {
|
||||
entry: NormalizedEntry | ProcessStartPayload;
|
||||
@@ -304,38 +308,101 @@ const CollapsibleEntry: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
type ToolStatusAppearance = 'default' | 'denied' | 'timed_out';
|
||||
|
||||
const PLAN_APPEARANCE: Record<
|
||||
ToolStatusAppearance,
|
||||
{
|
||||
border: string;
|
||||
headerBg: string;
|
||||
headerText: string;
|
||||
contentBg: string;
|
||||
contentText: string;
|
||||
}
|
||||
> = {
|
||||
default: {
|
||||
border: 'border-blue-400/40',
|
||||
headerBg: 'bg-blue-50 dark:bg-blue-950/20',
|
||||
headerText: 'text-blue-700 dark:text-blue-300',
|
||||
contentBg: 'bg-blue-50 dark:bg-blue-950/20',
|
||||
contentText: 'text-blue-700 dark:text-blue-300',
|
||||
},
|
||||
denied: {
|
||||
border: 'border-red-400/40',
|
||||
headerBg: 'bg-red-50 dark:bg-red-950/20',
|
||||
headerText: 'text-red-700 dark:text-red-300',
|
||||
contentBg: 'bg-red-50 dark:bg-red-950/10',
|
||||
contentText: 'text-red-700 dark:text-red-300',
|
||||
},
|
||||
timed_out: {
|
||||
border: 'border-amber-400/40',
|
||||
headerBg: 'bg-amber-50 dark:bg-amber-950/20',
|
||||
headerText: 'text-amber-700 dark:text-amber-200',
|
||||
contentBg: 'bg-amber-50 dark:bg-amber-950/10',
|
||||
contentText: 'text-amber-700 dark:text-amber-200',
|
||||
},
|
||||
};
|
||||
|
||||
const PlanPresentationCard: React.FC<{
|
||||
plan: string;
|
||||
expansionKey: string;
|
||||
}> = ({ plan, expansionKey }) => {
|
||||
const [expanded, toggle] = useExpandable(`plan-entry:${expansionKey}`, true);
|
||||
defaultExpanded?: boolean;
|
||||
statusAppearance?: ToolStatusAppearance;
|
||||
}> = ({
|
||||
plan,
|
||||
expansionKey,
|
||||
defaultExpanded = false,
|
||||
statusAppearance = 'default',
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
const [expanded, toggle] = useExpandable(
|
||||
`plan-entry:${expansionKey}`,
|
||||
defaultExpanded
|
||||
);
|
||||
const tone = PLAN_APPEARANCE[statusAppearance];
|
||||
|
||||
return (
|
||||
<div className="inline-block w-full">
|
||||
<div className="border w-full overflow-hidden border-blue-400/40">
|
||||
<div
|
||||
className={cn('border w-full overflow-hidden rounded-sm', tone.border)}
|
||||
>
|
||||
<button
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}}
|
||||
title={expanded ? 'Hide plan' : 'Show plan'}
|
||||
className="w-full px-2 py-1.5 flex items-center gap-1.5 text-left bg-blue-50 dark:bg-blue-950/20 text-blue-700 dark:text-blue-300 border-b border-blue-400/40"
|
||||
title={
|
||||
expanded
|
||||
? t('conversation.planToggle.hide')
|
||||
: t('conversation.planToggle.show')
|
||||
}
|
||||
className={cn(
|
||||
'w-full px-2 py-1.5 flex items-center gap-1.5 text-left border-b',
|
||||
tone.headerBg,
|
||||
tone.headerText,
|
||||
tone.border
|
||||
)}
|
||||
>
|
||||
<span className=" min-w-0 truncate">
|
||||
<span className="font-semibold">Plan</span>
|
||||
<span className="font-semibold">{t('conversation.plan')}</span>
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ExpandChevron
|
||||
expanded={expanded}
|
||||
onClick={toggle}
|
||||
variant="system"
|
||||
variant={statusAppearance === 'denied' ? 'error' : 'system'}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-3 py-2 max-h-[65vh] overflow-y-auto overscroll-contain bg-blue-50 dark:bg-blue-950/20">
|
||||
<div className=" text-blue-700 dark:text-blue-300">
|
||||
<div
|
||||
className={cn(
|
||||
'px-3 py-2 max-h-[65vh] overflow-y-auto overscroll-contain',
|
||||
tone.contentBg
|
||||
)}
|
||||
>
|
||||
<div className={cn('text-sm', tone.contentText)}>
|
||||
<MarkdownRenderer
|
||||
content={plan}
|
||||
className="whitespace-pre-wrap break-words"
|
||||
@@ -355,9 +422,26 @@ const ToolCallCard: React.FC<{
|
||||
expansionKey: string;
|
||||
content?: string;
|
||||
entryContent?: string;
|
||||
}> = ({ entryType, action, expansionKey, content, entryContent }) => {
|
||||
highlighted?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
statusAppearance?: ToolStatusAppearance;
|
||||
forceExpanded?: boolean;
|
||||
}> = ({
|
||||
entryType,
|
||||
action,
|
||||
expansionKey,
|
||||
content,
|
||||
entryContent,
|
||||
defaultExpanded = false,
|
||||
forceExpanded = false,
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
const at: any = entryType?.action_type || action;
|
||||
const [expanded, toggle] = useExpandable(`tool-entry:${expansionKey}`, false);
|
||||
const [expanded, toggle] = useExpandable(
|
||||
`tool-entry:${expansionKey}`,
|
||||
defaultExpanded
|
||||
);
|
||||
const effectiveExpanded = forceExpanded || expanded;
|
||||
|
||||
const label =
|
||||
at?.action === 'command_run'
|
||||
@@ -400,16 +484,18 @@ const ToolCallCard: React.FC<{
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
},
|
||||
title: expanded ? 'Hide details' : 'Show details',
|
||||
title: effectiveExpanded
|
||||
? t('conversation.toolDetailsToggle.hide')
|
||||
: t('conversation.toolDetailsToggle.show'),
|
||||
}
|
||||
: {};
|
||||
|
||||
const headerClassName = cn(
|
||||
'w-full flex items-center gap-1.5 text-left text-secondary-foreground'
|
||||
);
|
||||
return (
|
||||
<div className="inline-block w-full flex flex-col gap-4">
|
||||
<HeaderWrapper
|
||||
{...headerProps}
|
||||
className="w-full flex items-center gap-1.5 text-left text-secondary-foreground"
|
||||
>
|
||||
<div className="inline-block w-full flex flex-col gap-4">
|
||||
<HeaderWrapper {...headerProps} className={headerClassName}>
|
||||
<span className=" min-w-0 flex items-center gap-1.5">
|
||||
{entryType ? (
|
||||
<span>
|
||||
@@ -425,14 +511,14 @@ const ToolCallCard: React.FC<{
|
||||
</span>
|
||||
</HeaderWrapper>
|
||||
|
||||
{expanded && (
|
||||
{effectiveExpanded && (
|
||||
<div className="max-h-[200px] overflow-y-auto border">
|
||||
{isCommand ? (
|
||||
<>
|
||||
{argsText && (
|
||||
<>
|
||||
<div className="font-normal uppercase bg-background border-b border-dashed px-2 py-1">
|
||||
Args
|
||||
{t('conversation.args')}
|
||||
</div>
|
||||
<div className="px-2 py-1">{argsText}</div>
|
||||
</>
|
||||
@@ -441,7 +527,7 @@ const ToolCallCard: React.FC<{
|
||||
{output && (
|
||||
<>
|
||||
<div className="font-normal uppercase bg-background border-y border-dashed px-2 py-1">
|
||||
Output
|
||||
{t('conversation.output')}
|
||||
</div>
|
||||
<div className="px-2 py-1">
|
||||
<RawLogText content={output} />
|
||||
@@ -454,13 +540,13 @@ const ToolCallCard: React.FC<{
|
||||
{entryType?.action_type.action === 'tool' && (
|
||||
<>
|
||||
<div className="font-normal uppercase bg-background border-b border-dashed px-2 py-1">
|
||||
Args
|
||||
{t('conversation.args')}
|
||||
</div>
|
||||
<div className="px-2 py-1">
|
||||
{renderJson(entryType.action_type.arguments)}
|
||||
</div>
|
||||
<div className="font-normal uppercase bg-background border-y border-dashed px-2 py-1">
|
||||
Result
|
||||
{t('conversation.result')}
|
||||
</div>
|
||||
<div className="px-2 py-1">
|
||||
{entryType.action_type.result?.type.type === 'markdown' &&
|
||||
@@ -493,6 +579,17 @@ const LoadingCard = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const isPendingApprovalStatus = (
|
||||
status: ToolStatus
|
||||
): status is Extract<ToolStatus, { status: 'pending_approval' }> =>
|
||||
status.status === 'pending_approval';
|
||||
|
||||
const getToolStatusAppearance = (status: ToolStatus): ToolStatusAppearance => {
|
||||
if (status.status === 'denied') return 'denied';
|
||||
if (status.status === 'timed_out') return 'timed_out';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
/*******************
|
||||
* Main component *
|
||||
*******************/
|
||||
@@ -541,10 +638,85 @@ function DisplayConversationEntry({
|
||||
/>
|
||||
);
|
||||
}
|
||||
const renderToolUse = () => {
|
||||
if (!isNormalizedEntry(entry)) return null;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2 text-sm">
|
||||
{isSystem || isError ? (
|
||||
if (entryType.type !== 'tool_use') return null;
|
||||
const toolEntry = entryType;
|
||||
|
||||
const status = toolEntry.status;
|
||||
const statusAppearance = getToolStatusAppearance(status);
|
||||
const isPlanPresentation =
|
||||
toolEntry.action_type.action === 'plan_presentation';
|
||||
const isPendingApproval = status.status === 'pending_approval';
|
||||
const defaultExpanded = isPendingApproval || isPlanPresentation;
|
||||
|
||||
const body = (() => {
|
||||
if (isFileEdit(toolEntry.action_type)) {
|
||||
const fileEditAction = toolEntry.action_type as FileEditAction;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{fileEditAction.changes.map((change, idx) => (
|
||||
<FileChangeRenderer
|
||||
key={idx}
|
||||
path={fileEditAction.path}
|
||||
change={change}
|
||||
expansionKey={`edit:${expansionKey}:${idx}`}
|
||||
defaultExpanded={defaultExpanded}
|
||||
statusAppearance={statusAppearance}
|
||||
forceExpanded={isPendingApproval}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolEntry.action_type.action === 'plan_presentation') {
|
||||
return (
|
||||
<PlanPresentationCard
|
||||
plan={toolEntry.action_type.plan}
|
||||
expansionKey={expansionKey}
|
||||
defaultExpanded={defaultExpanded}
|
||||
statusAppearance={statusAppearance}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolCallCard
|
||||
entryType={toolEntry}
|
||||
expansionKey={expansionKey}
|
||||
entryContent={entry.content}
|
||||
defaultExpanded={defaultExpanded}
|
||||
statusAppearance={statusAppearance}
|
||||
forceExpanded={isPendingApproval}
|
||||
/>
|
||||
);
|
||||
})();
|
||||
|
||||
const content = <div className="px-4 py-2 text-sm space-y-3">{body}</div>;
|
||||
|
||||
if (isPendingApprovalStatus(status)) {
|
||||
return (
|
||||
<PendingApprovalEntry
|
||||
pendingStatus={status}
|
||||
executionProcessId={executionProcessId}
|
||||
>
|
||||
{content}
|
||||
</PendingApprovalEntry>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
if (isToolUse) {
|
||||
return renderToolUse();
|
||||
}
|
||||
|
||||
if (isSystem || isError) {
|
||||
return (
|
||||
<div className="px-4 py-2 text-sm">
|
||||
<CollapsibleEntry
|
||||
content={isNormalizedEntry(entry) ? entry.content : ''}
|
||||
markdown={shouldRenderMarkdown(entryType)}
|
||||
@@ -552,47 +724,33 @@ function DisplayConversationEntry({
|
||||
variant={isSystem ? 'system' : 'error'}
|
||||
contentClassName={getContentClassName(entryType)}
|
||||
/>
|
||||
) : isToolUse && isFileEdit(entryType.action_type) ? (
|
||||
// Only FileChangeRenderer for file_edit
|
||||
(() => {
|
||||
const fileEditAction = entryType.action_type as FileEditAction;
|
||||
return fileEditAction.changes.map((change, idx) => (
|
||||
<FileChangeRenderer
|
||||
key={idx}
|
||||
path={fileEditAction.path}
|
||||
change={change}
|
||||
expansionKey={`edit:${expansionKey}:${idx}`}
|
||||
/>
|
||||
));
|
||||
})()
|
||||
) : isToolUse && entryType.action_type.action === 'plan_presentation' ? (
|
||||
<PlanPresentationCard
|
||||
plan={entryType.action_type.plan}
|
||||
expansionKey={expansionKey}
|
||||
/>
|
||||
) : isToolUse ? (
|
||||
<ToolCallCard
|
||||
entryType={entryType}
|
||||
expansionKey={expansionKey}
|
||||
entryContent={isNormalizedEntry(entry) ? entry.content : ''}
|
||||
/>
|
||||
) : isLoading ? (
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-4 py-2 text-sm">
|
||||
<LoadingCard />
|
||||
) : (
|
||||
<div className={getContentClassName(entryType)}>
|
||||
{shouldRenderMarkdown(entryType) ? (
|
||||
<MarkdownRenderer
|
||||
content={isNormalizedEntry(entry) ? entry.content : ''}
|
||||
className="whitespace-pre-wrap break-words flex flex-col gap-1 font-light"
|
||||
enableCopyButton={entryType.type === 'assistant_message'}
|
||||
/>
|
||||
) : isNormalizedEntry(entry) ? (
|
||||
entry.content
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2 text-sm">
|
||||
<div className={getContentClassName(entryType)}>
|
||||
{shouldRenderMarkdown(entryType) ? (
|
||||
<MarkdownRenderer
|
||||
content={isNormalizedEntry(entry) ? entry.content : ''}
|
||||
className="whitespace-pre-wrap break-words flex flex-col gap-1 font-light"
|
||||
enableCopyButton={entryType.type === 'assistant_message'}
|
||||
/>
|
||||
) : isNormalizedEntry(entry) ? (
|
||||
entry.content
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,12 +13,16 @@ import '@/styles/diff-style-overrides.css';
|
||||
import '@/styles/edit-diff-overrides.css';
|
||||
import { useDiffViewMode } from '@/stores/useDiffViewStore';
|
||||
import DiffViewSwitch from '@/components/diff-view-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Props = {
|
||||
path: string;
|
||||
unifiedDiff: string;
|
||||
hasLineNumbers: boolean;
|
||||
expansionKey: string;
|
||||
defaultExpanded?: boolean;
|
||||
statusAppearance?: 'default' | 'denied' | 'timed_out';
|
||||
forceExpanded?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -64,9 +68,13 @@ function EditDiffRenderer({
|
||||
unifiedDiff,
|
||||
hasLineNumbers,
|
||||
expansionKey,
|
||||
defaultExpanded = false,
|
||||
statusAppearance = 'default',
|
||||
forceExpanded = false,
|
||||
}: Props) {
|
||||
const { config } = useUserSystem();
|
||||
const [expanded, setExpanded] = useExpandable(expansionKey, false);
|
||||
const [expanded, setExpanded] = useExpandable(expansionKey, defaultExpanded);
|
||||
const effectiveExpanded = forceExpanded || expanded;
|
||||
|
||||
const theme = getActualTheme(config?.theme);
|
||||
const globalMode = useDiffViewMode();
|
||||
@@ -87,9 +95,15 @@ function EditDiffRenderer({
|
||||
};
|
||||
}, [hunks, path]);
|
||||
|
||||
const headerClass = cn(
|
||||
'flex items-center gap-1.5 text-secondary-foreground',
|
||||
statusAppearance === 'denied' && 'text-red-700 dark:text-red-300',
|
||||
statusAppearance === 'timed_out' && 'text-amber-700 dark:text-amber-200'
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center text-secondary-foreground gap-1.5">
|
||||
<div className={headerClass}>
|
||||
<SquarePen className="h-3 w-3" />
|
||||
<p
|
||||
onClick={() => setExpanded()}
|
||||
@@ -105,7 +119,7 @@ function EditDiffRenderer({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
{effectiveExpanded && (
|
||||
<div className={'mt-2 border ' + hideLineNumbersClass}>
|
||||
<div className="flex items-center justify-end border-b px-2 py-1">
|
||||
<DiffViewSwitch />
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { type FileChange } from 'shared/types';
|
||||
import { useUserSystem } from '@/components/config-provider';
|
||||
import { Trash2, FilePlus2, ArrowRight } from 'lucide-react';
|
||||
import { Trash2, FilePlus2, ArrowRight, FileX, FileClock } from 'lucide-react';
|
||||
import { getHighLightLanguageFromPath } from '@/utils/extToLanguage';
|
||||
import { getActualTheme } from '@/utils/theme';
|
||||
import EditDiffRenderer from './EditDiffRenderer';
|
||||
import FileContentView from './FileContentView';
|
||||
import '@/styles/diff-style-overrides.css';
|
||||
import { useExpandable } from '@/stores/useExpandableStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Props = {
|
||||
path: string;
|
||||
change: FileChange;
|
||||
expansionKey: string;
|
||||
defaultExpanded?: boolean;
|
||||
statusAppearance?: 'default' | 'denied' | 'timed_out';
|
||||
forceExpanded?: boolean;
|
||||
};
|
||||
|
||||
function isWrite(
|
||||
@@ -35,11 +39,38 @@ function isEdit(
|
||||
return change?.action === 'edit';
|
||||
}
|
||||
|
||||
const FileChangeRenderer = ({ path, change, expansionKey }: Props) => {
|
||||
const FileChangeRenderer = ({
|
||||
path,
|
||||
change,
|
||||
expansionKey,
|
||||
defaultExpanded = false,
|
||||
statusAppearance = 'default',
|
||||
forceExpanded = false,
|
||||
}: Props) => {
|
||||
const { config } = useUserSystem();
|
||||
const [expanded, setExpanded] = useExpandable(expansionKey, false);
|
||||
const [expanded, setExpanded] = useExpandable(expansionKey, defaultExpanded);
|
||||
const effectiveExpanded = forceExpanded || expanded;
|
||||
|
||||
const theme = getActualTheme(config?.theme);
|
||||
const headerClass = cn('flex items-center gap-1.5 text-secondary-foreground');
|
||||
|
||||
const statusIcon =
|
||||
statusAppearance === 'denied' ? (
|
||||
<FileX className="h-3 w-3" />
|
||||
) : statusAppearance === 'timed_out' ? (
|
||||
<FileClock className="h-3 w-3" />
|
||||
) : null;
|
||||
|
||||
if (statusIcon) {
|
||||
return (
|
||||
<div>
|
||||
<div className={headerClass}>
|
||||
{statusIcon}
|
||||
<p className="text-sm font-light overflow-x-auto flex-1">{path}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Edit: delegate to EditDiffRenderer for identical styling and behavior
|
||||
if (isEdit(change)) {
|
||||
@@ -49,6 +80,9 @@ const FileChangeRenderer = ({ path, change, expansionKey }: Props) => {
|
||||
unifiedDiff={change.unified_diff}
|
||||
hasLineNumbers={change.has_line_numbers}
|
||||
expansionKey={expansionKey}
|
||||
defaultExpanded={defaultExpanded}
|
||||
statusAppearance={statusAppearance}
|
||||
forceExpanded={forceExpanded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -98,7 +132,7 @@ const FileChangeRenderer = ({ path, change, expansionKey }: Props) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center text-secondary-foreground gap-1.5">
|
||||
<div className={headerClass}>
|
||||
{icon}
|
||||
<p
|
||||
onClick={() => expandable && setExpanded()}
|
||||
@@ -109,7 +143,7 @@ const FileChangeRenderer = ({ path, change, expansionKey }: Props) => {
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{isWrite(change) && expanded && (
|
||||
{isWrite(change) && effectiveExpanded && (
|
||||
<FileContentView
|
||||
content={change.content}
|
||||
lang={getHighLightLanguageFromPath(path)}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ApprovalStatus, ToolStatus } from 'shared/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { CircularProgress } from '@/components/ui/circular-progress';
|
||||
import { approvalsApi } from '@/lib/api';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
const DEFAULT_DENIAL_REASON = 'User denied this tool use request.';
|
||||
|
||||
interface PendingApprovalEntryProps {
|
||||
pendingStatus: Extract<ToolStatus, { status: 'pending_approval' }>;
|
||||
executionProcessId?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function formatSeconds(s: number) {
|
||||
if (s <= 0) return '0s';
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
return m > 0 ? `${m}m ${rem}s` : `${rem}s`;
|
||||
}
|
||||
|
||||
const PendingApprovalEntry = ({
|
||||
pendingStatus,
|
||||
executionProcessId,
|
||||
children,
|
||||
}: PendingApprovalEntryProps) => {
|
||||
const [timeLeft, setTimeLeft] = useState<number>(() => {
|
||||
const remaining = new Date(pendingStatus.timeout_at).getTime() - Date.now();
|
||||
return Math.max(0, Math.floor(remaining / 1000));
|
||||
});
|
||||
const [isResponding, setIsResponding] = useState(false);
|
||||
const [hasResponded, setHasResponded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEnteringReason, setIsEnteringReason] = useState(false);
|
||||
const [denyReason, setDenyReason] = useState('');
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const denyReasonRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const percent = useMemo(() => {
|
||||
const total = Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
(new Date(pendingStatus.timeout_at).getTime() -
|
||||
new Date(pendingStatus.requested_at).getTime()) /
|
||||
1000
|
||||
)
|
||||
);
|
||||
return Math.max(0, Math.min(100, Math.round((timeLeft / total) * 100)));
|
||||
}, [pendingStatus.requested_at, pendingStatus.timeout_at, timeLeft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasResponded) return;
|
||||
|
||||
const id = window.setInterval(() => {
|
||||
const remaining =
|
||||
new Date(pendingStatus.timeout_at).getTime() - Date.now();
|
||||
const next = Math.max(0, Math.floor(remaining / 1000));
|
||||
setTimeLeft(next);
|
||||
if (next <= 0) {
|
||||
window.clearInterval(id);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(id);
|
||||
}, [pendingStatus.timeout_at, hasResponded]);
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
const disabled = isResponding || hasResponded || timeLeft <= 0;
|
||||
|
||||
const respond = async (approved: boolean, reason?: string) => {
|
||||
if (disabled) return;
|
||||
if (!executionProcessId) {
|
||||
setError('Missing executionProcessId');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResponding(true);
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const status: ApprovalStatus = approved
|
||||
? { status: 'approved' }
|
||||
: { status: 'denied', reason };
|
||||
|
||||
try {
|
||||
await approvalsApi.respond(
|
||||
pendingStatus.approval_id,
|
||||
{
|
||||
execution_process_id: executionProcessId,
|
||||
status,
|
||||
},
|
||||
controller.signal
|
||||
);
|
||||
|
||||
setHasResponded(true);
|
||||
setIsEnteringReason(false);
|
||||
setDenyReason('');
|
||||
} catch (e: any) {
|
||||
console.error('Approval respond failed:', e);
|
||||
setError(e?.message || 'Failed to send response');
|
||||
} finally {
|
||||
setIsResponding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = () => respond(true);
|
||||
const handleStartDeny = () => {
|
||||
if (disabled) return;
|
||||
setError(null);
|
||||
setIsEnteringReason(true);
|
||||
};
|
||||
|
||||
const handleCancelDeny = () => {
|
||||
if (isResponding) return;
|
||||
setIsEnteringReason(false);
|
||||
setDenyReason('');
|
||||
};
|
||||
|
||||
const handleSubmitDeny = () => {
|
||||
const trimmed = denyReason.trim();
|
||||
respond(false, trimmed || DEFAULT_DENIAL_REASON);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasResponded) return;
|
||||
}, [hasResponded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnteringReason) return;
|
||||
const id = window.setTimeout(() => denyReasonRef.current?.focus(), 0);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [isEnteringReason]);
|
||||
|
||||
return (
|
||||
<div className="relative mt-3">
|
||||
<div className="absolute -top-3 left-4 rounded-full border bg-background px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide shadow-sm">
|
||||
Awaiting approval
|
||||
</div>
|
||||
<div className="overflow-hidden border">
|
||||
{children}
|
||||
<div className="border-t bg-background px-2 py-1.5 text-xs sm:text-sm">
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<div className="flex items-center gap-1.5 pl-4">
|
||||
{!isEnteringReason && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
variant="ghost"
|
||||
className="h-8 w-8 rounded-full p-0"
|
||||
disabled={disabled}
|
||||
aria-label={
|
||||
isResponding ? 'Submitting approval' : 'Approve'
|
||||
}
|
||||
>
|
||||
<Check className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{isResponding ? 'Submitting…' : 'Approve request'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={handleStartDeny}
|
||||
variant="ghost"
|
||||
className="h-8 w-8 rounded-full p-0"
|
||||
disabled={disabled}
|
||||
aria-label={
|
||||
isResponding ? 'Submitting denial' : 'Deny'
|
||||
}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{isResponding
|
||||
? 'Submitting…'
|
||||
: 'Provide denial reason'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isEnteringReason && !hasResponded && timeLeft > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center pr-8">
|
||||
<CircularProgress percent={percent} />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{formatSeconds(timeLeft)} remaining</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="mt-1 text-xs text-red-600">{error}</div>}
|
||||
{isEnteringReason && !hasResponded && (
|
||||
<div className="mt-3 bg-background px-3 py-3 text-sm">
|
||||
<Textarea
|
||||
ref={denyReasonRef}
|
||||
value={denyReason}
|
||||
onChange={(e) => {
|
||||
setDenyReason(e.target.value);
|
||||
}}
|
||||
placeholder="Let the agent know why this request was denied..."
|
||||
disabled={isResponding}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCancelDeny}
|
||||
disabled={isResponding}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmitDeny}
|
||||
disabled={isResponding}
|
||||
>
|
||||
Submit denial
|
||||
</Button>
|
||||
</div>
|
||||
{!hasResponded && timeLeft > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center pr-2">
|
||||
<CircularProgress percent={percent} />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{formatSeconds(timeLeft)} remaining</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PendingApprovalEntry;
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
VirtuosoMessageListMethods,
|
||||
VirtuosoMessageListProps,
|
||||
} from '@virtuoso.dev/message-list';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import DisplayConversationEntry from '../NormalizedConversation/DisplayConversationEntry';
|
||||
import { useEntries } from '@/contexts/EntriesContext';
|
||||
import {
|
||||
@@ -14,8 +15,8 @@ import {
|
||||
PatchTypeWithKey,
|
||||
useConversationHistory,
|
||||
} from '@/hooks/useConversationHistory';
|
||||
import { TaskAttempt } from 'shared/types';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { TaskAttempt } from 'shared/types';
|
||||
|
||||
interface VirtualizedListProps {
|
||||
attempt: TaskAttempt;
|
||||
@@ -25,56 +26,59 @@ interface MessageListContext {
|
||||
attempt: TaskAttempt;
|
||||
}
|
||||
|
||||
type ChannelData = DataWithScrollModifier<PatchTypeWithKey> | null;
|
||||
const INITIAL_TOP_ITEM = { index: 'LAST' as const, align: 'end' as const };
|
||||
|
||||
const InitialDataScrollModifier: ScrollModifier = {
|
||||
type: 'item-location',
|
||||
location: {
|
||||
index: 'LAST',
|
||||
align: 'end',
|
||||
},
|
||||
location: INITIAL_TOP_ITEM,
|
||||
purgeItemSizes: true,
|
||||
};
|
||||
|
||||
const AutoScrollToBottom: ScrollModifier = {
|
||||
type: 'auto-scroll-to-bottom',
|
||||
autoScroll: ({ atBottom, scrollInProgress }) => {
|
||||
if (atBottom || scrollInProgress) {
|
||||
return 'smooth';
|
||||
}
|
||||
return false;
|
||||
},
|
||||
autoScroll: 'smooth',
|
||||
};
|
||||
|
||||
const ItemContent: VirtuosoMessageListProps<
|
||||
PatchTypeWithKey,
|
||||
MessageListContext
|
||||
>['ItemContent'] = ({ data, context }) => {
|
||||
const attempt = context?.attempt;
|
||||
|
||||
if (data.type === 'STDOUT') {
|
||||
return <p>{data.content}</p>;
|
||||
} else if (data.type === 'STDERR') {
|
||||
}
|
||||
if (data.type === 'STDERR') {
|
||||
return <p>{data.content}</p>;
|
||||
} else if (data.type === 'NORMALIZED_ENTRY') {
|
||||
}
|
||||
if (data.type === 'NORMALIZED_ENTRY' && attempt) {
|
||||
return (
|
||||
<DisplayConversationEntry
|
||||
key={data.patchKey}
|
||||
expansionKey={data.patchKey}
|
||||
entry={data.content}
|
||||
executionProcessId={data.executionProcessId}
|
||||
taskAttempt={context.attempt}
|
||||
taskAttempt={attempt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const computeItemKey: VirtuosoMessageListProps<
|
||||
PatchTypeWithKey,
|
||||
MessageListContext
|
||||
>['computeItemKey'] = ({ data }) => `l-${data.patchKey}`;
|
||||
|
||||
const VirtualizedList = ({ attempt }: VirtualizedListProps) => {
|
||||
const [channelData, setChannelData] = useState<ChannelData>(null);
|
||||
const [channelData, setChannelData] =
|
||||
useState<DataWithScrollModifier<PatchTypeWithKey> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { setEntries, reset } = useEntries();
|
||||
|
||||
// When attempt changes, set loading and reset entries
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setChannelData(null);
|
||||
reset();
|
||||
}, [attempt.id, reset]);
|
||||
|
||||
@@ -83,7 +87,6 @@ const VirtualizedList = ({ attempt }: VirtualizedListProps) => {
|
||||
addType: AddEntryType,
|
||||
newLoading: boolean
|
||||
) => {
|
||||
// initial defaults to scrolling to the latest
|
||||
let scrollModifier: ScrollModifier = InitialDataScrollModifier;
|
||||
|
||||
if (addType === 'running' && !loading) {
|
||||
@@ -91,14 +94,17 @@ const VirtualizedList = ({ attempt }: VirtualizedListProps) => {
|
||||
}
|
||||
|
||||
setChannelData({ data: newEntries, scrollModifier });
|
||||
setEntries(newEntries); // Update shared context
|
||||
setEntries(newEntries);
|
||||
|
||||
if (loading) {
|
||||
setLoading(newLoading);
|
||||
}
|
||||
};
|
||||
|
||||
useConversationHistory({ attempt, onEntriesUpdated });
|
||||
|
||||
const messageListRef = useRef<VirtuosoMessageListMethods | null>(null);
|
||||
const messageListContext = useMemo(() => ({ attempt }), [attempt]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -109,12 +115,12 @@ const VirtualizedList = ({ attempt }: VirtualizedListProps) => {
|
||||
ref={messageListRef}
|
||||
className="flex-1"
|
||||
data={channelData}
|
||||
context={{ attempt }}
|
||||
itemIdentity={(item) => item.patchKey}
|
||||
computeItemKey={({ data }) => data.patchKey}
|
||||
initialLocation={INITIAL_TOP_ITEM}
|
||||
context={messageListContext}
|
||||
computeItemKey={computeItemKey}
|
||||
ItemContent={ItemContent}
|
||||
Header={() => <div className="h-2"></div>} // Padding
|
||||
Footer={() => <div className="h-2"></div>} // Padding
|
||||
Header={() => <div className="h-2"></div>}
|
||||
Footer={() => <div className="h-2"></div>}
|
||||
/>
|
||||
</VirtuosoMessageListLicense>
|
||||
{loading && (
|
||||
|
||||
41
frontend/src/components/ui/circular-progress.tsx
Normal file
41
frontend/src/components/ui/circular-progress.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
|
||||
interface CircularProgressProps {
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export const CircularProgress: React.FC<CircularProgressProps> = ({
|
||||
percent,
|
||||
}) => {
|
||||
const size = 24;
|
||||
const strokeWidth = 2;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDashoffset = circumference - (percent / 100) * circumference;
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
className="text-muted-foreground/20"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className="text-muted-foreground transition-all duration-1000 ease-linear"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -13,15 +13,17 @@ const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
NormalizedEntry,
|
||||
PatchType,
|
||||
TaskAttempt,
|
||||
ToolStatus,
|
||||
} from 'shared/types';
|
||||
import { useExecutionProcesses } from './useExecutionProcesses';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
@@ -273,13 +274,22 @@ export const useConversationHistory = ({
|
||||
p.executionProcess.id
|
||||
);
|
||||
|
||||
const exitCode = Number(executionProcess?.exit_code) || 0;
|
||||
const exit_status: CommandExitStatus | null =
|
||||
executionProcess?.status === 'running'
|
||||
? null
|
||||
: {
|
||||
type: 'exit_code',
|
||||
code: Number(executionProcess?.exit_code) || 0,
|
||||
code: exitCode,
|
||||
};
|
||||
|
||||
const toolStatus: ToolStatus =
|
||||
executionProcess?.status === 'running'
|
||||
? { status: 'created' }
|
||||
: exitCode === 0
|
||||
? { status: 'success' }
|
||||
: { status: 'failed' };
|
||||
|
||||
const output = p.entries.map((line) => line.content).join('\n');
|
||||
|
||||
const toolNormalizedEntry: NormalizedEntry = {
|
||||
@@ -294,6 +304,7 @@ export const useConversationHistory = ({
|
||||
exit_status,
|
||||
},
|
||||
},
|
||||
status: toolStatus,
|
||||
},
|
||||
content: toolName,
|
||||
timestamp: null,
|
||||
|
||||
@@ -21,5 +21,19 @@
|
||||
"en": "English",
|
||||
"ja": "日本語",
|
||||
"browserDefault": "Browser Default"
|
||||
},
|
||||
"conversation": {
|
||||
"plan": "Plan",
|
||||
"planToggle": {
|
||||
"show": "Show plan",
|
||||
"hide": "Hide plan"
|
||||
},
|
||||
"toolDetailsToggle": {
|
||||
"show": "Show details",
|
||||
"hide": "Hide details"
|
||||
},
|
||||
"args": "Args",
|
||||
"output": "Output",
|
||||
"result": "Result"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,5 +21,19 @@
|
||||
"en": "English",
|
||||
"ja": "日本語",
|
||||
"browserDefault": "ブラウザ設定"
|
||||
},
|
||||
"conversation": {
|
||||
"plan": "計画",
|
||||
"planToggle": {
|
||||
"show": "計画を表示",
|
||||
"hide": "計画を非表示"
|
||||
},
|
||||
"toolDetailsToggle": {
|
||||
"show": "詳細を表示",
|
||||
"hide": "詳細を非表示"
|
||||
},
|
||||
"args": "引数",
|
||||
"output": "出力",
|
||||
"result": "結果"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Import all necessary types from shared types
|
||||
|
||||
import {
|
||||
ApprovalStatus,
|
||||
ApiResponse,
|
||||
BranchStatus,
|
||||
CheckTokenResponse,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
FollowUpDraftResponse,
|
||||
UpdateFollowUpDraftRequest,
|
||||
GitOperationError,
|
||||
ApprovalResponse,
|
||||
} from 'shared/types';
|
||||
|
||||
// Re-export types for convenience
|
||||
@@ -795,3 +797,21 @@ export const imagesApi = {
|
||||
return `/api/images/${imageId}/file`;
|
||||
},
|
||||
};
|
||||
|
||||
// Approval API
|
||||
export const approvalsApi = {
|
||||
respond: async (
|
||||
approvalId: string,
|
||||
payload: ApprovalResponse,
|
||||
signal?: AbortSignal
|
||||
): Promise<ApprovalStatus> => {
|
||||
const res = await makeRequest(`/api/approvals/${approvalId}/respond`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
|
||||
return handleApiResponse<ApprovalStatus>(res);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user