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
61 changes: 46 additions & 15 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -649,17 +649,14 @@ function isVisibleElement(element: Element | null): element is HTMLElement {
);
}

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

const compactMenuTrigger = document.querySelector<HTMLButtonElement>(
Expand All @@ -672,7 +669,7 @@ async function readCurrentInteractionModeLabel(): Promise<"Chat" | "Code" | "Pla
'[role="menuitemradio"][aria-checked="true"]',
);
const radioLabel = selectedRadio?.textContent?.trim();
if (radioLabel === "Chat" || radioLabel === "Code" || radioLabel === "Plan") {
if (radioLabel === "Code" || radioLabel === "Plan") {
return radioLabel;
}
}
Expand Down Expand Up @@ -1291,7 +1288,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
try {
await vi.waitFor(
async () => {
expect(await readCurrentInteractionModeLabel()).toBe("Chat");
expect(await readCurrentInteractionModeLabel()).toBe("Code");
},
{ timeout: 8_000, interval: 16 },
);
Expand All @@ -1306,7 +1303,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
);
await waitForLayout();

expect(await readCurrentInteractionModeLabel()).toBe("Chat");
expect(await readCurrentInteractionModeLabel()).toBe("Code");

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

await vi.waitFor(
async () => {
expect(await readCurrentInteractionModeLabel()).toBe("Chat");
expect(await readCurrentInteractionModeLabel()).toBe("Code");
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await mounted.cleanup();
}
});

it("renders a direct code or plan mode switch and normalizes legacy chat threads to code", async () => {
const mounted = await mountChatView({
viewport: WIDE_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-target-mode-buttons" as MessageId,
targetText: "mode button target",
}),
});

try {
await vi.waitFor(
async () => {
expect(await readCurrentInteractionModeLabel()).toBe("Code");
},
{ timeout: 8_000, interval: 16 },
);

const planButton = document.querySelector<HTMLButtonElement>(
'[data-testid="thread-mode-plan"]',
);
expect(planButton).not.toBeNull();
planButton?.click();

await vi.waitFor(
async () => {
expect(await readCurrentInteractionModeLabel()).toBe("Plan");
},
{ timeout: 8_000, interval: 16 },
);
Expand Down
87 changes: 57 additions & 30 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,13 @@ const terminalContextIdListsEqual = (
): boolean =>
contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);

const INTERACTION_MODE_CYCLE: readonly ProviderInteractionMode[] = ["chat", "code", "plan"];
const INTERACTION_MODE_OPTIONS: readonly ProviderInteractionMode[] = ["code", "plan"];

function normalizeVisibleInteractionMode(
mode: ProviderInteractionMode | null | undefined,
): ProviderInteractionMode {
return mode === "plan" ? "plan" : "code";
}

