Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
CornerDownRight,
GitBranch,
Brain,
Maximize2,
Minimize2,
X,
} from 'lucide-react';
import { Icon_Home, Icon_Folder } from '@craft-agent/ui';
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<HTMLDivElement>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
Expand Down Expand Up @@ -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}
/>
Expand Down Expand Up @@ -2554,6 +2574,37 @@ export function FreeFormInput({

{/* Right side: Context + Model + Send - never shrink so they're always visible */}
<div className="flex items-center shrink-0">
{/* 3.5 Expand / collapse composer - Hidden in compact mode (mirrors model/thinking pickers) */}
{!compactMode && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-testid="composer-expand-toggle"
aria-pressed={isComposerExpanded}
onClick={() => setIsComposerExpanded((prev) => !prev)}
className={cn(
'input-toolbar-btn inline-flex items-center justify-center h-7 w-7 shrink-0 rounded-[6px] hover:bg-foreground/5 transition-colors select-none',
isComposerExpanded && 'bg-foreground/5',
)}
>
{isComposerExpanded ? (
<Minimize2 className="h-3.5 w-3.5 opacity-70" />
) : (
<Maximize2 className="h-3.5 w-3.5 opacity-70" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top">
{t(
isComposerExpanded
? 'chat.collapseComposer'
: 'chat.expandComposer',
)}
</TooltipContent>
</Tooltip>
)}

{/* 4. Context Usage Indicator */}
{!compactMode && contextUsageIndicator && (
<ContextUsageIndicator
Expand Down
4 changes: 3 additions & 1 deletion docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ log, not the system of record.

| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 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. |
| composer-expand | Expand/collapse (maximize) toggle for the chat composer | Claude / ChatGPT / Codex desktop composer maximize affordance | frontend-only | pr-open | [#48](https://github.com/modelstudioai/openwork/issues/48) | [#49](https://github.com/modelstudioai/openwork/pull/49) | loop/composer-expand | 2026-07-03 | Toolbar toggle (Maximize2/Minimize2) mirroring the model/thinking pickers; flips `RichTextInput` inline height between the auto-grow cap and `max(78vh, 560px)`. Two new i18n keys `chat.expandComposer`/`chat.collapseComposer` in all 7 locales (parity OK, 1544 keys). Resets on session switch. typecheck:all zero-delta (11 pre-existing); `bun test` zero-delta (identical 56-fail set); renderer build ✅. CDP assertion `composer-expand.assert.ts` written (measures real height growth + aria-pressed round-trip) & transpiles; **run blocked by sandbox egress** (Electron binary + `libsignal` 403), same as prior rounds. |
| jump-to-latest | "Jump to latest" (scroll-to-bottom) button in the chat transcript | Claude Code Desktop / ChatGPT / Codex desktop scroll-to-latest control | frontend-only | pr-open | [#46](https://github.com/modelstudioai/openwork/issues/46) | [#47](https://github.com/modelstudioai/openwork/pull/47) | loop/scroll-to-bottom | 2026-07-03 | Floating down-chevron in `ChatDisplay` toggled off `distanceFromBottom > 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. |
118 changes: 118 additions & 0 deletions e2e/assertions/composer-expand.assert.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(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<number>(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<number>(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;
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"chat.clearDraft": "下書きをクリア",
"chat.clickForTaskActions": "クリックしてタスクアクションを表示",
"chat.clickToOpen": "クリックして{{name}}を開く",
"chat.collapseComposer": "エディターを縮小",
"chat.connectionDefault": "この接続のデフォルト",
"chat.connectionUnavailable": "接続を利用できません",
"chat.connectionUnavailableDescription": "このセッションで使用していた接続は削除されました。続行するには新しいセッションを作成してください。",
Expand All @@ -142,6 +143,7 @@
"chat.contextUsage.usedOfTotal": "{{used}} 使用済み、合計 {{total}}",
"chat.emptyTitle": "何を構築しましょうか?",
"chat.enterSessionName": "セッション名を入力...",
"chat.expandComposer": "エディターを拡大",
"chat.failedToStopSharing": "共有の停止に失敗しました",
"chat.failedToUpdateShare": "共有の更新に失敗しました",
"chat.filesCount_few": "{{count}} 件のファイル",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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ć.",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"chat.clearDraft": "清除草稿",
"chat.clickForTaskActions": "点击查看任务操作",
"chat.clickToOpen": "点击打开 {{name}}",
"chat.collapseComposer": "收起输入框",
"chat.connectionDefault": "此连接的默认值",
"chat.connectionUnavailable": "连接不可用",
"chat.connectionUnavailableDescription": "此会话使用的连接已被删除。请创建一个新会话以继续。",
Expand All @@ -142,6 +143,7 @@
"chat.contextUsage.usedOfTotal": "已用 {{used}},共 {{total}}",
"chat.emptyTitle": "我们该构建什么",
"chat.enterSessionName": "输入会话名称...",
"chat.expandComposer": "展开输入框",
"chat.failedToStopSharing": "无法停止共享",
"chat.failedToUpdateShare": "无法更新共享",
"chat.filesCount_few": "{{count}} 个文件",
Expand Down