Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
453b957
🤖 feat: add end-to-end actor-critic mode loop
ammar-agent Feb 17, 2026
f31b0a3
🤖 tests: use Jest globals in critic mode UI test
ammar-agent Feb 17, 2026
32dfda9
🤖 fix: prioritize queued user input over critic auto-loop continuation
ammar-agent Feb 17, 2026
c143bee
🤖 tests: stabilize critic queue-priority integration timing
ammar-agent Feb 17, 2026
9f5a508
🤖 tests: align critic criticMode UI tests with tests skill
ammar-agent Feb 17, 2026
26ebed1
🤖 fix: defer actor-critic continuation for pending edits
ammar-agent Feb 17, 2026
d192eef
🤖 fix: preserve critic turn semantics across resume
ammar-agent Feb 17, 2026
866f244
🤖 fix: handle critic /done reasoning and resume inference
ammar-agent Feb 17, 2026
17e094a
🤖 fix: align critic /done filtering with visible text semantics
ammar-agent Feb 17, 2026
00357fb
🤖 fix: preserve critic loop settings after interrupted turns
ammar-agent Feb 18, 2026
5c87e16
🤖 feat: simplify critic mode UX — main textarea becomes critic prompt
ammar-agent Feb 19, 2026
3b9d36c
🤖 fix: use parsed send text for critic prompt, not raw input
ammar-agent Feb 19, 2026
a85ad0a
🤖 fix: reapply critic guardrails when resuming critic turns
ammar-agent Feb 19, 2026
3838c2e
🤖 feat: critic mode set-not-send UX — main input becomes critic prompt
ammar-agent Feb 19, 2026
be4e50f
🤖 fix: guard critic-loop start against in-flight streams and empty pr…
ammar-agent Feb 19, 2026
3fa81ee
🤖 fix: prevent critic prompts from queuing as user messages + empty h…
ammar-agent Feb 19, 2026
54e2feb
feat: allow critic loop to start from scratch on empty history
ammar-agent Feb 19, 2026
2cf2d86
fix: add isBusy guard to startCriticLoop + surface backend errors in …
ammar-agent Feb 19, 2026
c9c5de5
fix: apply tool output redaction in critic history + add lifecycle gu…
ammar-agent Feb 19, 2026
1370839
fix: race condition in empty-history critic test
ammar-agent Feb 19, 2026
9c5cbaf
fix: restore prompt on failure + propagate history read errors
ammar-agent Feb 19, 2026
8da0c9b
fix: roll back seeded prompt when actor stream fails to start
ammar-agent Feb 19, 2026
1761df4
fix: mark seeded prompt as non-synthetic + emit delete on rollback
ammar-agent Feb 19, 2026
63024e6
fix: show toast for empty-text sends in critic mode
ammar-agent Feb 19, 2026
1c5f4d5
refactor: extract resolveExperimentFlags + apply to startCriticLoop
ammar-agent Feb 19, 2026
bf3ae42
fix: start actor turn when history ends with unanswered user message
ammar-agent Feb 19, 2026
873e7b3
fix: skip critic-loop path when submitting a message edit
ammar-agent Feb 19, 2026
57a783e
fix: add recency + AI settings bookkeeping to startCriticLoop
ammar-agent Feb 19, 2026
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
93 changes: 91 additions & 2 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
getModelKey,
getThinkingLevelKey,
getWorkspaceAISettingsByAgentKey,
getCriticEnabledKey,
getCriticPromptKey,
getInputKey,
getInputAttachmentsKey,
AGENT_AI_DEFAULTS_KEY,
Expand Down Expand Up @@ -176,6 +178,21 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
const atMentionProjectPath = variant === "creation" ? props.projectPath : null;
const workspaceId = variant === "workspace" ? props.workspaceId : null;

const criticEnabledStorageKey =
workspaceId !== null ? getCriticEnabledKey(workspaceId) : "__critic-enabled__:creation";
const criticPromptStorageKey =
workspaceId !== null ? getCriticPromptKey(workspaceId) : "__critic-prompt__:creation";
const [criticEnabled, setCriticEnabled] = usePersistedState<boolean>(
criticEnabledStorageKey,
false,
{
listener: true,
}
);
const [, setCriticPrompt] = usePersistedState<string>(criticPromptStorageKey, "", {
listener: true,
});

