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 9b1d88446..d4475b010 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'; @@ -851,6 +853,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); @@ -914,12 +921,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); @@ -2502,7 +2518,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} /> @@ -2611,6 +2631,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 && !voice.isActive && contextUsageIndicator && ( { + 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 1d48afa80..7293edbea 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 9935f62d5..a25879e84 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 1fdba2fa7..ae802bd4e 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 465aa79a7..e9480d987 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 c859d5ef6..156d290b1 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 205d92aa6..3e19a32ca 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 859f25c33..2dd8337aa 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}} 个文件",