interface ChatViewProps {
threadId: ThreadId;
Expand Down Expand Up @@ -717,8 +723,9 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
const activeThread = serverThread ?? localDraftThread;
const runtimeMode =
composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
const interactionMode =
composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
const interactionMode = normalizeVisibleInteractionMode(
composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE,
);
const isServerThread = serverThread !== undefined;
const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
const canCheckoutPullRequestIntoThread = isLocalDraftThread;
Expand Down Expand Up @@ -1432,13 +1439,6 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
label: "/plan",
description: "Switch this thread into plan mode",
},
{
id: "slash:chat",
type: "slash-command",
command: "chat",
label: "/chat",
description: "Switch this thread into chat mode",
},
{
id: "slash:code",
type: "slash-command",
Expand Down Expand Up @@ -2175,8 +2175,8 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
],
);
const toggleInteractionMode = useCallback(() => {
const idx = Math.max(0, INTERACTION_MODE_CYCLE.indexOf(interactionMode));
const next = INTERACTION_MODE_CYCLE[(idx + 1) % INTERACTION_MODE_CYCLE.length]!;
const idx = Math.max(0, INTERACTION_MODE_OPTIONS.indexOf(interactionMode));
const next = INTERACTION_MODE_OPTIONS[(idx + 1) % INTERACTION_MODE_OPTIONS.length]!;
handleInteractionModeChange(next);
}, [handleInteractionModeChange, interactionMode]);
const toggleRuntimeMode = useCallback(() => {
Expand Down Expand Up @@ -4072,9 +4072,9 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
createdAt: messageCreatedAt,
});
// Optimistically open the plan sidebar when implementing (not refining).
// Chat/code mode here means the agent is executing the plan, which produces
// Code mode means the agent is executing the plan, which produces
// step-tracking activities that the sidebar will display.
if (nextInteractionMode === "chat" || nextInteractionMode === "code") {
if (nextInteractionMode === "code") {
planSidebarDismissedForTurnRef.current = null;
setPlanSidebarOpen(true);
}
Expand Down Expand Up @@ -5353,23 +5353,50 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
className="mx-0.5 hidden h-4 sm:block"
/>

<Button
variant="ghost"
className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
size="sm"
type="button"
onClick={toggleInteractionMode}
title="Cycle interaction mode: Chat → Code → Plan"
<div
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)]"
aria-label="Thread mode"
role="group"
>
<BotIcon />
<span className="sr-only sm:not-sr-only">
{interactionMode === "plan"
? "Plan"
: interactionMode === "code"
? "Code"
: "Chat"}
</span>
</Button>
<Button
variant={interactionMode === "code" ? "secondary" : "ghost"}
className={cn(
"h-7 gap-1.5 rounded-lg px-2.5 text-xs sm:h-8 sm:px-3",
interactionMode === "code"
? "bg-foreground text-background hover:bg-foreground/90 hover:text-background"
: "text-muted-foreground hover:text-foreground",
)}
size="sm"
type="button"
aria-pressed={interactionMode === "code"}
aria-label="Code mode"
data-testid="thread-mode-code"
title="Code mode"
onClick={() => handleInteractionModeChange("code")}
>
<BotIcon className="size-3.5" />
<span>Code</span>
</Button>
<Button
variant={interactionMode === "plan" ? "secondary" : "ghost"}
className={cn(
"h-7 gap-1.5 rounded-lg px-2.5 text-xs sm:h-8 sm:px-3",
interactionMode === "plan"
? "bg-blue-500/14 text-blue-200 ring-1 ring-inset ring-blue-400/40 hover:bg-blue-500/18 hover:text-blue-100"
: "text-muted-foreground hover:text-foreground",
)}
size="sm"
type="button"
aria-pressed={interactionMode === "plan"}
aria-label="Plan mode"
data-testid="thread-mode-plan"
title="Plan mode"
onClick={() => handleInteractionModeChange("plan")}
>
<ListTodoIcon className="size-3.5" />
<span>Plan</span>
</Button>
</div>

<Separator
orientation="vertical"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ async function mountMenu(props?: {
const screen = await render(
<CompactComposerControlsMenu
activePlan={false}
interactionMode="chat"
interactionMode="code"
planSidebarOpen={false}
runtimeMode="approval-required"
traitsMenuContent={
Expand Down
3 changes: 1 addition & 2 deletions apps/web/src/components/chat/CompactComposerControlsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,11 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
value={props.interactionMode}
onValueChange={(value) => {
if (!value || value === props.interactionMode) return;
if (value === "chat" || value === "code" || value === "plan") {
if (value === "code" || value === "plan") {
props.onInteractionModeChange(value);
}
}}
>
<MenuRadioItem value="chat">Chat</MenuRadioItem>
<MenuRadioItem value="code">Code</MenuRadioItem>
<MenuRadioItem value="plan">Plan</MenuRadioItem>
</MenuRadioGroup>
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/composer-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,16 +241,16 @@ describe("parseStandaloneComposerSlashCommand", () => {
expect(parseStandaloneComposerSlashCommand(" /plan ")).toBe("plan");
});

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

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

it("maps legacy /default to chat mode", () => {
expect(parseStandaloneComposerSlashCommand("/default")).toBe("chat");
it("maps legacy /default to code mode", () => {
expect(parseStandaloneComposerSlashCommand("/default")).toBe("code");
});

it("ignores slash commands with extra message text", () => {
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/composer-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export interface ComposerTrigger {
rangeEnd: number;
}

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

const SKILL_MANAGEMENT_COMMANDS = new Set<SkillManagementSubcommand>([
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type {
export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";

export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "chat";
export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "code";
export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
export const DEFAULT_THREAD_TERMINAL_ID = "default";
export const MAX_TERMINALS_PER_GROUP = 4;
Expand Down
Loading