Skip to content

Commit e66d255

Browse files
authored
Simplify thread mode controls to code and plan (#381)
- Replace the single chat/code/plan toggle with explicit code and plan buttons - Normalize legacy chat/default modes to code in composer logic and tests - Update the default interaction mode and remove the /chat slash command
1 parent 0a32dc0 commit e66d255

7 files changed

Lines changed: 113 additions & 56 deletions

File tree

apps/web/src/components/ChatView.browser.tsx

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -649,17 +649,14 @@ function isVisibleElement(element: Element | null): element is HTMLElement {
649649
);
650650
}
651651

652-
async function readCurrentInteractionModeLabel(): Promise<"Chat" | "Code" | "Plan"> {
653-
const inlineButton = Array.from(document.querySelectorAll("button")).find((button) => {
654-
const label = button.textContent?.trim();
655-
return (
656-
button.getAttribute("title") === "Cycle interaction mode: Chat → Code → Plan" &&
657-
(label === "Chat" || label === "Code" || label === "Plan")
658-
);
659-
});
660-
const inlineLabel = inlineButton?.textContent?.trim();
661-
if (inlineLabel === "Chat" || inlineLabel === "Code" || inlineLabel === "Plan") {
662-
return inlineLabel;
652+
async function readCurrentInteractionModeLabel(): Promise<"Code" | "Plan"> {
653+
const codeButton = document.querySelector<HTMLButtonElement>('[data-testid="thread-mode-code"]');
654+
const planButton = document.querySelector<HTMLButtonElement>('[data-testid="thread-mode-plan"]');
655+
if (codeButton?.getAttribute("aria-pressed") === "true") {
656+
return "Code";
657+
}
658+
if (planButton?.getAttribute("aria-pressed") === "true") {
659+
return "Plan";
663660
}
664661

665662
const compactMenuTrigger = document.querySelector<HTMLButtonElement>(
@@ -672,7 +669,7 @@ async function readCurrentInteractionModeLabel(): Promise<"Chat" | "Code" | "Pla
672669
'[role="menuitemradio"][aria-checked="true"]',
673670
);
674671
const radioLabel = selectedRadio?.textContent?.trim();
675-
if (radioLabel === "Chat" || radioLabel === "Code" || radioLabel === "Plan") {
672+
if (radioLabel === "Code" || radioLabel === "Plan") {
676673
return radioLabel;
677674
}
678675
}
@@ -1291,7 +1288,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
12911288
try {
12921289
await vi.waitFor(
12931290
async () => {
1294-
expect(await readCurrentInteractionModeLabel()).toBe("Chat");
1291+
expect(await readCurrentInteractionModeLabel()).toBe("Code");
12951292
},
12961293
{ timeout: 8_000, interval: 16 },
12971294
);
@@ -1306,7 +1303,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
13061303
);
13071304
await waitForLayout();
13081305

1309-
expect(await readCurrentInteractionModeLabel()).toBe("Chat");
1306+
expect(await readCurrentInteractionModeLabel()).toBe("Code");
13101307

13111308
const composerEditor = await waitForComposerEditor();
13121309
composerEditor.focus();
@@ -1337,7 +1334,41 @@ describe("ChatView timeline estimator parity (full app)", () => {
13371334

13381335
await vi.waitFor(
13391336
async () => {
1340-
expect(await readCurrentInteractionModeLabel()).toBe("Chat");
1337+
expect(await readCurrentInteractionModeLabel()).toBe("Code");
1338+
},
1339+
{ timeout: 8_000, interval: 16 },
1340+
);
1341+
} finally {
1342+
await mounted.cleanup();
1343+
}
1344+
});
1345+
1346+
it("renders a direct code or plan mode switch and normalizes legacy chat threads to code", async () => {
1347+
const mounted = await mountChatView({
1348+
viewport: WIDE_VIEWPORT,
1349+
snapshot: createSnapshotForTargetUser({
1350+
targetMessageId: "msg-user-target-mode-buttons" as MessageId,
1351+
targetText: "mode button target",
1352+
}),
1353+
});
1354+
1355+
try {
1356+
await vi.waitFor(
1357+
async () => {
1358+
expect(await readCurrentInteractionModeLabel()).toBe("Code");
1359+
},
1360+
{ timeout: 8_000, interval: 16 },
1361+
);
1362+
1363+
const planButton = document.querySelector<HTMLButtonElement>(
1364+
'[data-testid="thread-mode-plan"]',
1365+
);
1366+
expect(planButton).not.toBeNull();
1367+
planButton?.click();
1368+
1369+
await vi.waitFor(
1370+
async () => {
1371+
expect(await readCurrentInteractionModeLabel()).toBe("Plan");
13411372
},
13421373
{ timeout: 8_000, interval: 16 },
13431374
);

apps/web/src/components/ChatView.tsx

Lines changed: 57 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,13 @@ const terminalContextIdListsEqual = (
385385
): boolean =>
386386
contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);
387387

388-
const INTERACTION_MODE_CYCLE: readonly ProviderInteractionMode[] = ["chat", "code", "plan"];
388+
const INTERACTION_MODE_OPTIONS: readonly ProviderInteractionMode[] = ["code", "plan"];
389+
390+
function normalizeVisibleInteractionMode(
391+
mode: ProviderInteractionMode | null | undefined,
392+
): ProviderInteractionMode {
393+
return mode === "plan" ? "plan" : "code";
394+
}
389395

390396
interface ChatViewProps {
391397
threadId: ThreadId;
@@ -717,8 +723,9 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
717723
const activeThread = serverThread ?? localDraftThread;
718724
const runtimeMode =
719725
composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
720-
const interactionMode =
721-
composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
726+
const interactionMode = normalizeVisibleInteractionMode(
727+
composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE,
728+
);
722729
const isServerThread = serverThread !== undefined;
723730
const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
724731
const canCheckoutPullRequestIntoThread = isLocalDraftThread;
@@ -1432,13 +1439,6 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
14321439
label: "/plan",
14331440
description: "Switch this thread into plan mode",
14341441
},
1435-
{
1436-
id: "slash:chat",
1437-
type: "slash-command",
1438-
command: "chat",
1439-
label: "/chat",
1440-
description: "Switch this thread into chat mode",
1441-
},
14421442
{
14431443
id: "slash:code",
14441444
type: "slash-command",
@@ -2175,8 +2175,8 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
21752175
],
21762176
);
21772177
const toggleInteractionMode = useCallback(() => {
2178-
const idx = Math.max(0, INTERACTION_MODE_CYCLE.indexOf(interactionMode));
2179-
const next = INTERACTION_MODE_CYCLE[(idx + 1) % INTERACTION_MODE_CYCLE.length]!;
2178+
const idx = Math.max(0, INTERACTION_MODE_OPTIONS.indexOf(interactionMode));
2179+
const next = INTERACTION_MODE_OPTIONS[(idx + 1) % INTERACTION_MODE_OPTIONS.length]!;
21802180
handleInteractionModeChange(next);
21812181
}, [handleInteractionModeChange, interactionMode]);
21822182
const toggleRuntimeMode = useCallback(() => {
@@ -4072,9 +4072,9 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
40724072
createdAt: messageCreatedAt,
40734073
});
40744074
// Optimistically open the plan sidebar when implementing (not refining).
4075-
// Chat/code mode here means the agent is executing the plan, which produces
4075+
// Code mode means the agent is executing the plan, which produces
40764076
// step-tracking activities that the sidebar will display.
4077-
if (nextInteractionMode === "chat" || nextInteractionMode === "code") {
4077+
if (nextInteractionMode === "code") {
40784078
planSidebarDismissedForTurnRef.current = null;
40794079
setPlanSidebarOpen(true);
40804080
}
@@ -5353,23 +5353,50 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
53535353
className="mx-0.5 hidden h-4 sm:block"
53545354
/>
53555355

5356-
<Button
5357-
variant="ghost"
5358-
className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
5359-
size="sm"
5360-
type="button"
5361-
onClick={toggleInteractionMode}
5362-
title="Cycle interaction mode: Chat → Code → Plan"
5356+
<div
5357+
className="inline-flex shrink-0 items-center gap-1 rounded-xl border border-border/70 bg-card/80 p-1 shadow-[inset_0_1px_0_hsl(0_0%_100%/0.03)]"
5358+
aria-label="Thread mode"
5359+
role="group"
53635360
>
5364-
<BotIcon />
5365-
<span className="sr-only sm:not-sr-only">
5366-
{interactionMode === "plan"
5367-
? "Plan"
5368-
: interactionMode === "code"
5369-
? "Code"
5370-
: "Chat"}
5371-
</span>
5372-
</Button>
5361+
<Button
5362+
variant={interactionMode === "code" ? "secondary" : "ghost"}
5363+
className={cn(
5364+
"h-7 gap-1.5 rounded-lg px-2.5 text-xs sm:h-8 sm:px-3",
5365+
interactionMode === "code"
5366+
? "bg-foreground text-background hover:bg-foreground/90 hover:text-background"
5367+
: "text-muted-foreground hover:text-foreground",
5368+
)}
5369+
size="sm"
5370+
type="button"
5371+
aria-pressed={interactionMode === "code"}
5372+
aria-label="Code mode"
5373+
data-testid="thread-mode-code"
5374+
title="Code mode"
5375+
onClick={() => handleInteractionModeChange("code")}
5376+
>
5377+
<BotIcon className="size-3.5" />
5378+
<span>Code</span>
5379+
</Button>
5380+
<Button
5381+
variant={interactionMode === "plan" ? "secondary" : "ghost"}
5382+
className={cn(
5383+
"h-7 gap-1.5 rounded-lg px-2.5 text-xs sm:h-8 sm:px-3",
5384+
interactionMode === "plan"
5385+
? "bg-blue-500/14 text-blue-200 ring-1 ring-inset ring-blue-400/40 hover:bg-blue-500/18 hover:text-blue-100"
5386+
: "text-muted-foreground hover:text-foreground",
5387+
)}
5388+
size="sm"
5389+
type="button"
5390+
aria-pressed={interactionMode === "plan"}
5391+
aria-label="Plan mode"
5392+
data-testid="thread-mode-plan"
5393+
title="Plan mode"
5394+
onClick={() => handleInteractionModeChange("plan")}
5395+
>
5396+
<ListTodoIcon className="size-3.5" />
5397+
<span>Plan</span>
5398+
</Button>
5399+
</div>
53735400

53745401
<Separator
53755402
orientation="vertical"

apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ async function mountMenu(props?: {
4646
const screen = await render(
4747
<CompactComposerControlsMenu
4848
activePlan={false}
49-
interactionMode="chat"
49+
interactionMode="code"
5050
planSidebarOpen={false}
5151
runtimeMode="approval-required"
5252
traitsMenuContent={

apps/web/src/components/chat/CompactComposerControlsMenu.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,11 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
4848
value={props.interactionMode}
4949
onValueChange={(value) => {
5050
if (!value || value === props.interactionMode) return;
51-
if (value === "chat" || value === "code" || value === "plan") {
51+
if (value === "code" || value === "plan") {
5252
props.onInteractionModeChange(value);
5353
}
5454
}}
5555
>
56-
<MenuRadioItem value="chat">Chat</MenuRadioItem>
5756
<MenuRadioItem value="code">Code</MenuRadioItem>
5857
<MenuRadioItem value="plan">Plan</MenuRadioItem>
5958
</MenuRadioGroup>

apps/web/src/composer-logic.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -241,16 +241,16 @@ describe("parseStandaloneComposerSlashCommand", () => {
241241
expect(parseStandaloneComposerSlashCommand(" /plan ")).toBe("plan");
242242
});
243243

244-
it("parses standalone /chat command", () => {
245-
expect(parseStandaloneComposerSlashCommand("/chat")).toBe("chat");
244+
it("maps legacy /chat command to code mode", () => {
245+
expect(parseStandaloneComposerSlashCommand("/chat")).toBe("code");
246246
});
247247

248248
it("parses standalone /code command", () => {
249249
expect(parseStandaloneComposerSlashCommand("/code")).toBe("code");
250250
});
251251

252-
it("maps legacy /default to chat mode", () => {
253-
expect(parseStandaloneComposerSlashCommand("/default")).toBe("chat");
252+
it("maps legacy /default to code mode", () => {
253+
expect(parseStandaloneComposerSlashCommand("/default")).toBe("code");
254254
});
255255

256256
it("ignores slash commands with extra message text", () => {

apps/web/src/composer-logic.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export interface ComposerTrigger {
2525
rangeEnd: number;
2626
}
2727

28-
const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "chat", "code", "skill"];
28+
const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "code", "skill"];
2929
const isInlineTokenSegment = (
3030
segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" },
3131
): boolean => segment.type !== "text";
@@ -290,8 +290,8 @@ export function parseStandaloneComposerSlashCommand(
290290
if (command === "plan") return "plan";
291291
if (command === "code") return "code";
292292
if (command === "skill") return "skill";
293-
// `/default` is a legacy alias for chat mode
294-
return "chat";
293+
// `/chat` and `/default` are legacy aliases for code mode.
294+
return "code";
295295
}
296296

297297
const SKILL_MANAGEMENT_COMMANDS = new Set<SkillManagementSubcommand>([

apps/web/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type {
1818
export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
1919
export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
2020

21-
export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "chat";
21+
export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "code";
2222
export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
2323
export const DEFAULT_THREAD_TERMINAL_ID = "default";
2424
export const MAX_TERMINALS_PER_GROUP = 4;

0 commit comments

Comments
 (0)