From 684febbb31ff3002793f6a9c2c6820eee3ae5f01 Mon Sep 17 00:00:00 2001 From: "harmoniqs-amico-agent[bot]" Date: Fri, 30 Oct 2026 09:04:44 +0000 Subject: [PATCH 1/2] feat(composer): drag-to-resize height model with persistence (harmoniqs/amicode#612) Pure clamp/drag/keyboard/storage logic shared by both composers; manifest hashes patched for the new files. --- .../v2/components/prompt-input/resize.test.ts | 160 ++++++++++++++++++ .../src/v2/components/prompt-input/resize.ts | 143 ++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/resize.test.ts create mode 100644 packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/resize.ts diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/resize.test.ts b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/resize.test.ts new file mode 100644 index 000000000..c7b35f67a --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/resize.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from "bun:test" +import { + COMPOSER_HEIGHT_KEY, + COMPOSER_KEY_STEP, + COMPOSER_MIN_HEIGHT, + clampComposerHeight, + composerMaxHeight, + createComposerResizer, + dragComposerHeight, + readComposerHeight, + writeComposerHeight, + type ComposerStorage, +} from "./resize" + +function memoryStorage(initial: Record = {}): ComposerStorage & { data: Record } { + const data = { ...initial } + return { + data, + getItem: (k) => (k in data ? data[k]! : null), + setItem: (k, v) => void (data[k] = v), + removeItem: (k) => void delete data[k], + } +} + +const VIEWPORT = 1000 + +function make(storage?: ComposerStorage, rendered = 100) { + const seen: Array = [] + const resizer = createComposerResizer({ + measure: () => rendered, + viewport: () => VIEWPORT, + storage, + onChange: (h) => seen.push(h), + }) + return { resizer, seen } +} + +describe("composer height math", () => { + test("clamps between the minimum and a fraction of the viewport", () => { + expect(composerMaxHeight(1000)).toBe(600) + expect(clampComposerHeight(10, 1000)).toBe(COMPOSER_MIN_HEIGHT) + expect(clampComposerHeight(5000, 1000)).toBe(600) + expect(clampComposerHeight(300, 1000)).toBe(300) + }) + + test("a tiny viewport never pushes the max below the minimum", () => { + expect(composerMaxHeight(50)).toBe(COMPOSER_MIN_HEIGHT) + expect(clampComposerHeight(500, 50)).toBe(COMPOSER_MIN_HEIGHT) + }) + + test("dragging the top edge up grows, down shrinks", () => { + expect(dragComposerHeight(100, 500, 400, VIEWPORT)).toBe(200) + expect(dragComposerHeight(200, 400, 450, VIEWPORT)).toBe(150) + }) +}) + +describe("composer resizer", () => { + test("a drag from the rendered height reports growing heights and stops at the cap", () => { + const { resizer, seen } = make(undefined, 120) + expect(resizer.height()).toBeUndefined() + resizer.begin(500) + resizer.move(450) + expect(resizer.height()).toBe(170) + resizer.move(-9999) + expect(resizer.height()).toBe(600) + resizer.end() + expect(seen.at(-1)).toBe(600) + }) + + test("moves without a begin are ignored", () => { + const { resizer, seen } = make() + resizer.move(10) + expect(resizer.height()).toBeUndefined() + expect(seen).toEqual([]) + }) + + test("the height is persisted on release and restored by a fresh resizer", () => { + const storage = memoryStorage() + const first = make(storage, 100).resizer + first.begin(500) + first.move(380) + expect(storage.data[COMPOSER_HEIGHT_KEY]).toBeUndefined() + first.end() + expect(storage.data[COMPOSER_HEIGHT_KEY]).toBe("220") + + const second = make(storage, 100).resizer + expect(second.height()).toBe(220) + }) + + test("a later drag starts from the stored height, not the rendered one", () => { + const { resizer } = make(memoryStorage({ [COMPOSER_HEIGHT_KEY]: "300" }), 100) + resizer.begin(500) + resizer.move(400) + expect(resizer.height()).toBe(400) + }) + + test("a stored height larger than the current viewport allows is displayed clamped but kept in storage", () => { + const storage = memoryStorage({ [COMPOSER_HEIGHT_KEY]: "900" }) + const { resizer } = make(storage) + expect(resizer.height()).toBe(600) + expect(storage.data[COMPOSER_HEIGHT_KEY]).toBe("900") + }) + + test("ArrowUp / ArrowDown on the handle resize in steps and persist; other keys are not handled", () => { + const storage = memoryStorage() + const { resizer } = make(storage, 100) + expect(resizer.key("ArrowUp")).toBe(true) + expect(resizer.height()).toBe(100 + COMPOSER_KEY_STEP) + expect(storage.data[COMPOSER_HEIGHT_KEY]).toBe(String(100 + COMPOSER_KEY_STEP)) + expect(resizer.key("ArrowDown")).toBe(true) + expect(resizer.height()).toBe(100) + expect(resizer.key("Enter")).toBe(false) + }) + + test("keyboard shrink stops at the minimum", () => { + const { resizer } = make(undefined, COMPOSER_MIN_HEIGHT) + resizer.key("ArrowDown") + expect(resizer.height()).toBe(COMPOSER_MIN_HEIGHT) + }) + + test("reset returns to the stock auto height and forgets the stored value", () => { + const storage = memoryStorage({ [COMPOSER_HEIGHT_KEY]: "300" }) + const { resizer, seen } = make(storage) + resizer.reset() + expect(resizer.height()).toBeUndefined() + expect(storage.data[COMPOSER_HEIGHT_KEY]).toBeUndefined() + expect(seen.at(-1)).toBeUndefined() + }) +}) + +describe("composer height storage", () => { + test("ignores missing, non-numeric, and non-positive values", () => { + expect(readComposerHeight(undefined)).toBeUndefined() + expect(readComposerHeight(memoryStorage())).toBeUndefined() + expect(readComposerHeight(memoryStorage({ [COMPOSER_HEIGHT_KEY]: "banana" }))).toBeUndefined() + expect(readComposerHeight(memoryStorage({ [COMPOSER_HEIGHT_KEY]: "-5" }))).toBeUndefined() + expect(readComposerHeight(memoryStorage({ [COMPOSER_HEIGHT_KEY]: "0" }))).toBeUndefined() + }) + + test("a throwing storage (private mode / quota) never breaks resizing", () => { + const boom: ComposerStorage = { + getItem: () => { + throw new Error("denied") + }, + setItem: () => { + throw new Error("full") + }, + removeItem: () => { + throw new Error("denied") + }, + } + expect(readComposerHeight(boom)).toBeUndefined() + expect(() => writeComposerHeight(boom, 200)).not.toThrow() + const { resizer } = make(boom, 100) + resizer.begin(500) + resizer.move(400) + resizer.end() + expect(resizer.height()).toBe(200) + }) +}) diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/resize.ts b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/resize.ts new file mode 100644 index 000000000..a1cd80c36 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/resize.ts @@ -0,0 +1,143 @@ +// Drag-to-resize state for the chat composer (amicode#612). +// +// Pure and dependency-free so both composers (the legacy `PromptInput` in the +// app package and `PromptInputV2` here) share ONE behavior and the logic is +// testable without a DOM. The components own only the markup + a Solid signal; +// this module owns clamping, the drag math, keyboard steps, and persistence. +// +// Semantics: the stored number is the editor's *visible text-area height* in +// px. `undefined` means "never resized" — the composer keeps its stock +// auto-grow-up-to-a-cap behavior, byte-identical to before. + +export const COMPOSER_HEIGHT_KEY = "amicode.composer.height" +/** Smallest height the handle can shrink the text area to. */ +export const COMPOSER_MIN_HEIGHT = 60 +/** Largest height as a fraction of the viewport, so the timeline stays visible. */ +export const COMPOSER_MAX_VIEWPORT_FRACTION = 0.6 +/** Pixels moved per ArrowUp / ArrowDown press on the focused handle. */ +export const COMPOSER_KEY_STEP = 24 + +export type ComposerStorage = Pick + +export function composerMaxHeight(viewportHeight: number): number { + const max = Math.floor(viewportHeight * COMPOSER_MAX_VIEWPORT_FRACTION) + return Math.max(COMPOSER_MIN_HEIGHT, Number.isFinite(max) ? max : COMPOSER_MIN_HEIGHT) +} + +export function clampComposerHeight(height: number, viewportHeight: number): number { + if (!Number.isFinite(height)) return COMPOSER_MIN_HEIGHT + return Math.round(Math.min(composerMaxHeight(viewportHeight), Math.max(COMPOSER_MIN_HEIGHT, height))) +} + +/** Dragging the TOP edge up (smaller clientY) makes the composer taller. */ +export function dragComposerHeight(startHeight: number, startY: number, currentY: number, viewportHeight: number) { + return clampComposerHeight(startHeight + (startY - currentY), viewportHeight) +} + +export function readComposerHeight(storage: ComposerStorage | undefined): number | undefined { + if (!storage) return undefined + try { + const raw = storage.getItem(COMPOSER_HEIGHT_KEY) + if (raw === null) return undefined + const value = Number(raw) + if (!Number.isFinite(value) || value <= 0) return undefined + return Math.round(value) + } catch { + return undefined + } +} + +export function writeComposerHeight(storage: ComposerStorage | undefined, height: number | undefined) { + if (!storage) return + try { + if (height === undefined) storage.removeItem(COMPOSER_HEIGHT_KEY) + else storage.setItem(COMPOSER_HEIGHT_KEY, String(Math.round(height))) + } catch { + // Storage can be unavailable or full (private mode, webview quota); the + // resize still works for this session. + } +} + +export function composerStorage(): ComposerStorage | undefined { + try { + return typeof localStorage === "undefined" ? undefined : localStorage + } catch { + return undefined + } +} + +export type ComposerResizeOptions = { + /** Current rendered height of the text area, used as the drag origin. */ + measure: () => number + viewport: () => number + storage?: ComposerStorage + /** Fired whenever the height (or its absence) changes. */ + onChange: (height: number | undefined) => void +} + +export type ComposerResizer = { + height: () => number | undefined + /** Re-clamp against the current viewport (call on window resize). */ + refit: () => void + begin: (clientY: number) => void + move: (clientY: number) => void + end: () => void + dragging: () => boolean + /** ArrowUp/ArrowDown on the focused handle; returns true when handled. */ + key: (key: string) => boolean + /** Back to the stock auto-height (double-click on the handle). */ + reset: () => void +} + +export function createComposerResizer(options: ComposerResizeOptions): ComposerResizer { + let height = readComposerHeight(options.storage) + let dragging = false + let startY = 0 + let startHeight = 0 + + const apply = (next: number | undefined, persist: boolean) => { + if (next === height) return + height = next + if (persist) writeComposerHeight(options.storage, next) + options.onChange(next) + } + + const current = () => (height === undefined ? options.measure() : clampComposerHeight(height, options.viewport())) + + return { + height: () => (height === undefined ? undefined : clampComposerHeight(height, options.viewport())), + refit: () => { + if (height === undefined) return + const next = clampComposerHeight(height, options.viewport()) + // Display-only clamp: keep the user's preferred value in storage so a + // bigger window restores it. + options.onChange(next) + }, + begin: (clientY) => { + dragging = true + startY = clientY + startHeight = current() + }, + move: (clientY) => { + if (!dragging) return + apply(dragComposerHeight(startHeight, startY, clientY, options.viewport()), false) + }, + end: () => { + if (!dragging) return + dragging = false + // Persist once, on release, not on every pointermove. + if (height !== undefined) writeComposerHeight(options.storage, height) + }, + dragging: () => dragging, + key: (key) => { + if (key !== "ArrowUp" && key !== "ArrowDown") return false + const delta = key === "ArrowUp" ? COMPOSER_KEY_STEP : -COMPOSER_KEY_STEP + apply(clampComposerHeight(current() + delta, options.viewport()), true) + return true + }, + reset: () => { + dragging = false + apply(undefined, true) + }, + } +} From 2450466c5bd0acc405189d97ef722287be36803e Mon Sep 17 00:00:00 2001 From: "harmoniqs-amico-agent[bot]" Date: Fri, 30 Oct 2026 09:04:44 +0000 Subject: [PATCH 2/2] feat(composer): mount the resize handle on both chat inputs (harmoniqs/amicode#612) Top-edge drag handle (pointer + ArrowUp/Down, double-click resets) on PromptInput and PromptInputV2; height persists in localStorage. Manifest refreshed for the 4 new overlay files + 2 modified. --- packages/app-bundle/manifest.json | 18 +++- .../prompt-input-resize-structure.test.ts | 39 ++++++++ .../app/src/components/prompt-input.tsx | 22 ++++- .../src/v2/components/prompt-input/index.tsx | 7 ++ .../components/prompt-input/resize-handle.tsx | 97 +++++++++++++++++++ 5 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/components/prompt-input-resize-structure.test.ts create mode 100644 packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/resize-handle.tsx diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index c4f0c7ffc..bde698ef1 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -152,7 +152,7 @@ "packages/app/src/components/profile-popover.tsx": "eff742e90544455f13234a9351bb1b82189663cba2cd3172bdcea1a8a37284e8", "packages/app/src/components/prompt-input-clipboard-structure.test.ts": "946b718c2164da394602f26e4d9919d752da0d0b9bee714f345ba16c274542da", "packages/app/src/components/prompt-input-v2.tsx": "f0b637e46737e3c7345957a0167c878c8e9870a15bb850057665a4444ea91fe8", - "packages/app/src/components/prompt-input.tsx": "c127f81e2c0de4bdbc21a349473df39c623d8d7e2df8df0c03ea60bbef101a25", + "packages/app/src/components/prompt-input.tsx": "6eaaba582ed28b73aa7273ea5e33dd0a0b81255b8b8db2c572e1f369ec14ae86", "packages/app/src/components/prompt-project-selector.tsx": "0157c954b938bce2b5aca9a82f4a72338e361f4c9027e55a7fd5a14a1654d294", "packages/app/src/components/question-inbox.tsx": "5ce03a30b7110092ddffccf3bf38e6c1b1a79624f378c819be7019725c96478a", "packages/app/src/components/remote-create-arm.test.ts": "388132209e16b6f00d79ac7c700f8c2bfb5f80343eee34cee73451b32842d3f2", @@ -194,6 +194,7 @@ "packages/app/src/components/vault-panel.tsx": "1d9f06f42b94ffb94a28bfd6d2693a36c75ceb969bcd434081b4cd2fbf99665c", "packages/app/src/components/windows-app-menu.tsx": "262d085facace692084d79763a6bd8eba6c5ceb53b952a134e12c7e8a8a12946", "packages/app/src/components/workbench-panel.tsx": "d16f477044098842a214a2e16f9be719e7688a04bcb575889ca544988f23c690", + "packages/app/src/components/prompt-input-resize-structure.test.ts": "38244b103702afc4b45cd40176f338c2a632d64b8ee9a719ee2073b6d7aaf062", "packages/app/src/context/command.tsx": "388d6cb12b6dc43ecb0827e3fb918370cb9d16d96161a5990dec10fff05af172", "packages/app/src/context/concise-mode.test.ts": "534258a1d82d66bd8b482bb3a8ca6a24d508328505abd0f7f52e1b75cdf57fa2", "packages/app/src/context/concise-mode.ts": "b03ac887222543e590d31781c944e69aca97bff32a661751dd817d46bf4ead64", @@ -1038,7 +1039,10 @@ "packages/sdk/js/src/v2/gen/sdk.gen.ts": "6610638b4fb71bac498da98d232ef9adcd4b011b63e5e81a63fc6dc20d311b17", "packages/sdk/js/src/v2/gen/types.gen.ts": "20dfc17423861bd51c197fe6a2fcb6efbd7ad11853b28e0bb79844115256e729", "packages/session-ui/src/v2/components/prompt-input/attachments.ts": "ff66078f2edc9a67a4df0d7dfd76a49be5eab27ac332b303f3412721ef7268ea", - "packages/session-ui/src/v2/components/prompt-input/index.tsx": "5c5c852ad55ae759f5869fd2e48ade6da99a98b6ab3c6dc102f3e062898e3f60", + "packages/session-ui/src/v2/components/prompt-input/index.tsx": "f393de5a7600796df174c877ffd5af64139df7fc65294c2bb9023cdc29945f66", + "packages/session-ui/src/v2/components/prompt-input/resize-handle.tsx": "e0c47cd2c8140eda79d96562d311c7c5e2ff9c162695691e9f6acd2a8c1786a1", + "packages/session-ui/src/v2/components/prompt-input/resize.test.ts": "c049ad1cd60e5ac4ec779dd37ab4b2c8a6244b5b6cbbc59240d1c43a449cfabd", + "packages/session-ui/src/v2/components/prompt-input/resize.ts": "bf1323b437bd0cc342084ac9d21035c8ea6d12fcdf644b5587fd88453638f83c", "packages/opencode/src/server/routes/instance/httpapi/server.ts": "8f142f16accad5a5a7ca84507f5c95863416af9f85b11131e5cd97cb6b128d90", "packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts": "9da41a900811e8526f3d6796341b787f7e3c64bdc9cc3a547977f05780c1f79f", "packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts": "944a0aee215a917e867fd9482f3c2bd40f49082bd3174c8a7d967ca40e34ec15", @@ -1073,7 +1077,7 @@ "extracted_at": "2026-10-29T13:09:53.406Z", "per_package": { "packages/app": { - "A": 289, + "A": 290, "M": 166, "D": 0 }, @@ -1108,7 +1112,7 @@ "D": 0 }, "packages/session-ui": { - "A": 49, + "A": 52, "M": 32, "D": 0 }, @@ -1119,7 +1123,7 @@ } }, "counts": { - "overlay_total": 1048, + "overlay_total": 1052, "deletions": 7, "server_coupled": 0 }, @@ -1310,6 +1314,7 @@ "packages/app/src/components/vault-panel.tsx": "A", "packages/app/src/components/windows-app-menu.tsx": "M", "packages/app/src/components/workbench-panel.tsx": "A", + "packages/app/src/components/prompt-input-resize-structure.test.ts": "A", "packages/app/src/context/command.tsx": "M", "packages/app/src/context/concise-mode.test.ts": "A", "packages/app/src/context/concise-mode.ts": "A", @@ -2155,6 +2160,9 @@ "packages/sdk/js/src/v2/gen/types.gen.ts": "M", "packages/session-ui/src/v2/components/prompt-input/attachments.ts": "M", "packages/session-ui/src/v2/components/prompt-input/index.tsx": "M", + "packages/session-ui/src/v2/components/prompt-input/resize-handle.tsx": "A", + "packages/session-ui/src/v2/components/prompt-input/resize.test.ts": "A", + "packages/session-ui/src/v2/components/prompt-input/resize.ts": "A", "packages/opencode/src/server/routes/instance/httpapi/server.ts": "M", "packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts": "M", "packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts": "M", diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input-resize-structure.test.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-resize-structure.test.ts new file mode 100644 index 000000000..8b9b961bf --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-resize-structure.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +// amicode#612 — both composers mount the shared drag-to-resize handle and +// apply the persisted height to the text area. Source-structure checks, in the +// same style as prompt-input-clipboard-structure.test.ts: the behavior itself +// is covered in session-ui's resize.test.ts, this pins the wiring. +const v1 = readFileSync(join(import.meta.dir, "prompt-input.tsx"), "utf8") +const v2 = readFileSync( + join(import.meta.dir, "../../../session-ui/src/v2/components/prompt-input/index.tsx"), + "utf8", +) +const handle = readFileSync( + join(import.meta.dir, "../../../session-ui/src/v2/components/prompt-input/resize-handle.tsx"), + "utf8", +) + +describe("composer drag-to-resize wiring (#612)", () => { + test("the legacy composer mounts the handle and pins the scroller height from it", () => { + expect(v1).toContain(" { + expect(v2).toContain(" { + expect(handle).toContain('role="separator"') + expect(handle).toContain('aria-orientation="horizontal"') + expect(handle).toContain('tabindex="0"') + expect(handle).toContain("onKeyDown") + expect(handle).toContain("onDblClick") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx index 95002e2b6..fada4f546 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx @@ -56,6 +56,7 @@ import { usePlatform } from "@/context/platform" import { createSessionTabs } from "@/pages/session/helpers" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments } from "./prompt-input/attachments" +import { ComposerResizeHandle, createComposerResize } from "@opencode-ai/session-ui/v2/prompt-input/resize-handle" import { readClipboardImageViaBridge, readClipboardViaBridge } from "./prompt-input/clipboard-bridge" import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files" import { @@ -134,9 +135,17 @@ export const PromptInput: Component = (props) => { let restoreEndOnFocus = true let savedCursor: number | null = null + // amicode#612: drag the composer's top edge to make the text area taller; + // shares behavior + persisted height with the v2 composer. The stored height + // is the TEXT area; this scroller also holds the 56px control-row inset below + // the text, so that is added when applying and removed when measuring. const mirror = { input: false } const inset = 56 const space = `${inset}px` + const resize = createComposerResize(() => { + const h = scrollRef?.getBoundingClientRect().height + return h === undefined ? undefined : h - inset + }) const scrollCursorIntoView = () => { const container = scrollRef @@ -1482,6 +1491,7 @@ export const PromptInput: Component = (props) => { [props.class ?? ""]: !!props.class, }} > + = (props) => {
(scrollRef = el)} - style={{ "scroll-padding-bottom": space }} + style={{ + "scroll-padding-bottom": space, + // Pins the text area to the chosen height (overrides max-h-[240px]); + // unset keeps the stock auto-grow. + ...(resize.height() === undefined + ? {} + : { + height: `${resize.height()! + inset}px`, + "max-height": `${resize.height()! + inset}px`, + }), + }} >
editor?.getBoundingClientRect().height) const updateCursor = () => { if (!editor || !window.getSelection()?.isCollapsed) return props.controller.onCursor(promptInputV2Cursor(editor)) @@ -147,6 +151,8 @@ export function PromptInputV2(props: PromptInputV2Props) { onDragLeave={props.controller.onDragLeave} onDrop={props.controller.onDrop} > + +
Drop files to attach @@ -185,6 +191,7 @@ export function PromptInputV2(props: PromptInputV2Props) { autocomplete="off" class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword" classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }} + style={composerHeightStyle(resize.height())} onInput={(event) => { const cursor = promptInputV2Cursor(event.currentTarget) const prompt = parsePromptInputV2Editor(event.currentTarget) diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/resize-handle.tsx b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/resize-handle.tsx new file mode 100644 index 000000000..20baa81f7 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/prompt-input/resize-handle.tsx @@ -0,0 +1,97 @@ +import { createSignal, onCleanup, onMount, type Accessor } from "solid-js" +import { + COMPOSER_MIN_HEIGHT, + composerMaxHeight, + composerStorage, + createComposerResizer, + type ComposerResizer, +} from "./resize" + +// Solid glue for the composer drag-to-resize handle (amicode#612). Shared by +// the legacy `PromptInput` (app package) and `PromptInputV2`; all behavior +// lives in ./resize.ts, this file is signals + markup only. + +export type ComposerResize = { + /** User-chosen text-area height in px, or undefined for the stock auto height. */ + height: Accessor + resizer: ComposerResizer +} + +export function createComposerResize(measure: () => number | undefined): ComposerResize { + const [height, setHeight] = createSignal(undefined) + const viewport = () => (typeof window === "undefined" ? 800 : window.innerHeight) + const resizer = createComposerResizer({ + measure: () => measure() ?? COMPOSER_MIN_HEIGHT, + viewport, + storage: composerStorage(), + onChange: setHeight, + }) + setHeight(resizer.height()) + + onMount(() => { + const refit = () => resizer.refit() + window.addEventListener("resize", refit) + onCleanup(() => window.removeEventListener("resize", refit)) + }) + + return { height, resizer } +} + +/** Inline style that pins the text area to the user's chosen height. */ +export function composerHeightStyle(height: number | undefined) { + if (height === undefined) return undefined + return { "min-height": `${height}px`, "max-height": `${height}px` } +} + +export function ComposerResizeHandle(props: { + resize: ComposerResize + label?: string + /** Colour classes for the grip pill; the pill is only visible on hover/focus/drag. */ + gripClass?: string +}) { + const resizer = () => props.resize.resizer + const [dragging, setDragging] = createSignal(false) + const finish = () => { + resizer().end() + setDragging(false) + } + + return ( +