Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,28 @@ export interface ClaudeSessionImportFailedProperties {
failed_step?: string;
}

/** Fired when a user arms autoresearch mode on the new-task composer. */
export interface AutoresearchArmedProperties {
/** Hands-off mode auto-applied on arm so the unattended loop isn't blocked on permission prompts. */
default_mode: "bypassPermissions" | "acceptEdits";
workspace_mode?: "local" | "worktree" | "cloud";
}

/** Fired when an armed autoresearch task is submitted and its run kicks off. */
export interface AutoresearchRunStartedProperties {
direction: "maximize" | "minimize";
/** Whether the user set a target metric value to stop early at. */
has_target: boolean;
max_iterations: number;
/** Build and measure stages differ, so each iteration splits into a build turn and a measure turn. */
stages_split: boolean;
implement_model?: string;
measure_model?: string;
implement_effort?: string;
measure_effort?: string;
workspace_mode?: "local" | "worktree" | "cloud";
}

// Event names as constants
export const ANALYTICS_EVENTS = {
// App lifecycle
Expand Down Expand Up @@ -1150,6 +1172,10 @@ export const ANALYTICS_EVENTS = {
DASHBOARD_ACTION: "Dashboard action",
CANVAS_PROMPT_SENT: "Canvas prompt sent",
CONTEXT_ACTION: "Context action",

// Autoresearch events
AUTORESEARCH_ARMED: "Autoresearch armed",
AUTORESEARCH_RUN_STARTED: "Autoresearch run started",
} as const;

// Event property mapping
Expand Down Expand Up @@ -1302,6 +1328,10 @@ export type EventPropertyMap = {
[ANALYTICS_EVENTS.DASHBOARD_ACTION]: DashboardActionProperties;
[ANALYTICS_EVENTS.CANVAS_PROMPT_SENT]: CanvasPromptSentProperties;
[ANALYTICS_EVENTS.CONTEXT_ACTION]: ContextActionProperties;

// Autoresearch events
[ANALYTICS_EVENTS.AUTORESEARCH_ARMED]: AutoresearchArmedProperties;
[ANALYTICS_EVENTS.AUTORESEARCH_RUN_STARTED]: AutoresearchRunStartedProperties;
};

/**
Expand Down
128 changes: 80 additions & 48 deletions packages/ui/src/features/autoresearch/AutoresearchComposerControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ interface AutoresearchComposerControlsProps {
}

/**
* Autoresearch settings rendered inside the composer box (its header addon)
* while the mode is armed — one input view, not a widget attached under it.
* Autoresearch settings shown as a bar above the composer while the mode is
* armed. It reads as one sentence — "Optimize to maximize until it reaches N
* or after K iterations using <model>" — so each control explains itself in
* place; tooltips on the labels add the detail.
*
* There is deliberately no metric or instructions field: the prompt IS the
* optimization brief, and the agent names the metric in its reports.
*
Expand All @@ -39,44 +42,63 @@ export function AutoresearchComposerControls({
onExit,
}: AutoresearchComposerControlsProps) {
return (
<div className="flex w-full flex-wrap items-center gap-x-2 gap-y-1 text-[12px]">
<span className="flex shrink-0 items-center gap-1 font-medium text-violet-11">
<ChartLineUp size={13} />
Autoresearch
<div className="flex w-full flex-wrap items-center gap-x-2 gap-y-1.5 text-[12px]">
<Tooltip content="Runs an autonomous optimization loop: it measures a baseline from your brief, then edits the code and re-measures each round — keeping changes that move the metric and reverting the ones that don't. It stops when the metric reaches your target or hits the iteration cap, whichever comes first.">
<span className="flex shrink-0 cursor-help items-center gap-1 font-medium text-violet-11">
<ChartLineUp size={13} />
Autoresearch
</span>
</Tooltip>

{/* The goal: which way to push the metric the brief describes. */}
<span className="flex items-center gap-1.5 whitespace-nowrap text-(--gray-11)">
<Tooltip content="Whether the agent should drive the metric from your brief up or down.">
<span className="cursor-help">Optimize to</span>
</Tooltip>
<Select.Root
size="1"
value={draft.direction}
onValueChange={(value) =>
onChange({ direction: value as AutoresearchDirection })
}
disabled={disabled}
>
<Select.Trigger variant="soft" aria-label="Optimization direction" />
<Select.Content>
<Select.Item value="maximize">maximize</Select.Item>
<Select.Item value="minimize">minimize</Select.Item>
</Select.Content>
</Select.Root>
</span>

