diff --git a/apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx b/apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx index bb596ed1d..7ce12ec8c 100644 --- a/apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx +++ b/apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx @@ -13,6 +13,8 @@ import { CornerDownRight, GitBranch, Brain, + Maximize2, + Minimize2, X, } from 'lucide-react'; import { Icon_Home, Icon_Folder } from '@craft-agent/ui'; @@ -847,6 +849,11 @@ export function FreeFormInput({ const [isDraggingOver, setIsDraggingOver] = React.useState(false); const [loadingCount, setLoadingCount] = React.useState(0); const [inputMaxHeight, setInputMaxHeight] = React.useState(540); + // Height the input grows to when the user maximizes the composer (see the + // expand toggle in the toolbar). Recomputed from the window in the resize + // effect below so it always fills a large share of the available height. + const [expandedMaxHeight, setExpandedMaxHeight] = React.useState(720); + const [isComposerExpanded, setIsComposerExpanded] = React.useState(false); const [modelDropdownOpen, setModelDropdownOpen] = React.useState(false); const [thinkingDropdownOpen, setThinkingDropdownOpen] = React.useState(false); @@ -885,12 +892,21 @@ export function FreeFormInput({ const updateMaxHeight = () => { const maxFromWindow = Math.floor(window.innerHeight * 0.66); setInputMaxHeight(Math.min(maxFromWindow, 540)); + // Maximized composer fills most of the window, floored so it is always + // meaningfully taller than the collapsed cap even on short windows. + setExpandedMaxHeight(Math.max(Math.floor(window.innerHeight * 0.78), 560)); }; updateMaxHeight(); window.addEventListener('resize', updateMaxHeight); return () => window.removeEventListener('resize', updateMaxHeight); }, []); + // Collapse the maximized composer when switching sessions so a new + // conversation always starts from the compact input. + React.useEffect(() => { + setIsComposerExpanded(false); + }, [sessionId]); + const dragCounterRef = React.useRef(0); const containerRef = React.useRef(null); const fileInputRef = React.useRef(null); @@ -2453,7 +2469,11 @@ export function FreeFormInput({ workspaceId={workspaceSlug} slashCommandNames={richInputSlashCommandNames} className="pl-5 pr-4 pt-4 pb-3 overflow-y-auto min-h-[88px]" - style={{ maxHeight: inputMaxHeight }} + style={ + isComposerExpanded + ? { maxHeight: expandedMaxHeight, minHeight: expandedMaxHeight } + : { maxHeight: inputMaxHeight } + } data-tutorial="chat-input" spellCheck={spellCheck} /> @@ -2554,6 +2574,37 @@ export function FreeFormInput({ {/* Right side: Context + Model + Send - never shrink so they're always visible */}
+ {/* 3.5 Expand / collapse composer - Hidden in compact mode (mirrors model/thinking pickers) */} + {!compactMode && ( + + + + + + {t( + isComposerExpanded + ? 'chat.collapseComposer' + : 'chat.expandComposer', + )} + + + )} + {/* 4. Context Usage Indicator */} {!compactMode && contextUsageIndicator && ( 200`; one new i18n key `chat.scrollToBottom` in all 6 locales. Adds reusable `seed(profileDirs)` harness hook. PR open awaiting review. | +| 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 | merged | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-03 | Merged into `main`. `thinkingLevel`/`onThinkingLevelChange` were already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). | | 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. | | 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. | diff --git a/e2e/assertions/composer-expand.assert.ts b/e2e/assertions/composer-expand.assert.ts new file mode 100644 index 000000000..e487b5c7e --- /dev/null +++ b/e2e/assertions/composer-expand.assert.ts @@ -0,0 +1,118 @@ +/** + * Feature assertion: the composer's expand / collapse (maximize) toggle. + * + * Drives the real built app over CDP through the full path: + * composer renders an expand toggle (aria-pressed=false, showing the maximize + * icon) → click it → the input area grows to a large maximized height and the + * toggle flips to aria-pressed=true (minimize icon) → click it again → the + * input collapses back to roughly its original compact height and the toggle + * returns to aria-pressed=false. + * + * The height measurements prove the toggle actually *resizes* the composer, not + * merely swaps an icon. It runs entirely in the draft (no-session) state, so it + * needs no seeded conversation. + */ + +import type { Assertion } from '../runner'; + +const TOGGLE = '[data-testid="composer-expand-toggle"]'; +const INPUT = '[data-tutorial="chat-input"]'; + +/** Measured pixel height of the composer's editable input area. */ +const INPUT_HEIGHT_EXPR = `(() => { + const el = document.querySelector(${JSON.stringify(INPUT)}); + return el ? Math.round(el.getBoundingClientRect().height) : -1; +})()`; + +/** Current pressed state of the toggle as a string ("true" / "false" / null). */ +const PRESSED_EXPR = `(() => { + const el = document.querySelector(${JSON.stringify(TOGGLE)}); + return el ? el.getAttribute('aria-pressed') : null; +})()`; + +const assertion: Assertion = { + name: 'composer expand toggle maximizes and collapses the input', + async run(app) { + const { session } = app; + + // App fully mounted. + await session.waitForFunction( + '!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0', + { timeoutMs: 30000, message: 'React UI did not mount' }, + ); + + // Reach the ready AppShell (not onboarding / workspace picker) — the same + // stable, non-localized anchor the other composer assertions wait on. + await session.waitForSelector('[aria-label="Craft menu"]', { + timeoutMs: 30000, + message: 'app did not reach the ready AppShell state', + }); + + // 1. The composer renders the expand toggle, initially not pressed. + await session.waitForSelector(TOGGLE, { + timeoutMs: 20000, + message: 'composer expand toggle did not render in the composer', + }); + await session.waitForSelector(INPUT, { + timeoutMs: 20000, + message: 'composer input area did not render', + }); + + const initialPressed = await session.evaluate(PRESSED_EXPR); + if (initialPressed !== 'false') { + throw new Error( + `expected the expand toggle to start un-pressed, saw aria-pressed="${initialPressed}"`, + ); + } + + const collapsedHeight = await session.evaluate(INPUT_HEIGHT_EXPR); + if (collapsedHeight <= 0) { + throw new Error(`could not measure the collapsed input height (got ${collapsedHeight})`); + } + + // 2. Click to maximize — the input grows substantially and the toggle presses. + await session.click(TOGGLE, { + timeoutMs: 5000, + message: 'failed to click the expand toggle', + }); + + // A real maximize floors at ~560px; require a clear, unambiguous growth. + const expandedTarget = Math.max(collapsedHeight * 2, 400); + await session.waitForFunction( + `${INPUT_HEIGHT_EXPR} >= ${expandedTarget}`, + { + timeoutMs: 6000, + message: `input did not grow to the maximized height (expected >= ${expandedTarget}px from ${collapsedHeight}px)`, + }, + ); + await session.waitForFunction(`${PRESSED_EXPR} === "true"`, { + timeoutMs: 4000, + message: 'expand toggle did not flip to aria-pressed="true" after maximizing', + }); + + const expandedHeight = await session.evaluate(INPUT_HEIGHT_EXPR); + + // 3. Click again to collapse — the input returns to roughly its start height + // and the toggle un-presses. + await session.click(TOGGLE, { + timeoutMs: 5000, + message: 'failed to click the collapse toggle', + }); + + // Collapsed again means well below the expanded height (allow layout slack). + const collapsedCeiling = Math.max(collapsedHeight + 40, Math.round(expandedHeight / 2)); + await session.waitForFunction( + `${INPUT_HEIGHT_EXPR} <= ${collapsedCeiling}`, + { + timeoutMs: 6000, + message: `input did not collapse back (expected <= ${collapsedCeiling}px from ${expandedHeight}px)`, + }, + ); + await session.waitForFunction(`${PRESSED_EXPR} === "false"`, { + timeoutMs: 4000, + message: 'expand toggle did not return to aria-pressed="false" after collapsing', + }); + }, +}; + +export default assertion; diff --git a/packages/shared/src/i18n/locales/de.json b/packages/shared/src/i18n/locales/de.json index ad23be300..69e3bd9e3 100644 --- a/packages/shared/src/i18n/locales/de.json +++ b/packages/shared/src/i18n/locales/de.json @@ -127,6 +127,7 @@ "chat.clearDraft": "Entwurf löschen", "chat.clickForTaskActions": "Für Aufgabenaktionen klicken", "chat.clickToOpen": "Klicken um {{name}} zu öffnen", + "chat.collapseComposer": "Editor verkleinern", "chat.connectionDefault": "Standard dieser Verbindung", "chat.connectionUnavailable": "Verbindung nicht verfügbar", "chat.connectionUnavailableDescription": "Die von dieser Sitzung verwendete Verbindung wurde entfernt. Erstellen Sie eine neue Sitzung, um fortzufahren.", @@ -142,6 +143,7 @@ "chat.contextUsage.usedOfTotal": "{{used}} verwendet, {{total}} gesamt", "chat.emptyTitle": "Was sollen wir bauen?", "chat.enterSessionName": "Sitzungsname eingeben...", + "chat.expandComposer": "Editor vergrößern", "chat.failedToStopSharing": "Freigabe konnte nicht beendet werden", "chat.failedToUpdateShare": "Freigabe konnte nicht aktualisiert werden", "chat.filesCount_few": "{{count}} Dateien", diff --git a/packages/shared/src/i18n/locales/en.json b/packages/shared/src/i18n/locales/en.json index 515571e0a..555fb0989 100644 --- a/packages/shared/src/i18n/locales/en.json +++ b/packages/shared/src/i18n/locales/en.json @@ -127,6 +127,7 @@ "chat.clearDraft": "Clear draft", "chat.clickForTaskActions": "Click for task actions", "chat.clickToOpen": "Click to open {{name}}", + "chat.collapseComposer": "Collapse composer", "chat.connectionDefault": "Connection default", "chat.connectionUnavailable": "Connection Unavailable", "chat.connectionUnavailableDescription": "The connection used by this session has been removed. Create a new session to continue.", @@ -142,6 +143,7 @@ "chat.contextUsage.usedOfTotal": "{{used}} used, {{total}} total", "chat.emptyTitle": "What should we build?", "chat.enterSessionName": "Enter session name...", + "chat.expandComposer": "Expand composer", "chat.failedToStopSharing": "Failed to stop sharing", "chat.failedToUpdateShare": "Failed to update share", "chat.filesCount_few": "{{count}} files", diff --git a/packages/shared/src/i18n/locales/es.json b/packages/shared/src/i18n/locales/es.json index 0ae1c140c..e3dad8991 100644 --- a/packages/shared/src/i18n/locales/es.json +++ b/packages/shared/src/i18n/locales/es.json @@ -127,6 +127,7 @@ "chat.clearDraft": "Borrar borrador", "chat.clickForTaskActions": "Haz clic para acciones de tarea", "chat.clickToOpen": "Clic para abrir {{name}}", + "chat.collapseComposer": "Contraer el editor", "chat.connectionDefault": "Predeterminado de esta conexión", "chat.connectionUnavailable": "Conexión no disponible", "chat.connectionUnavailableDescription": "La conexión usada por esta sesión se ha eliminado. Crea una nueva sesión para continuar.", @@ -142,6 +143,7 @@ "chat.contextUsage.usedOfTotal": "{{used}} usados, {{total}} en total", "chat.emptyTitle": "¿Qué deberíamos construir?", "chat.enterSessionName": "Introduce el nombre de la sesión...", + "chat.expandComposer": "Ampliar el editor", "chat.failedToStopSharing": "Error al detener la compartición", "chat.failedToUpdateShare": "Error al actualizar el compartido", "chat.filesCount_few": "{{count}} archivos", diff --git a/packages/shared/src/i18n/locales/hu.json b/packages/shared/src/i18n/locales/hu.json index 9f613dd4f..f18574eb9 100644 --- a/packages/shared/src/i18n/locales/hu.json +++ b/packages/shared/src/i18n/locales/hu.json @@ -127,6 +127,7 @@ "chat.clearDraft": "Piszkozat törlése", "chat.clickForTaskActions": "Kattints a feladatműveletekért", "chat.clickToOpen": "Kattints a(z) {{name}} megnyitásához", + "chat.collapseComposer": "Szerkesztő összecsukása", "chat.connectionDefault": "A kapcsolat alapértelmezése", "chat.connectionUnavailable": "Kapcsolat nem érhető el", "chat.connectionUnavailableDescription": "A munkamenet által használt kapcsolatot eltávolították. A folytatáshoz hozz létre egy új munkamenetet.", @@ -142,6 +143,7 @@ "chat.contextUsage.usedOfTotal": "{{used}} felhasználva, összesen {{total}}", "chat.emptyTitle": "Mit építsünk?", "chat.enterSessionName": "Add meg a munkamenet nevét...", + "chat.expandComposer": "Szerkesztő kibontása", "chat.failedToStopSharing": "A megosztás leállítása sikertelen", "chat.failedToUpdateShare": "A megosztás frissítése sikertelen", "chat.filesCount_few": "{{count}} fájl", diff --git a/packages/shared/src/i18n/locales/ja.json b/packages/shared/src/i18n/locales/ja.json index 6c09b71d4..7a5036fda 100644 --- a/packages/shared/src/i18n/locales/ja.json +++ b/packages/shared/src/i18n/locales/ja.json @@ -127,6 +127,7 @@ "chat.clearDraft": "下書きをクリア", "chat.clickForTaskActions": "クリックしてタスクアクションを表示", "chat.clickToOpen": "クリックして{{name}}を開く", + "chat.collapseComposer": "エディターを縮小", "chat.connectionDefault": "この接続のデフォルト", "chat.connectionUnavailable": "接続を利用できません", "chat.connectionUnavailableDescription": "このセッションで使用していた接続は削除されました。続行するには新しいセッションを作成してください。", @@ -142,6 +143,7 @@ "chat.contextUsage.usedOfTotal": "{{used}} 使用済み、合計 {{total}}", "chat.emptyTitle": "何を構築しましょうか?", "chat.enterSessionName": "セッション名を入力...", + "chat.expandComposer": "エディターを拡大", "chat.failedToStopSharing": "共有の停止に失敗しました", "chat.failedToUpdateShare": "共有の更新に失敗しました", "chat.filesCount_few": "{{count}} 件のファイル", diff --git a/packages/shared/src/i18n/locales/pl.json b/packages/shared/src/i18n/locales/pl.json index 049c922ce..bfaacc06f 100644 --- a/packages/shared/src/i18n/locales/pl.json +++ b/packages/shared/src/i18n/locales/pl.json @@ -127,6 +127,7 @@ "chat.clearDraft": "Wyczyść szkic", "chat.clickForTaskActions": "Kliknij, aby zobaczyć akcje zadania", "chat.clickToOpen": "Kliknij, aby otworzyć {{name}}", + "chat.collapseComposer": "Zwiń edytor", "chat.connectionDefault": "Domyślne dla tego połączenia", "chat.connectionUnavailable": "Połączenie niedostępne", "chat.connectionUnavailableDescription": "Połączenie używane przez tę sesję zostało usunięte. Utwórz nową sesję, aby kontynuować.", @@ -142,6 +143,7 @@ "chat.contextUsage.usedOfTotal": "Użyto {{used}}, łącznie {{total}}", "chat.emptyTitle": "Co powinniśmy zbudować?", "chat.enterSessionName": "Wprowadź nazwę sesji...", + "chat.expandComposer": "Rozwiń edytor", "chat.failedToStopSharing": "Nie udało się zatrzymać udostępniania", "chat.failedToUpdateShare": "Nie udało się zaktualizować udostępnienia", "chat.filesCount_few": "{{count}} pliki", diff --git a/packages/shared/src/i18n/locales/zh-Hans.json b/packages/shared/src/i18n/locales/zh-Hans.json index 1879d2df7..fda86ebe1 100644 --- a/packages/shared/src/i18n/locales/zh-Hans.json +++ b/packages/shared/src/i18n/locales/zh-Hans.json @@ -127,6 +127,7 @@ "chat.clearDraft": "清除草稿", "chat.clickForTaskActions": "点击查看任务操作", "chat.clickToOpen": "点击打开 {{name}}", + "chat.collapseComposer": "收起输入框", "chat.connectionDefault": "此连接的默认值", "chat.connectionUnavailable": "连接不可用", "chat.connectionUnavailableDescription": "此会话使用的连接已被删除。请创建一个新会话以继续。", @@ -142,6 +143,7 @@ "chat.contextUsage.usedOfTotal": "已用 {{used}},共 {{total}}", "chat.emptyTitle": "我们该构建什么", "chat.enterSessionName": "输入会话名称...", + "chat.expandComposer": "展开输入框", "chat.failedToStopSharing": "无法停止共享", "chat.failedToUpdateShare": "无法更新共享", "chat.filesCount_few": "{{count}} 个文件",