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
21 changes: 21 additions & 0 deletions docs/chat-composer-right-controls/change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Chat composer right-side controls

## Intent

Move the model and thinking-level selectors out of the left side of the chat composer footer and into the right-aligned action cluster, without changing any composer behavior.

## Spec

- Keep model selection before thinking-level selection.
- Place both selectors in the right-side action group before the optional byte counter and the send, follow-up, or stop action.
- Preserve selector, send, follow-up, stop, disabled, search, and accessibility behavior.
- Keep the action group wrappable and constrained to the composer width so narrow layouts do not overflow or hide controls.
- Do not change model controls outside `ChatComposer`.

## Plan

1. Add a layout regression test that asserts the right-side group membership and DOM order, including the byte counter.
2. Group the existing composer footer controls in a right-aligned wrapping container and adjust only the related CSS flex rules.
3. Run the focused `ChatComposer` tests, formatting, lint, type checking, and the project verification gate.

Risk: standard, because this is a localized user-visible Renderer layout change with multiple acceptance criteria and no API, persistence, or security impact.
61 changes: 58 additions & 3 deletions src/features/chat/ChatComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,54 @@ describe("ChatComposer", () => {
expect(onThinkingLevelChange).toHaveBeenCalledWith("xhigh");
});

it("orders selectors, byte count, and submit within the right action group", () => {
const { container } = render(
<ChatComposer
running={false}
initialValue={"x".repeat(52_429)}
config={{
model: { provider: "traex", id: "reasoning-model" },
models: [
{
provider: "traex",
id: "reasoning-model",
label: "Reasoning Model",
reasoning: true,
},
],
thinkingLevel: "medium",
availableThinkingLevels: ["off", "medium"],
commands: [],
}}
onSend={vi.fn()}
onStop={vi.fn()}
/>,
);

const actions = container.querySelector<HTMLElement>(
".chat-composer-actions",
);
const rightActions = container.querySelector<HTMLElement>(
".chat-composer-right-actions",
);
const config = container.querySelector<HTMLElement>(
".chat-composer-config",
);
const count = container.querySelector<HTMLElement>(".chat-composer-count");
const submit = screen.getByRole("button", { name: "Send" });

expect(actions).toContainElement(rightActions);
expect(rightActions).toContainElement(config);
expect(config?.querySelectorAll('[role="combobox"]')).toHaveLength(2);
expect(rightActions).toContainElement(count);
expect(rightActions).toContainElement(submit);
expect(Array.from(rightActions?.children ?? [])).toEqual([
config,
count,
submit,
]);
});