// Extract workspace-specific props with defaults
const disabled = props.disabled ?? false;
const editingMessage = variant === "workspace" ? props.editingMessage : undefined;
Expand Down Expand Up @@ -1569,6 +1586,12 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
setToast,
setPreferredModel,
setVimEnabled,
criticEnabled: variant === "workspace" ? criticEnabled : undefined,
setCriticEnabled: variant === "workspace" ? setCriticEnabled : undefined,
isStreaming:
variant === "workspace"
? Boolean((props.canInterrupt ?? false) || isStreamStarting)
: false,
onTruncateHistory: variant === "workspace" ? props.onTruncateHistory : undefined,
resetInputHeight: () => {
if (inputRef.current) {
Expand Down Expand Up @@ -1804,6 +1827,56 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
return;
}

// In critic mode, the textarea text is the critic prompt, not a user message.
// Save the prompt and start the critic loop against existing history.
// Guards: require non-empty prompt text, and don't start if a stream is already active
// (that would abort the in-progress output via StreamManager.startStream).
// Skip the critic-loop path when submitting a message edit — edits must go through
// the normal edit-send flow, not start a new critic loop.
const isCriticModeActive = variant === "workspace" && criticEnabled && !editingMessage;
if (isCriticModeActive) {
// In critic mode, ONLY allow /commands to fall through to normal handling.
// All other input is treated as critic instructions — never sent as user messages.
const isStreamActive = isStreamStarting || (props.canInterrupt ?? false);
if (!api || !workspaceId || !messageText || isStreamActive) {
if (isStreamActive) {
pushToast({ type: "error", message: "Wait for the current response to finish" });
} else if (!messageText) {
pushToast({ type: "error", message: "Enter critic instructions to start the loop" });
}
return;
}
}
if (isCriticModeActive && api && workspaceId) {
setCriticPrompt(messageText);
setInput("");
if (inputRef.current) {
inputRef.current.style.height = "";
}

const result = await api.workspace.startCriticLoop({
workspaceId,
options: {
...sendMessageOptions,
criticEnabled: true,
criticPrompt: messageText,
},
});

if (!result.success) {
// Restore the user's prompt so they don't have to re-type it
setInput(messageText);
const errorDetail =
"raw" in result.error
? result.error.raw
: "message" in result.error
? result.error.message
: null;
pushToast({ type: "error", message: errorDetail ?? "Failed to start critic loop" });
}
return;
}

const modelOverride = modelOneShot?.modelString;

// Regular message (or /<model-alias> one-shot override) - send directly via API
Expand Down Expand Up @@ -2057,6 +2130,8 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
rawThinkingOverride != null
? resolveThinkingInput(rawThinkingOverride, policyModel)
: undefined;
// Note: critic mode is handled above with an early return — this path is
// only reached for normal (non-critic) sends.
const sendOptions = {
...sendMessageOptions,
...compactionOptions,
Expand Down Expand Up @@ -2273,6 +2348,11 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
return `Compacting... (${formatKeybind(interruptKeybind)} cancel | ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to queue)`;
}

// In critic mode, the main textarea IS the critic prompt input.
if (variant === "workspace" && criticEnabled) {
return "Critic instructions...";
}

// Keep placeholder minimal; shortcut hints are rendered below the input.
return "Type a message...";
})();
Expand Down Expand Up @@ -2460,6 +2540,15 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
<ChatAttachments attachments={attachments} onRemove={handleRemoveAttachment} />

<div className="flex flex-col gap-0.5" data-component="ChatModeToggles">
{variant === "workspace" && criticEnabled && (
<div
data-component="CriticBadge"
className="text-exec-mode bg-exec-mode/10 inline-flex items-center rounded-sm px-1.5 py-0.5 text-[11px] font-medium"
>
Critic mode active
</div>
)}

