Skip to content

Commit 26f609c

Browse files
DragonnZhangclaude
andauthored
Add a thinking-level (reasoning effort) picker to the chat composer (#45)
* Add a thinking-level (reasoning effort) picker to the chat composer Adds a composer toolbar dropdown to change the per-session thinking level (off / low / medium / high / xhigh / max), mirroring the existing model picker. The thinkingLevel / onThinkingLevelChange props were already plumbed to FreeFormInput and persisted by both the draft and active chat pages; only the UI trigger was missing. Frontend-only: renders a Radix dropdown and calls the existing onThinkingLevelChange prop. No new i18n keys — level names/descriptions reuse the existing thinking.* keys and the trigger tooltip reuses settings.ai.thinking. Includes a CDP e2e assertion (e2e/assertions/thinking-level-picker.assert.ts) that opens the picker, asserts all six levels are listed with the active one marked, selects a different level, and asserts the trigger label updates. Closes #44 * docs(loop): record thinking-level-picker as pr-open (#45) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8741a7a commit 26f609c

3 files changed

Lines changed: 214 additions & 2 deletions

File tree

apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
AlertCircle,
1313
CornerDownRight,
1414
GitBranch,
15+
Brain,
1516
X,
1617
} from 'lucide-react';
1718
import { Icon_Home, Icon_Folder } from '@craft-agent/ui';
@@ -81,7 +82,11 @@ import {
8182
PERMISSION_MODE_ORDER,
8283
type PermissionMode,
8384
} from '@craft-agent/shared/agent/modes';
84-
import { type ThinkingLevel } from '@craft-agent/shared/agent/thinking-levels';
85+
import {
86+
type ThinkingLevel,
87+
THINKING_LEVELS,
88+
getThinkingLevelNameKey,
89+
} from '@craft-agent/shared/agent/thinking-levels';
8590
import { useEscapeInterrupt } from '@/context/EscapeInterruptContext';
8691
import { hasOpenOverlay } from '@/lib/overlay-detection';
8792
import { ToolbarStatusSlot } from './ToolbarStatusSlot';
@@ -443,6 +448,7 @@ export function FreeFormInput({
443448
currentModel,
444449
onModelChange,
445450
thinkingLevel = 'medium',
451+
onThinkingLevelChange,
446452
permissionMode = 'ask',
447453
onPermissionModeChange,
448454
enabledModes = [...PERMISSION_MODE_ORDER],
@@ -842,6 +848,7 @@ export function FreeFormInput({
842848
const [loadingCount, setLoadingCount] = React.useState(0);
843849
const [inputMaxHeight, setInputMaxHeight] = React.useState(540);
844850
const [modelDropdownOpen, setModelDropdownOpen] = React.useState(false);
851+
const [thinkingDropdownOpen, setThinkingDropdownOpen] = React.useState(false);
845852

846853
// Input settings (loaded from config)
847854
const [autoCapitalisation, setAutoCapitalisation] = React.useState(true);
@@ -2559,6 +2566,66 @@ export function FreeFormInput({
25592566
/>
25602567
)}
25612568

2569+
{/* 4.5 Thinking Level Selector - Hidden in compact mode (mirrors model picker) */}
2570+
{!compactMode && onThinkingLevelChange && (
2571+
<DropdownMenu
2572+
open={thinkingDropdownOpen}
2573+
onOpenChange={setThinkingDropdownOpen}
2574+
>
2575+
<Tooltip>
2576+
<TooltipTrigger asChild>
2577+
<DropdownMenuTrigger asChild>
2578+
<button
2579+
type="button"
2580+
data-testid="thinking-level-trigger"
2581+
className={cn(
2582+
'input-toolbar-btn inline-flex items-center h-7 px-1.5 gap-1 text-[13px] shrink-0 rounded-[6px] hover:bg-foreground/5 transition-colors select-none',
2583+
thinkingDropdownOpen && 'bg-foreground/5',
2584+
)}
2585+
>
2586+
<Brain className="h-3.5 w-3.5 shrink-0 opacity-70" />
2587+
{t(getThinkingLevelNameKey(thinkingLevel))}
2588+
<ChevronDown className="h-3 w-3 opacity-50 shrink-0" />
2589+
</button>
2590+
</DropdownMenuTrigger>
2591+
</TooltipTrigger>
2592+
<TooltipContent side="top">
2593+
{t('settings.ai.thinking')}
2594+
</TooltipContent>
2595+
</Tooltip>
2596+
<StyledDropdownMenuContent
2597+
side="top"
2598+
align="end"
2599+
sideOffset={8}
2600+
className="min-w-[240px]"
2601+
>
2602+
{THINKING_LEVELS.map((level) => {
2603+
const isSelected = thinkingLevel === level.id;
2604+
return (
2605+
<StyledDropdownMenuItem
2606+
key={level.id}
2607+
data-testid="thinking-level-item"
2608+
onSelect={() => onThinkingLevelChange(level.id)}
2609+
className="flex items-center justify-between px-2 py-2 rounded-lg cursor-pointer"
2610+
>
2611+
<div className="text-left">
2612+
<div className="font-medium text-sm">
2613+
{t(level.nameKey)}
2614+
</div>
2615+
<div className="text-xs text-muted-foreground">
2616+
{t(level.descriptionKey)}
2617+
</div>
2618+
</div>
2619+
{isSelected && (
2620+
<Check className="h-3 w-3 text-foreground shrink-0 ml-3" />
2621+
)}
2622+
</StyledDropdownMenuItem>
2623+
);
2624+
})}
2625+
</StyledDropdownMenuContent>
2626+
</DropdownMenu>
2627+
)}
2628+
25622629
{/* 5. Model Selector - Hidden in compact mode (EditPopover embedding) */}
25632630
{!compactMode && (
25642631
<DropdownMenu

docs/loop/feature-ledger.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@ log, not the system of record.
3232

3333
| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
3434
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
35-
| command-palette | Global command palette (⌘K/Ctrl+K) to search & run any action | Claude Code Desktop ⌘K / VS Code & Codex ⌘⇧P / Linear ⌘K | frontend-only | pr-open | [#41](https://github.com/modelstudioai/openwork/issues/41) | [#42](https://github.com/modelstudioai/openwork/pull/42) | loop/command-palette | 2026-07-01 | Reuses action registry `execute()` + cmdk primitives; zero new i18n keys. CDP e2e 2/2 pass. typecheck/test +0 vs main. |
35+
| thinking-level-picker | Thinking-level (reasoning effort) picker in the chat composer | Claude Code Desktop effort menu (⌘⇧E) + OpenWork's own model picker | frontend-only | pr-open | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-02 | `thinkingLevel`/`onThinkingLevelChange` already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). typecheck/`bun test` zero-delta vs main (3578 pass/56 fail on both); renderer build ✅. **CDP could not run locally**: this sandbox's egress policy 403s the Electron binary download and the `libsignal` GitHub dep (WhatsApp worker), so the app can't be built/launched here — assertion included for CI/reviewer. |
36+
| command-palette | Global command palette (⌘K/Ctrl+K) to search & run any action | Claude Code Desktop ⌘K / VS Code & Codex ⌘⇧P / Linear ⌘K | frontend-only | merged | [#41](https://github.com/modelstudioai/openwork/issues/41) | [#42](https://github.com/modelstudioai/openwork/pull/42) | loop/command-palette | 2026-07-02 | Merged into `main`. Reuses action registry `execute()` + cmdk primitives; zero new i18n keys. CDP e2e 2/2 pass. typecheck/test +0 vs main. |
3637
| settings-search | Searchable/filterable settings navigation | Claude Code Desktop / VS Code / Codex desktop settings search | frontend-only | merged | [#39](https://github.com/modelstudioai/openwork/issues/39) | [#40](https://github.com/modelstudioai/openwork/pull/40) | loop/settings-search | 2026-07-01 | Merged into `main`. Filters `SettingsNavigator` by title+description; reuses `common.search`/`common.noResultsFound` (no new locale keys). Also hardened `e2e/app.ts` teardown (per-launch profile dir + setsid process-group kill) so multiple CDP assertions run under headless xvfb. |
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* Feature assertion: the composer's thinking-level (reasoning effort) picker.
3+
*
4+
* Drives the real built app over CDP through the full path:
5+
* composer renders a thinking-level trigger → open it → it lists all six
6+
* levels with exactly one marked selected (matching the trigger) → select a
7+
* *different* level → the menu closes and the trigger label updates to the
8+
* chosen level.
9+
*
10+
* The final step proves the picker actually *changes* the session's thinking
11+
* level (the value round-trips back into the trigger), not merely displays a
12+
* list.
13+
*/
14+
15+
import type { Assertion } from '../runner';
16+
17+
const TRIGGER = '[data-testid="thinking-level-trigger"]';
18+
const ITEM = '[data-testid="thinking-level-item"]';
19+
20+
/** The six thinking levels are the single source of truth for the count. */
21+
const EXPECTED_LEVEL_COUNT = 6;
22+
23+
/** Menu items that are actually visible (Radix renders them in a portal). */
24+
const VISIBLE_ITEMS_EXPR = `[...document.querySelectorAll(${JSON.stringify(
25+
ITEM,
26+
)})].filter((el) => el.offsetParent !== null)`;
27+
28+
/** Trimmed visible label of the trigger button (icons are SVGs with no text). */
29+
const TRIGGER_LABEL_EXPR = `(() => { const el = document.querySelector(${JSON.stringify(
30+
TRIGGER,
31+
)}); return el ? (el.textContent || '').trim() : null; })()`;
32+
33+
/**
34+
* Radix `DropdownMenuTrigger` opens on `pointerdown` (button 0), not on a
35+
* synthetic `click`, so dispatch a real pointerdown to open the menu.
36+
*/
37+
const OPEN_MENU_EXPR = `(() => {
38+
const el = document.querySelector(${JSON.stringify(TRIGGER)});
39+
if (!el) return false;
40+
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0 }));
41+
return true;
42+
})()`;
43+
44+
/** Read each visible item's primary label + whether it is marked selected (has a check SVG). */
45+
const READ_ITEMS_EXPR = `JSON.stringify(${VISIBLE_ITEMS_EXPR}.map((el) => ({
46+
label: (el.querySelector('.font-medium')?.textContent || '').trim(),
47+
selected: !!el.querySelector('svg'),
48+
})))`;
49+
50+
interface ItemInfo {
51+
label: string;
52+
selected: boolean;
53+
}
54+
55+
const assertion: Assertion = {
56+
name: 'composer thinking-level picker lists levels and switches the active level',
57+
async run(app) {
58+
const { session } = app;
59+
60+
// App fully mounted.
61+
await session.waitForFunction(
62+
'!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0',
63+
{ timeoutMs: 30000, message: 'React UI did not mount' },
64+
);
65+
66+
// Reach the ready AppShell (not onboarding / workspace picker) — the same
67+
// stable, non-localized anchor the command-palette assertion waits on.
68+
await session.waitForSelector('[aria-label="Craft menu"]', {
69+
timeoutMs: 30000,
70+
message: 'app did not reach the ready AppShell state',
71+
});
72+
73+
// 1. The composer renders a thinking-level trigger.
74+
await session.waitForSelector(TRIGGER, {
75+
timeoutMs: 20000,
76+
message: 'thinking-level picker trigger did not render in the composer',
77+
});
78+
79+
const initialLabel = (await session.evaluate<string | null>(TRIGGER_LABEL_EXPR))?.trim();
80+
if (!initialLabel) {
81+
throw new Error('thinking-level trigger rendered without a visible label');
82+
}
83+
84+
// 2. Opening it lists all six thinking levels.
85+
if (!(await session.evaluate<boolean>(OPEN_MENU_EXPR))) {
86+
throw new Error('failed to dispatch pointerdown on the thinking-level trigger');
87+
}
88+
await session.waitForFunction(
89+
`${VISIBLE_ITEMS_EXPR}.length === ${EXPECTED_LEVEL_COUNT}`,
90+
{
91+
timeoutMs: 8000,
92+
message: `thinking-level menu did not list ${EXPECTED_LEVEL_COUNT} levels`,
93+
},
94+
);
95+
96+
const items = JSON.parse(await session.evaluate<string>(READ_ITEMS_EXPR)) as ItemInfo[];
97+
98+
// 3. Exactly one level is marked selected, and it matches the trigger.
99+
const selected = items.filter((it) => it.selected);
100+
if (selected.length !== 1) {
101+
throw new Error(
102+
`expected exactly one selected thinking level, saw ${selected.length}: ${JSON.stringify(items)}`,
103+
);
104+
}
105+
if (selected[0].label !== initialLabel) {
106+
throw new Error(
107+
`selected menu item "${selected[0].label}" does not match the trigger label "${initialLabel}"`,
108+
);
109+
}
110+
111+
// 4. Select a *different* level and assert the trigger label updates to it.
112+
const target = items.find((it) => !it.selected && it.label && it.label !== initialLabel);
113+
if (!target) {
114+
throw new Error(`could not find a non-selected level to switch to: ${JSON.stringify(items)}`);
115+
}
116+
117+
const clicked = await session.evaluate<boolean>(`(() => {
118+
const el = ${VISIBLE_ITEMS_EXPR}.find(
119+
(el) => (el.querySelector('.font-medium')?.textContent || '').trim() === ${JSON.stringify(target.label)},
120+
);
121+
if (!el) return false;
122+
el.click();
123+
return true;
124+
})()`);
125+
if (!clicked) throw new Error(`could not click the "${target.label}" thinking-level row`);
126+
127+
// Menu closes after selection.
128+
await session.waitForFunction(`${VISIBLE_ITEMS_EXPR}.length === 0`, {
129+
timeoutMs: 5000,
130+
message: 'thinking-level menu did not close after selecting a level',
131+
});
132+
133+
// The choice round-trips: the trigger now shows the newly selected level.
134+
await session.waitForFunction(
135+
`${TRIGGER_LABEL_EXPR} === ${JSON.stringify(target.label)}`,
136+
{
137+
timeoutMs: 5000,
138+
message: `trigger label did not update to "${target.label}" after selection (was "${initialLabel}")`,
139+
},
140+
);
141+
},
142+
};
143+
144+
export default assertion;

0 commit comments

Comments
 (0)