it("filters and completes runtime slash commands with keyboard controls", async () => {
render(
<ChatComposer
Expand Down Expand Up @@ -223,17 +271,24 @@ describe("ChatComposer", () => {
const actions = container.querySelector<HTMLElement>(
".chat-composer-actions",
);
const rightActions = container.querySelector<HTMLElement>(
".chat-composer-right-actions",
);
const config = container.querySelector<HTMLElement>(
".chat-composer-config",
);
expect(actions).toContainElement(config);
expect(actions).toContainElement(rightActions);
expect(rightActions).toContainElement(config);
expect(config?.querySelectorAll('[role="combobox"]')).toHaveLength(2);
expect(actions?.lastElementChild).toHaveClass("chat-composer-submit");
expect(rightActions?.lastElementChild).toHaveClass("chat-composer-submit");

const css = readFileSync("src/styles/index.css", "utf8");
expect(css).toMatch(/\.chat-composer-actions\s*\{[^}]*flex-wrap:\s*wrap;/s);
expect(css).toMatch(
/\.chat-composer-config\s*\{[^}]*min-width:\s*0;[^}]*flex:\s*1 1 [^;]+;/s,
/\.chat-composer-right-actions\s*\{[^}]*max-width:\s*100%;[^}]*flex-wrap:\s*wrap;[^}]*justify-content:\s*flex-end;/s,
);
expect(css).toMatch(
/\.chat-composer-config\s*\{[^}]*min-width:\s*0;[^}]*flex:\s*0 1 auto;/s,
);
expect(css).toMatch(
/\.chat-composer-config \.searchable-select\s*\{[^}]*max-width:\s*min\(220px, 100%\);[^}]*flex:\s*0 1 auto;/s,
Expand Down
148 changes: 75 additions & 73 deletions src/features/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,80 +272,82 @@ export function ChatComposer({
</div>
)}
<div className="chat-composer-actions">
<div className="chat-composer-config">
<SearchableSelect
ariaLabel={t("chat.composer.model")}
value={modelValue}
disabled={controlsDisabled}
options={
modelValue
? modelOptions
: [
{
value: "",
label: t("chat.composer.noModel"),
},
...modelOptions,
]
}
onChange={(value) => {
const separator = value.indexOf("/");
if (separator < 1 || !onModelChange) return;
void runMutation(() =>
onModelChange(
value.slice(0, separator),
value.slice(separator + 1),
),
);
}}
/>
<SearchableSelect
ariaLabel={t("chat.composer.thinkingLevel")}
value={config?.thinkingLevel ?? "off"}
disabled={controlsDisabled}
options={(config?.availableThinkingLevels ?? ["off"]).map(
(level) => ({
value: level,
label: t(THINKING_KEYS[level]),
}),
)}
onChange={(value) => {
if (!onThinkingLevelChange) return;
void runMutation(() =>
onThinkingLevelChange(value as ChatThinkingLevel),
);
}}
/>
<div className="chat-composer-right-actions">
<div className="chat-composer-config">
<SearchableSelect
ariaLabel={t("chat.composer.model")}
value={modelValue}
disabled={controlsDisabled}
options={
modelValue
? modelOptions
: [
{
value: "",
label: t("chat.composer.noModel"),
},
...modelOptions,
]
}
onChange={(value) => {
const separator = value.indexOf("/");
if (separator < 1 || !onModelChange) return;
void runMutation(() =>
onModelChange(
value.slice(0, separator),
value.slice(separator + 1),
),
);
}}
/>
<SearchableSelect
ariaLabel={t("chat.composer.thinkingLevel")}
value={config?.thinkingLevel ?? "off"}
disabled={controlsDisabled}
options={(config?.availableThinkingLevels ?? ["off"]).map(
(level) => ({
value: level,
label: t(THINKING_KEYS[level]),
}),
)}
onChange={(value) => {
if (!onThinkingLevelChange) return;
void runMutation(() =>
onThinkingLevelChange(value as ChatThinkingLevel),
);
}}
/>
</div>
{(byteLength > MAX_INPUT_BYTES * 0.8 || tooLarge) && (
<span className="chat-composer-count">
{formatNumber(byteLength)} / {formatNumber(MAX_INPUT_BYTES)}
</span>
)}
{showStop && (
<button
className="chat-composer-submit chat-composer-stop"
type="button"
aria-label={t("chat.composer.stop")}
title={t("chat.composer.stop")}
disabled={pending || disabled}
onClick={() => void stop()}
>
<RiStopMiniFill aria-hidden="true" />
</button>
)}
{(!running || hasDraft) && (
<button
className="chat-composer-submit"
type="button"
aria-label={actionLabel}
title={actionLabel}
disabled={sendDisabled}
onClick={() => void submit()}
>
<RiSendPlane2Fill aria-hidden="true" />
</button>
)}
</div>
{(byteLength > MAX_INPUT_BYTES * 0.8 || tooLarge) && (
<span className="chat-composer-count">
{formatNumber(byteLength)} / {formatNumber(MAX_INPUT_BYTES)}
</span>
)}
{showStop && (
<button
className="chat-composer-submit chat-composer-stop"
type="button"
aria-label={t("chat.composer.stop")}
title={t("chat.composer.stop")}
disabled={pending || disabled}
onClick={() => void stop()}
>
<RiStopMiniFill aria-hidden="true" />
</button>
)}
{(!running || hasDraft) && (
<button
className="chat-composer-submit"
type="button"
aria-label={actionLabel}
title={actionLabel}
disabled={sendDisabled}
onClick={() => void submit()}
>
<RiSendPlane2Fill aria-hidden="true" />
</button>
)}
</div>
</div>
</div>
Expand Down
16 changes: 13 additions & 3 deletions src/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -4221,16 +4221,26 @@ details[open] > summary > .chat-disclosure-icon {
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding-top: 7px;
}
.chat-composer-right-actions {
max-width: 100%;
margin-left: auto;
display: flex;
flex: 0 1 auto;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.chat-composer-config {
min-width: 0;
margin-right: auto;
max-width: 100%;
display: flex;
flex: 1 1 auto;
flex: 0 1 auto;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.chat-composer-config .searchable-select {
Expand Down
Loading