{/* Editing indicator - workspace only */}
{variant === "workspace" && editingMessage && (
<div className="text-edit-mode text-[11px] font-medium">
Expand Down Expand Up @@ -2555,7 +2644,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
type="button"
onClick={() => void handleSend()}
disabled={!canSend}
aria-label="Send message"
aria-label={criticEnabled ? "Set critic prompt" : "Send message"}
size="xs"
variant="ghost"
className={cn(
Expand All @@ -2571,7 +2660,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
</Button>
</TooltipTrigger>
<TooltipContent align="center">
Send message{" "}
{criticEnabled ? "Set critic prompt" : "Send message"}{" "}
<span className="mobile-hide-shortcut-hints">
({formatKeybind(KEYBINDS.SEND_MESSAGE)})
</span>
Expand Down
7 changes: 7 additions & 0 deletions src/browser/components/Messages/AssistantMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
const isStreaming = message.isStreaming;
const isCompacted = message.isCompacted;
const isStreamingCompaction = isStreaming && isCompacting;
const isCritic = message.messageSource === "critic";

// Use Start Here hook for final assistant messages
const {
Expand Down Expand Up @@ -154,6 +155,11 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({

return (
<div className="flex items-center gap-2">
{isCritic && (
<span className="inline-flex items-center rounded-sm bg-[color-mix(in_srgb,var(--color-critic-mode)_14%,transparent)] px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-[var(--color-critic-mode)] uppercase">
Critic
</span>
)}
{modelName && (
<ModelDisplay
modelString={modelName}
Expand Down Expand Up @@ -183,6 +189,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
buttons={buttons}
className={className}
backgroundEffect={isStreamingCompaction ? <CompactionBackground /> : undefined}
messageSource={message.messageSource}
>
{renderContent()}
</MessageWindow>
Expand Down
8 changes: 7 additions & 1 deletion src/browser/components/Messages/MessageWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface MessageWindowProps {
className?: string;
rightLabel?: ReactNode;
backgroundEffect?: ReactNode; // Optional background effect (e.g., animation)
messageSource?: "actor" | "critic";
}

export const MessageWindow: React.FC<MessageWindowProps> = ({
Expand All @@ -38,6 +39,7 @@ export const MessageWindow: React.FC<MessageWindowProps> = ({
children,
rightLabel,
backgroundEffect,
messageSource,
}) => {
const [showJson, setShowJson] = useState(false);

Expand Down Expand Up @@ -77,12 +79,16 @@ export const MessageWindow: React.FC<MessageWindowProps> = ({
isLastPartOfMessage && "mb-4"
)}
data-message-block
data-message-source={messageSource}
>
<div
className={cn(
variant === "user" &&
"bg-[var(--color-user-surface)] border border-[var(--color-user-border)] rounded-lg px-3 py-2 overflow-x-auto shadow-sm",
variant === "assistant" && "px-1 py-1"
variant === "assistant" && "px-1 py-1",
variant === "assistant" &&
messageSource === "critic" &&
"rounded-md border border-[color:color-mix(in_srgb,var(--color-critic-mode)_40%,transparent)] bg-[color-mix(in_srgb,var(--color-critic-mode)_6%,transparent)] px-2"
)}
>
{backgroundEffect}
Expand Down
28 changes: 21 additions & 7 deletions src/browser/components/Messages/ReasoningMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, cla

const content = message.content;
const isStreaming = message.isStreaming;
const isCritic = message.messageSource === "critic";
const accentColorClass = isCritic ? "text-[var(--color-critic-mode)]" : "text-thinking-mode";
const surfaceClass = isCritic
? "bg-[color-mix(in_srgb,var(--color-critic-mode)_5%,transparent)]"
: "bg-[color-mix(in_srgb,var(--color-thinking-mode)_5%,transparent)]";
const shimmerColor = isCritic ? "var(--color-critic-mode)" : "var(--color-thinking-mode)";
const trimmedContent = content?.trim() ?? "";
const hasContent = trimmedContent.length > 0;
const summaryLineRaw = hasContent ? (trimmedContent.split(/\r?\n/)[0] ?? "") : "";
Expand Down Expand Up @@ -125,10 +131,8 @@ export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, cla

return (
<div
className={cn(
"my-2 px-2 py-1 bg-[color-mix(in_srgb,var(--color-thinking-mode)_5%,transparent)] rounded relative",
className
)}
className={cn("my-2 px-2 py-1 rounded relative", surfaceClass, className)}
data-message-source={message.messageSource}
>
<div
className={cn(
Expand All @@ -141,15 +145,22 @@ export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, cla
<div
className={cn(
"flex flex-1 items-center gap-1 min-w-0 text-xs opacity-80",
"text-thinking-mode"
accentColorClass
)}
>
<span className="text-xs">
<Lightbulb className={cn("size-3.5", isStreaming && "animate-pulse")} />
</span>
<div className="flex min-w-0 items-center gap-1 truncate">
{isCritic && (
<span className="inline-flex items-center rounded-sm bg-[color-mix(in_srgb,var(--color-critic-mode)_14%,transparent)] px-1 py-0.5 text-[10px] font-semibold tracking-wide text-[var(--color-critic-mode)] uppercase">
Critic
</span>
)}
{isStreaming ? (
<Shimmer colorClass="var(--color-thinking-mode)">Thinking...</Shimmer>
<Shimmer colorClass={shimmerColor}>
{isCritic ? "Critic thinking..." : "Thinking..."}
</Shimmer>
) : hasContent ? (
<span className={cn("truncate whitespace-nowrap text-text", REASONING_FONT_CLASSES)}>
{parsedLeadingBoldSummary ? (
Expand All @@ -161,6 +172,8 @@ export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, cla
summaryLine
)}
</span>
) : isCritic ? (
"Critic thought"
) : (
"Thought"
)}
Expand All @@ -177,7 +190,8 @@ export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, cla
{isCollapsible && (
<span
className={cn(
"text-thinking-mode opacity-60 transition-transform duration-200 ease-in-out text-xs",
"opacity-60 transition-transform duration-200 ease-in-out text-xs",
accentColorClass,
isExpanded ? "rotate-90" : "rotate-0"
)}
>
Expand Down
32 changes: 32 additions & 0 deletions src/browser/hooks/useResumeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,30 @@ export function useResumeManager() {
return true;
};

const shouldResumeAsCriticTurn = (state: WorkspaceState): boolean => {
// Inspect only the latest partial assistant/reasoning message. Older partial critic
// entries can remain in state after later actor partials and must not force critic resume.
const latestPartialAssistantLike = [...state.messages].reverse().find((message) => {
if (message.type !== "assistant" && message.type !== "reasoning") {
return false;
}
return message.isPartial === true;
});

if (!latestPartialAssistantLike) {
return false;
}

if (
latestPartialAssistantLike.type !== "assistant" &&
latestPartialAssistantLike.type !== "reasoning"
) {
return false;
}

return latestPartialAssistantLike.messageSource === "critic";
};

/**
* Attempt to resume a workspace stream
* Polling will check eligibility every 1 second
Expand Down Expand Up @@ -187,6 +211,14 @@ export function useResumeManager() {
const parsedCompaction = lastUserMsg.compactionRequest.parsed;
options = applyCompactionOverrides(options, parsedCompaction);
}

if (shouldResumeAsCriticTurn(state)) {
options = {
...options,
criticEnabled: true,
isCriticTurn: true,
};
}
}

if (!api) {
Expand Down
11 changes: 11 additions & 0 deletions src/browser/hooks/useSendMessageOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
} from "@/browser/utils/messages/buildSendMessageOptions";
import {
DEFAULT_MODEL_KEY,
getCriticEnabledKey,
getCriticPromptKey,
getModelKey,
PREFERRED_SYSTEM_1_MODEL_KEY,
PREFERRED_SYSTEM_1_THINKING_LEVEL_KEY,
Expand Down Expand Up @@ -78,6 +80,13 @@ export function useSendMessageOptions(workspaceId: string): SendMessageOptionsWi
);
const system1ThinkingLevel = normalizeSystem1ThinkingLevel(preferredSystem1ThinkingLevel);

const [criticEnabled] = usePersistedState<boolean>(getCriticEnabledKey(workspaceId), false, {
listener: true,
});
const [criticPrompt] = usePersistedState<string>(getCriticPromptKey(workspaceId), "", {
listener: true,
});

// Compute base model (canonical format) for UI components
const baseModel = normalizeModelPreference(preferredModel, defaultModel);

Expand All @@ -95,6 +104,8 @@ export function useSendMessageOptions(workspaceId: string): SendMessageOptionsWi
system1Model,
system1ThinkingLevel,
disableWorkspaceAgents,
criticEnabled,
criticPrompt,
});

return {
Expand Down
4 changes: 4 additions & 0 deletions src/browser/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
--color-exec-mode-hover: hsl(268.56 94.04% 67%);
--color-exec-mode-light: hsl(268.56 94.04% 78%);

--color-critic-mode: hsl(336 82% 66%);
--color-critic-mode-hover: hsl(336 82% 74%);
--color-critic-mode-light: hsl(336 82% 82%);

/* Edit mode: amber/gold for editing warnings and barriers */
--color-edit-mode: hsl(38 80% 45%);
--color-edit-mode-hover: hsl(38 80% 55%);
Expand Down
Loading