{/* Two stop conditions, whichever comes first: the metric hits the
target value, or the run exhausts its iteration budget. Only the
label text is wrapped in a tooltip — never the input — so focusing
the field to type doesn't pop the tooltip open. */}
<span className="flex items-center gap-1.5 whitespace-nowrap text-(--gray-11)">
<Tooltip content="Finish early once the metric reaches this value. Leave blank to always run the full iteration budget.">
<span className="cursor-help">until it reaches</span>
</Tooltip>
<TextField.Root
size="1"
className="w-24"
value={draft.targetValue === null ? "" : String(draft.targetValue)}
onChange={(event) => {
const raw = event.target.value.trim();
const numeric = Number(raw);
onChange({
targetValue:
raw === "" || !Number.isFinite(numeric) ? null : numeric,
});
}}
placeholder="optional"
inputMode="decimal"
aria-label="Target metric value to stop at (optional)"
disabled={disabled}
/>
</span>
<Select.Root
size="1"
value={draft.direction}
onValueChange={(value) =>
onChange({ direction: value as AutoresearchDirection })
}
disabled={disabled}
>
<Select.Trigger variant="soft" aria-label="Optimization direction" />
<Select.Content>
<Select.Item value="maximize">Maximize</Select.Item>
<Select.Item value="minimize">Minimize</Select.Item>
</Select.Content>
</Select.Root>
<TextField.Root
size="1"
className="w-24"
value={draft.targetValue === null ? "" : String(draft.targetValue)}
onChange={(event) => {
const raw = event.target.value.trim();
const numeric = Number(raw);
onChange({
targetValue:
raw === "" || !Number.isFinite(numeric) ? null : numeric,
});
}}
placeholder="Target"
inputMode="decimal"
aria-label="Target value (optional)"
disabled={disabled}
/>
<span className="flex items-center gap-1 text-(--gray-11)">
<span className="flex items-center gap-1.5 whitespace-nowrap text-(--gray-11)">
or after
<TextField.Root
size="1"
className="w-14"
Expand All @@ -89,18 +111,28 @@ export function AutoresearchComposerControls({
})
}
inputMode="numeric"
aria-label="Iteration budget"
aria-label="Maximum iterations"
disabled={disabled}
/>
iterations
<Tooltip content="Hard cap on build-and-measure rounds before the run stops.">
<span className="cursor-help">iterations</span>
</Tooltip>
</span>
<StagesPopover
draft={draft}
modelOptions={modelOptions}
effortOptions={effortOptions}
disabled={disabled}
onChange={onChange}
/>

{/* The engine: model + effort per stage. */}
<span className="flex items-center gap-1.5 whitespace-nowrap text-(--gray-11)">
<Tooltip content="Model and reasoning effort the loop runs on. Set the build and measure stages to different models to measure with a cheaper one.">
<span className="cursor-help">using</span>
</Tooltip>
<StagesPopover
draft={draft}
modelOptions={modelOptions}
effortOptions={effortOptions}
disabled={disabled}
onChange={onChange}
/>
</span>

