make plan mode reachable in chat
opusplan has been in the picker since 57bbcca9 and did nothing: it means
'Opus while planning, Sonnet at rest', and claude-manager hardcoded
permissionMode: 'bypassPermissions', so a session never entered plan mode
and opusplan rested forever on Sonnet.
verified against a real claude first (SDK 0.3.233), and one finding changed
the design: approving ExitPlanMode drops the harness into 'default', NOT back
into bypassPermissions, and the agent carries on inside the same turn. so the
restore is fired the moment approval is given rather than at turn end, and
canUseTool allows everything that is not ExitPlanMode - for a few hundred ms
it is the only thing between the agent and its tools, and a default-deny there
would jail an agent the platform deliberately does not jail.
canUseTool is passed always. under bypassPermissions the SDK shadows it
entirely (CLAUDE_SDK_CAN_USE_TOOL_SHADOWED) so it costs a log line, and since
it is a query() option read once, passing it unconditionally is what makes the
mode toggleable on a live conversation instead of only at session start.
sticky per session on chat_session_models.plan_mode, beside the model because
they are one decision about what a turn costs. boolean rather than the
PermissionMode string: officer exposes two of the five, and a text column would
accept three the sidecar cannot handle.
plan approval is the only place the agent blocks on officer - a canUseTool
promise held in the sidecar while the plan round-trips to a browser. every path
that ends a session or a turn settles it as refused, plus a 20m timeout, because
a leaked entry is a hung turn rather than a stale value.
not auto-entered for opusplan: the CLI does not either. the toggle says so
instead - 'opusplan rests on Sonnet, turn on Plan for Opus'.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -74,8 +74,11 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
selectedModel,
|
||||
hasStarted,
|
||||
thinkingLevel,
|
||||
planMode,
|
||||
setSelectedModel,
|
||||
setThinkingLevel,
|
||||
setPlanMode,
|
||||
decidePlan,
|
||||
sendPrompt,
|
||||
stopGeneration: stopTurn,
|
||||
loadOlder,
|
||||
@@ -572,8 +575,11 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
selectedModel,
|
||||
hasStarted,
|
||||
thinkingLevel,
|
||||
planMode,
|
||||
setSelectedModel,
|
||||
setThinkingLevel,
|
||||
setPlanMode,
|
||||
decidePlan,
|
||||
sendPrompt,
|
||||
stopGeneration,
|
||||
input,
|
||||
|
||||
@@ -41,6 +41,8 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
appendToInput,
|
||||
thinkingLevel,
|
||||
setThinkingLevel,
|
||||
planMode,
|
||||
setPlanMode,
|
||||
queued,
|
||||
sendQueuedNow,
|
||||
unqueue,
|
||||
@@ -162,6 +164,8 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
thinkingLevel={thinkingLevel}
|
||||
onThinkingChange={setThinkingLevel}
|
||||
onBackgroundJob={() => setJobDialogOpen(true)}
|
||||
planMode={planMode}
|
||||
onPlanModeChange={setPlanMode}
|
||||
/>
|
||||
|
||||
<WebpageDialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen} onSubmit={attachWebpage} />
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
RotateCcw,
|
||||
FoldVertical,
|
||||
ArrowLeftRight,
|
||||
ClipboardList,
|
||||
} from 'lucide-react';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import type { Tone } from '@/components/Data';
|
||||
@@ -120,11 +121,13 @@ type MessageBubbleProps = {
|
||||
message: ChatMessage;
|
||||
onAnswer?: (text: string) => void;
|
||||
onRetry?: (prompt: string) => void;
|
||||
/** Answer a plan the agent is parked on. Absent in read-only views, where the card renders without buttons. */
|
||||
onDecidePlan?: (requestId: string, approve: boolean, feedback?: string) => void;
|
||||
/** Set for rows in the turn you are watching; tool rows start expanded and stay that way until it folds. */
|
||||
defaultOpen?: boolean;
|
||||
};
|
||||
|
||||
export const MessageBubble = ({ message, onAnswer, onRetry, defaultOpen }: MessageBubbleProps) => {
|
||||
export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaultOpen }: MessageBubbleProps) => {
|
||||
switch (message.role) {
|
||||
case 'user': {
|
||||
const rawText = typeof message.text === 'string' ? message.text : '';
|
||||
@@ -215,6 +218,12 @@ export const MessageBubble = ({ message, onAnswer, onRetry, defaultOpen }: Messa
|
||||
case 'model':
|
||||
return <ModelSwitch message={message} />;
|
||||
|
||||
case 'planmode':
|
||||
return <PlanModeSwitch message={message} />;
|
||||
|
||||
case 'plan':
|
||||
return <PlanCard message={message} onDecide={onDecidePlan} />;
|
||||
|
||||
case 'interrupted':
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
|
||||
@@ -382,6 +391,132 @@ const ModelSwitch = ({ message }: { message: Extract<ChatMessage, { role: 'model
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Plan mode going on or off — the same centred rule as `ModelSwitch`, and the same kind of fact.
|
||||
*
|
||||
* Worth a marker rather than a silent state change because it explains behaviour that otherwise looks like
|
||||
* a fault: in plan mode the agent researches and proposes but will not edit anything, and a transcript with
|
||||
* no notice in it reads as an agent that inexplicably stopped doing the work.
|
||||
*/
|
||||
const PlanModeSwitch = ({ message }: { message: Extract<ChatMessage, { role: 'planmode' }> }) => (
|
||||
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
|
||||
<ClipboardList className="h-3 w-3" />
|
||||
{message.ok ? (
|
||||
message.enabled ? (
|
||||
'Plan mode on — research only'
|
||||
) : (
|
||||
'Plan mode off'
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
Plan mode unchanged
|
||||
<span className="normal-case opacity-70">· {message.error ?? 'the agent refused'}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* A plan, with the two buttons the agent is waiting on.
|
||||
*
|
||||
* The only blocking row in the transcript: while `approved` is undefined there is a real `canUseTool`
|
||||
* promise parked in the sidecar, so this card is not a summary of something that happened — it is the thing
|
||||
* that has to happen next. It is styled as a card rather than a rule for exactly that reason; every other
|
||||
* marker here is scenery, and this one is a question.
|
||||
*
|
||||
* Rejection takes a reason and sends it, because the harness treats a denial message as revision feedback
|
||||
* ("revise your plan and call ExitPlanMode again") rather than as an abort. So the useful rejection is a
|
||||
* sentence, and the box is offered before the button rather than after it.
|
||||
*/
|
||||
const PlanCard = ({
|
||||
message,
|
||||
onDecide,
|
||||
}: {
|
||||
message: Extract<ChatMessage, { role: 'plan' }>;
|
||||
onDecide?: (requestId: string, approve: boolean, feedback?: string) => void;
|
||||
}) => {
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const decided = message.approved !== undefined;
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className={`w-full max-w-[85%] min-w-0 rounded-2xl border px-4 py-3 text-sm ${
|
||||
decided ? 'border-border bg-muted/50' : 'border-primary/40 bg-primary/5'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase text-muted-foreground">
|
||||
<ClipboardList className="h-3 w-3" />
|
||||
{message.approved === undefined
|
||||
? 'Plan — awaiting your approval'
|
||||
: message.approved
|
||||
? 'Plan approved'
|
||||
: 'Plan rejected'}
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto break-words">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MD_COMPONENTS}>
|
||||
{message.plan}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{/* The reason a rejected plan was rejected, kept on the card. It is what the agent was told, so the
|
||||
next plan is a response to it and the pair only makes sense read together. */}
|
||||
{decided && message.feedback && (
|
||||
<div className="mt-2 border-l-2 border-border pl-2 text-xs text-muted-foreground">{message.feedback}</div>
|
||||
)}
|
||||
|
||||
{!decided && onDecide && (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{rejecting && (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={feedback}
|
||||
onChange={(ev) => setFeedback(ev.target.value)}
|
||||
placeholder="What should change? The agent revises the plan from this."
|
||||
rows={2}
|
||||
className="w-full resize-none rounded border border-border bg-background px-2 py-1.5 text-xs outline-none focus:border-primary/50"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{rejecting && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRejecting(false)}
|
||||
className="cursor-pointer rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (rejecting ? onDecide(message.requestId, false, feedback.trim()) : setRejecting(true))}
|
||||
className="flex cursor-pointer items-center gap-1 rounded border border-border px-2 py-1 text-xs font-medium transition-colors hover:bg-muted"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
{rejecting ? 'Send feedback' : 'Reject'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDecide(message.requestId, true)}
|
||||
className="flex cursor-pointer items-center gap-1 rounded bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
Approve
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The wait between pressing send and the first token — tens of seconds with thinking enabled — used to
|
||||
* render nothing at all, because the streaming bubble bailed on empty text. The transcript looked frozen
|
||||
|
||||
@@ -24,6 +24,7 @@ export const MessageList = ({ manager }: MessageListProps) => {
|
||||
showJumpToBottom,
|
||||
jumpToBottom,
|
||||
sendPrompt,
|
||||
decidePlan,
|
||||
scrollViewportRef,
|
||||
bottomRef,
|
||||
isLoadingOlder,
|
||||
@@ -131,6 +132,7 @@ export const MessageList = ({ manager }: MessageListProps) => {
|
||||
message={row.message}
|
||||
onAnswer={(text) => sendPrompt(text)}
|
||||
onRetry={(prompt) => sendPrompt(prompt)}
|
||||
onDecidePlan={decidePlan}
|
||||
// Deliberately not `isGenerating`: a turn's work stays open after it finishes, and
|
||||
// closes when you send the next message and the turn folds behind its summary.
|
||||
defaultOpen={row.live && watched.current}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { CirclePlay } from 'lucide-react';
|
||||
import { CirclePlay, ClipboardList } from 'lucide-react';
|
||||
import { toneText } from '@/components/Data';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import type { ModelOption } from 'state/useModels';
|
||||
@@ -42,6 +42,15 @@ type ModelSelectorProps = {
|
||||
onThinkingChange?: (level: string | null) => void;
|
||||
/** Opens the background-job composer. Absent where the feature is not offered. */
|
||||
onBackgroundJob?: () => void;
|
||||
/**
|
||||
* Plan mode — research and propose, execute nothing until approved.
|
||||
*
|
||||
* Lives here rather than beside the send button because it belongs with the MODEL: `claude-code/opusplan`
|
||||
* is defined in terms of it (Opus while planning, Sonnet at rest), so the two controls are one decision
|
||||
* about what this turn will cost.
|
||||
*/
|
||||
planMode?: boolean;
|
||||
onPlanModeChange?: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export function ModelSelector({
|
||||
@@ -54,6 +63,8 @@ export function ModelSelector({
|
||||
isGenerating,
|
||||
hasStarted,
|
||||
onBackgroundJob,
|
||||
planMode = false,
|
||||
onPlanModeChange,
|
||||
}: ModelSelectorProps) {
|
||||
const providers = [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[];
|
||||
|
||||
@@ -194,6 +205,37 @@ export function ModelSelector({
|
||||
<CirclePlay className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
Plan mode.
|
||||
|
||||
Claude-only, and hidden rather than disabled for OpenCode: it has no plan mode and no permission-mode
|
||||
control request, so there is nothing a greyed-out button here would be promising.
|
||||
|
||||
The label carries the `opusplan` pairing, which is the one thing about this that is not guessable.
|
||||
`claude-code/opusplan` means "Opus while planning, Sonnet at rest" — so with plan mode OFF it is just
|
||||
Sonnet, quietly, and a user who picked it expecting Opus would never find that out. Officer does NOT
|
||||
auto-enter plan mode for it, because the CLI does not either; it says so instead.
|
||||
*/}
|
||||
{onPlanModeChange && currentHarness !== 'opencode' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPlanModeChange(!planMode)}
|
||||
disabled={!isConnected}
|
||||
aria-pressed={planMode}
|
||||
title={
|
||||
displayModel?.includes('opusplan') && !planMode
|
||||
? 'opusplan rests on Sonnet — turn on Plan for Opus'
|
||||
: planMode
|
||||
? 'Plan mode on — the agent researches and proposes, and edits nothing until you approve'
|
||||
: 'Plan first — research and propose before making any changes'
|
||||
}
|
||||
className={`shrink-0 cursor-pointer rounded-md p-1.5 transition-colors disabled:cursor-default disabled:opacity-40 ${
|
||||
planMode ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:bg-foreground/10'
|
||||
}`}
|
||||
>
|
||||
<ClipboardList className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
)}
|
||||
{/* The thinking toggle used to render here. It is gone because it did nothing: `thinking` is
|
||||
accepted on the wire (types.ts, websocket.ts) and forwarded by NEITHER channel — not
|
||||
send-claude-code.ts, not send-opencode.ts — so pressing it changed the label and nothing else.
|
||||
|
||||
@@ -20,7 +20,17 @@ import type { ChatMessage } from '../types';
|
||||
function isStructural(message: ChatMessage): boolean {
|
||||
// `model` belongs here for the same reason `compact` does: it is the answer to "which half of this
|
||||
// conversation did opus write", which is worthless folded away inside a turn summary.
|
||||
return message.role === 'divider' || message.role === 'compact' || message.role === 'model';
|
||||
//
|
||||
// `plan` belongs here for a stronger reason than any of the others: it is INTERACTIVE and the agent is
|
||||
// blocked on it. Folding one away behind a turn summary would hide the buttons a parked turn is waiting
|
||||
// for, and the symptom would be a chat that appears to have stopped for no reason.
|
||||
return (
|
||||
message.role === 'divider' ||
|
||||
message.role === 'compact' ||
|
||||
message.role === 'model' ||
|
||||
message.role === 'planmode' ||
|
||||
message.role === 'plan'
|
||||
);
|
||||
}
|
||||
|
||||
/** The work of a turn: everything that belongs behind the summary row. */
|
||||
|
||||
@@ -104,6 +104,31 @@ export type ChatMessage =
|
||||
to: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
| {
|
||||
// Where plan mode went on or off. Same reasoning as `model` above: it is a boundary, and without it a
|
||||
// stretch of transcript where the agent researched but refused to edit anything reads as an agent
|
||||
// that lost its nerve.
|
||||
role: 'planmode';
|
||||
enabled: boolean;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* A plan, and its decision, in one row that resolves in place — the same shape as `task` and
|
||||
* `compact`.
|
||||
*
|
||||
* This is the only row in the transcript that is INTERACTIVE and BLOCKING: while `approved` is
|
||||
* undefined an agent is genuinely parked on a `canUseTool` promise waiting for one of these buttons.
|
||||
* That is why the decision resolves onto this row instead of appending a second one — two bubbles
|
||||
* would leave the original card sitting there still offering to approve something already approved.
|
||||
*/
|
||||
role: 'plan';
|
||||
requestId: string;
|
||||
plan: string;
|
||||
approved?: boolean;
|
||||
feedback?: string;
|
||||
};
|
||||
|
||||
export type RunningTask = { taskId: string; description: string; taskType?: string };
|
||||
@@ -167,7 +192,12 @@ export type ServerMessage =
|
||||
| { type: 'compact:start'; trigger: 'manual' | 'auto' }
|
||||
| { type: 'compact:done'; trigger: 'manual' | 'auto'; preTokens: number; durationMs?: number }
|
||||
/** The conversation moved onto a different model. See the `model` message role. */
|
||||
| { type: 'model:switched'; from: string | null; to: string; ok: boolean; error?: string };
|
||||
| { type: 'model:switched'; from: string | null; to: string; ok: boolean; error?: string }
|
||||
/** Plan mode went on or off. See the `planmode` message role. */
|
||||
| { type: 'plan:mode'; enabled: boolean; ok: boolean; error?: string }
|
||||
/** The agent is blocked, waiting for this plan to be approved. See the `plan` message role. */
|
||||
| { type: 'plan:proposed'; requestId: string; plan: string }
|
||||
| { type: 'plan:decided'; requestId: string; approved: boolean; feedback?: string };
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
|
||||
@@ -30,6 +30,8 @@ export type SelectedSession = {
|
||||
title?: string | null;
|
||||
/** Transcripts spliced into `initialMessages`; `> 1` when this is a merged `/clear` chain. */
|
||||
partCount?: number;
|
||||
/** Whether this conversation was left in plan mode, so the composer toggle comes back lit. */
|
||||
planMode?: boolean;
|
||||
} | null;
|
||||
|
||||
type ChatLocationState = {
|
||||
@@ -140,10 +142,12 @@ type NewChatProps = {
|
||||
sessionCwd?: string | null;
|
||||
sessionTitle?: string | null;
|
||||
partCount?: number;
|
||||
/** Plan mode as this session was left. Only the stored row knows — the transcript records nothing. */
|
||||
planMode?: boolean;
|
||||
};
|
||||
|
||||
function NewChat(props: NewChatProps) {
|
||||
const { resumeSummary, resumeSessionId, initialMessages, total, initialOffset, sessionCwd } = props;
|
||||
const { resumeSummary, resumeSessionId, initialMessages, total, initialOffset, sessionCwd, planMode } = props;
|
||||
const location = useLocation();
|
||||
const locationState = location.state as ChatLocationState;
|
||||
const { invalidate: invalidateClaudeSessions } = useClaudeSessions();
|
||||
@@ -161,6 +165,7 @@ function NewChat(props: NewChatProps) {
|
||||
initialMessages,
|
||||
onTurnComplete,
|
||||
context: 'chat',
|
||||
initialPlanMode: planMode,
|
||||
// Only the tail is loaded up front — let the chat page older messages upward on scroll.
|
||||
paginate:
|
||||
resumeSessionId && typeof total === 'number'
|
||||
@@ -261,6 +266,7 @@ export const ChatDetailPanel = () => {
|
||||
sessionCwd={selected.cwd}
|
||||
sessionTitle={title}
|
||||
partCount={selected.partCount}
|
||||
planMode={selected.planMode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Check, Folder, Layers, Loader2, Pencil, Trash2, X } from 'lucide-react';
|
||||
import { Check, ClipboardList, Folder, Layers, Loader2, Pencil, Trash2, X } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { DataRow, MetaItem, RelativeTime } from '@/components/Data';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
@@ -47,6 +47,22 @@ export const ModelBadge = ({ model }: { model?: string | null }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* This conversation plans before it acts.
|
||||
*
|
||||
* Worth a badge of its own rather than folding into `ModelBadge`, because it is not a tier — the same model
|
||||
* behaves completely differently under it, and `opusplan` in particular is only Opus while this is on. So a
|
||||
* row reading `opusplan` with no plan badge is a row running Sonnet, which is exactly the thing that is
|
||||
* impossible to work out from the model name alone.
|
||||
*/
|
||||
export const PlanBadge = ({ planMode }: { planMode?: boolean }) =>
|
||||
planMode ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 font-medium text-primary">
|
||||
<ClipboardList className="h-3 w-3" />
|
||||
plan
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
/**
|
||||
* This conversation has an agent behind it right now.
|
||||
*
|
||||
@@ -218,6 +234,7 @@ export const SessionRow = ({
|
||||
<RelativeTime key="t" value={session.updatedAt} />,
|
||||
<HarnessBadge key="h" harness={session.harness} />,
|
||||
<ModelBadge key="m" model={session.model} />,
|
||||
<PlanBadge key="pm" planMode={session.planMode} />,
|
||||
// Claude's transcripts give a real turn count. OpenCode's session list has no count field, so the
|
||||
// number on those rows is a hardcoded 0 and costs an HTTP call per row to make real — the badge
|
||||
// stands in its place rather than beside a zero that means "not asked".
|
||||
|
||||
@@ -30,6 +30,8 @@ type UsePiChatOptions = {
|
||||
// When set, `initialMessages` is only the tail of a long transcript; scroll-up pages older ones in.
|
||||
paginate?: { sessionId: string; total: number; initialOffset: number };
|
||||
onTurnComplete?: (hadToolCalls: boolean) => void;
|
||||
/** Plan mode as the session was left, from `chat_session_models` — see `initialPlanMode` in useChat. */
|
||||
initialPlanMode?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -62,6 +64,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
initialMessages: preloadedMessages,
|
||||
paginate,
|
||||
onTurnComplete,
|
||||
initialPlanMode,
|
||||
} = options ?? {};
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(preloadedMessages ?? []);
|
||||
/**
|
||||
@@ -88,6 +91,15 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(initialModel ?? null);
|
||||
const [cwd, setCwd] = useState<string | null>(null);
|
||||
const [thinkingLevel, setThinkingLevel] = useState<string | null>(null);
|
||||
/*
|
||||
Plan mode, seeded from the stored row so a refresh comes back planning.
|
||||
|
||||
A ref alongside the state for the same reason `modelRef` has one: `sendPrompt` is called from closures
|
||||
built a render earlier, and the turn must carry what the toggle says NOW rather than what it said when
|
||||
that closure was created.
|
||||
*/
|
||||
const [planMode, setPlanModeState] = useState(initialPlanMode === true);
|
||||
const planModeRef = useRef(initialPlanMode === true);
|
||||
|
||||
// Resilient-transport cursor: the max durable-event `seq` seen. On (re)connect we send it as a
|
||||
// resume-cursor so the server replays anything we missed (e.g. a background task:notification that
|
||||
@@ -119,6 +131,15 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
if (initialModel) setSelectedModel(initialModel);
|
||||
}, [initialModel]);
|
||||
|
||||
// Same, for plan mode: the detail request that carries it resolves after the first render, so the toggle
|
||||
// has to follow it in. Unlike the model above this syncs `false` as well — plan mode being OFF is a real
|
||||
// answer about a session, not the absence of one.
|
||||
useEffect(() => {
|
||||
if (initialPlanMode === undefined) return;
|
||||
setPlanModeState(initialPlanMode);
|
||||
planModeRef.current = initialPlanMode;
|
||||
}, [initialPlanMode]);
|
||||
|
||||
const streamingRef = useRef('');
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
|
||||
@@ -483,6 +504,49 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
}
|
||||
break;
|
||||
|
||||
case 'plan:mode':
|
||||
// Committed first, like every other boundary marker: the notice says where the rules changed, so
|
||||
// whatever was streamed under the old ones has to land above it.
|
||||
commitStreaming();
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'planmode', enabled: msg.enabled, ok: msg.ok, ...(msg.error ? { error: msg.error } : {}) },
|
||||
]);
|
||||
// Follow the harness, not the click. This covers the refusal AND the ordinary exit: approving a plan
|
||||
// takes the session out of plan mode server-side, and without this the toggle would stay lit over a
|
||||
// session that has gone back to editing.
|
||||
if (msg.ok) {
|
||||
setPlanModeState(msg.enabled);
|
||||
planModeRef.current = msg.enabled;
|
||||
} else {
|
||||
setPlanModeState(!msg.enabled);
|
||||
planModeRef.current = !msg.enabled;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'plan:proposed':
|
||||
commitStreaming();
|
||||
setMessages((prev) => [...prev, { role: 'plan', requestId: msg.requestId, plan: msg.plan }]);
|
||||
break;
|
||||
|
||||
case 'plan:decided':
|
||||
// Resolved ONTO the card rather than appended, so the buttons go away with the decision. Also the
|
||||
// path by which a second pane learns that the plan it is showing has already been answered
|
||||
// elsewhere — the decision is broadcast to every socket on the conversation, not just the one that
|
||||
// clicked.
|
||||
setMessages((prev) => {
|
||||
const at = prev.findLastIndex((m) => m.role === 'plan' && m.requestId === msg.requestId);
|
||||
if (at < 0) return prev;
|
||||
const next = [...prev];
|
||||
next[at] = {
|
||||
...(prev[at] as Extract<ChatMessage, { role: 'plan' }>),
|
||||
approved: msg.approved,
|
||||
feedback: msg.feedback,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'compact:done':
|
||||
setMessages((prev) => {
|
||||
const at = prev.findLastIndex((m) => m.role === 'compact' && !m.done);
|
||||
@@ -653,9 +717,38 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
...(contextId ? { contextId } : {}),
|
||||
...(pendingResumeSummary ? { resumeSummary: pendingResumeSummary } : {}),
|
||||
...(resumeSessionId ? { resumeSessionId } : {}),
|
||||
// Always sent, never conditional. This is the ONLY channel that can put a brand-new conversation into
|
||||
// plan mode — there is no live session to control and no stored row to update before the first turn —
|
||||
// and sending it only when true would mean a session could never be turned back off this way either.
|
||||
planMode: planModeRef.current,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn plan mode on or off between turns.
|
||||
*
|
||||
* Optimistic, and corrected by the `plan:mode` the server always answers with: the toggle has to respond
|
||||
* to the click immediately, but the harness is entitled to refuse, and the handler above puts it back when
|
||||
* it does.
|
||||
*
|
||||
* `claudeSessionId` rides along so the preference can be stored right away — the client is the only party
|
||||
* that has it between turns (it is in the URL; officer learns it per-turn).
|
||||
*/
|
||||
function setPlanMode(enabled: boolean) {
|
||||
setPlanModeState(enabled);
|
||||
planModeRef.current = enabled;
|
||||
send({
|
||||
type: 'plan:mode',
|
||||
enabled,
|
||||
...(claudeSessionIdRef.current ? { claudeSessionId: claudeSessionIdRef.current } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Answer a plan the agent is blocked on. `feedback` on a rejection is a revision request, not an abort. */
|
||||
function decidePlan(requestId: string, approve: boolean, feedback?: string) {
|
||||
send({ type: 'plan:decide', requestId, approve, ...(feedback ? { feedback } : {}) });
|
||||
}
|
||||
|
||||
function stopGeneration() {
|
||||
send({ type: 'stop' });
|
||||
}
|
||||
@@ -678,8 +771,11 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
hasStarted,
|
||||
cwd,
|
||||
thinkingLevel,
|
||||
planMode,
|
||||
setSelectedModel,
|
||||
setThinkingLevel,
|
||||
setPlanMode,
|
||||
decidePlan,
|
||||
sendPrompt,
|
||||
stopGeneration,
|
||||
disconnectSession,
|
||||
|
||||
@@ -37,6 +37,8 @@ export type ClaudeSessionSummary = {
|
||||
* yet, and the row shows no model rather than guessing.
|
||||
*/
|
||||
model?: string | null;
|
||||
/** Whether this conversation is in plan mode. Absent on older rows; false is the default everywhere. */
|
||||
planMode?: boolean;
|
||||
};
|
||||
|
||||
// Display-ready message, matching the frontend ChatMessage union (rebuilt from Claude's transcript).
|
||||
@@ -75,6 +77,13 @@ export type ClaudeSessionDetail = {
|
||||
* catalogue entry — which is the bug this field exists to fix.
|
||||
*/
|
||||
officerModel: string | null;
|
||||
/**
|
||||
* Whether this conversation is in plan mode.
|
||||
*
|
||||
* Like `officerModel`, only the stored row can answer: plan mode leaves no trace in a transcript at all,
|
||||
* so a session resumed without this comes back with the toggle off and quietly starts editing again.
|
||||
*/
|
||||
planMode: boolean;
|
||||
cwd: string;
|
||||
messages: ClaudeSessionMessage[];
|
||||
total: number; // full transcript length (the loaded messages may be a tail window)
|
||||
|
||||
Reference in New Issue
Block a user