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:
Gabriel Gordon-Hall
2025-09-22 16:02:42 +01:00
committed by GitHub
parent eaff3dee9e
commit 798bcb80a3
51 changed files with 1808 additions and 198 deletions

View File

@@ -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>
);
}

View File

@@ -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 />

View File

@@ -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)}

View File

@@ -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;

View File

@@ -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 && (

View 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>
);
};

View File

@@ -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;

View File

@@ -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,

View File

@@ -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"
}
}

View File

@@ -21,5 +21,19 @@
"en": "English",
"ja": "日本語",
"browserDefault": "ブラウザ設定"
},
"conversation": {
"plan": "計画",
"planToggle": {
"show": "計画を表示",
"hide": "計画を非表示"
},
"toolDetailsToggle": {
"show": "詳細を表示",
"hide": "詳細を非表示"
},
"args": "引数",
"output": "出力",
"result": "結果"
}
}

View File

@@ -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);
},
};