<span className="ml-auto flex shrink-0 items-center">
<Tooltip content="Exit autoresearch mode">
<button
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import { CaretDown } from "@phosphor-icons/react";
import { CaretDown, ChartLineUp } from "@phosphor-icons/react";
import {
Button,
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
MenuLabel,
} from "@posthog/quill";
Expand All @@ -19,16 +21,28 @@ interface ModeSelectorProps {
onChange: (value: string) => void;
allowBypassPermissions: boolean;
disabled?: boolean;
/**
* When provided, an "Autoresearch" toggle renders as the last item of the
* menu (new-task composer only). It arms/disarms the autonomous iteration
* loop; `active` drives its checkmark. Applied after the menu closes, like a
* mode change, so the composer doesn't relayout under the closing menu.
*/
autoresearch?: {
active: boolean;
onToggle: () => void;
};
}

export function ModeSelector({
modeOption,
onChange,
allowBypassPermissions,
disabled,
autoresearch,
}: ModeSelectorProps) {
const [open, setOpen] = useState(false);
const pendingValueRef = useRef<string | null>(null);
const pendingAutoresearchRef = useRef(false);
const displayOption = useRetainedConfigOption(modeOption);

if (!displayOption || displayOption.type !== "select") return null;
Expand Down Expand Up @@ -58,10 +72,15 @@ export function ModeSelector({
open={open}
onOpenChange={setOpen}
onOpenChangeComplete={(isOpen) => {
if (!isOpen && pendingValueRef.current !== null) {
if (isOpen) return;
if (pendingValueRef.current !== null) {
onChange(pendingValueRef.current);
pendingValueRef.current = null;
}
if (pendingAutoresearchRef.current) {
pendingAutoresearchRef.current = false;
autoresearch?.onToggle();
}
}}
>
<DropdownMenuTrigger
Expand Down Expand Up @@ -107,6 +126,23 @@ export function ModeSelector({
);
})}
</DropdownMenuRadioGroup>
{autoresearch && (
<>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={autoresearch.active}
onCheckedChange={() => {
pendingAutoresearchRef.current = true;
setOpen(false);
}}
>
<span className="text-violet-11">
<ChartLineUp size={12} />
</span>
<span className="whitespace-nowrap">Autoresearch</span>
</DropdownMenuCheckboxItem>
</>
)}
Comment on lines +129 to +145

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Checkbox checked visual state can lag behind onCheckedChange resolution

onCheckedChange sets pendingAutoresearchRef.current = true and calls setOpen(false), but the actual store update (and thus the new value of autoresearch.active) only fires during onOpenChangeComplete after the menu's close animation ends. If Radix UI optimistically flips the checkbox's visual checked state inside onCheckedChange, it will momentarily disagree with the checked={autoresearch.active} prop until the animation completes. In practice the menu closes immediately so the user may never see the inconsistency, but it's worth verifying Radix's DropdownMenuCheckboxItem doesn't eagerly render the toggled state before the controlled prop catches up.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

</DropdownMenuContent>
</DropdownMenu>
);
Expand Down
10 changes: 10 additions & 0 deletions packages/ui/src/features/message-editor/components/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ export interface PromptInputProps {
modeOption?: SessionConfigOption;
onModeChange?: (value: string) => void;
allowBypassPermissions?: boolean;
/**
* When provided, the mode dropdown gains an "Autoresearch" toggle as its
* last item (new-task composer only). `active` drives its checkmark.
*/
autoresearch?: {
active: boolean;
onToggle: () => void;
};
// capabilities
enableBashMode?: boolean;
enableCommands?: boolean;
Expand Down Expand Up @@ -94,6 +102,7 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
modeOption,
onModeChange,
allowBypassPermissions = false,
autoresearch,
enableBashMode = false,
enableCommands = true,
modelSelector,
Expand Down Expand Up @@ -395,6 +404,7 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
onChange={onModeChange}
allowBypassPermissions={allowBypassPermissions}
disabled={disabled}
autoresearch={autoresearch}
/>
)}
{modelSelector && <span>{modelSelector}</span>}
Expand Down
Loading
Loading