diff --git a/packages/core/src/state/model-catalog.ts b/packages/core/src/state/model-catalog.ts index 6955af7..a7410d0 100644 --- a/packages/core/src/state/model-catalog.ts +++ b/packages/core/src/state/model-catalog.ts @@ -563,6 +563,16 @@ export const MODEL_CATALOG: ModelCatalogEntry[] = [ clientType: "openai", baseUrl: FIREWORKS_BASE_URL, }, + { + modelId: "accounts/fireworks/models/kimi-k3", + displayName: "Kimi K3", + provider: "fireworks", + contextWindow: 1000000, + pricing: usd(0.3, 3, 15), + supportsVision: true, + clientType: "openai", + baseUrl: FIREWORKS_BASE_URL, + }, { modelId: "accounts/fireworks/models/kimi-k2p7-code", displayName: "Kimi K2.7 Code", diff --git a/packages/core/test/model-catalog.test.ts b/packages/core/test/model-catalog.test.ts index cc6aaf4..e7dd921 100644 --- a/packages/core/test/model-catalog.test.ts +++ b/packages/core/test/model-catalog.test.ts @@ -189,6 +189,7 @@ describe("model-catalog", () => { ["accounts/fireworks/models/deepseek-v4-flash", false], ["accounts/fireworks/models/deepseek-v4-pro", false], ["accounts/fireworks/models/glm-5p2", false], + ["accounts/fireworks/models/kimi-k3", true], ["accounts/fireworks/models/kimi-k2p7-code", true], ["accounts/fireworks/models/minimax-m3", true], ]); diff --git a/packages/web/src/features/chat/chat-input.tsx b/packages/web/src/features/chat/chat-input.tsx index a07bbe4..2027e70 100644 --- a/packages/web/src/features/chat/chat-input.tsx +++ b/packages/web/src/features/chat/chat-input.tsx @@ -48,7 +48,7 @@ * Renders only the card body itself: outer positioning such as bottom-docking or vertical * centering is decided by the page. */ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { ChangeEvent, ClipboardEvent, KeyboardEvent, ReactNode } from "react"; import type { AgentSummary, @@ -724,6 +724,13 @@ function SkillSelect({ ); } +/** + * Picture glyph (24×24 line path) for the "+" menu's image-upload entry: the framing rectangle + * and the mountain line as two subpaths of one `d`, since GlyphIcon renders a single ``. + */ +const IMAGE_ICON = + "M6 5h12a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3zM3 15l5-5 4 4 3-3 6 6"; + /** One entry of the composer's "+" extension menu. */ interface PlusMenuItem { key: string; @@ -1277,13 +1284,12 @@ export function ChatInput({ /** The slash token currently under the caret (kept in a ref so command run() closures always remove the live token). */ const slashMatchRef = useRef>(null); const commands = useMemo(() => { - /** Removes just the slash token after a command runs (the rest of the text stays; setText is async: wait for the DOM value to update before measuring height). */ + /** Removes just the slash token after a command runs (the rest of the text stays; the height re-measures itself off the new value, see the autoGrow layout effect). */ const clearInput = () => { const match = slashMatchRef.current; const next = match ? removeSlashToken(textRef.current, match) : ""; setText(next); onTextChange?.(next); - requestAnimationFrame(autoGrow); }; return [ { @@ -1413,7 +1419,6 @@ export function ChatInput({ setText(""); setImages([]); setSelectedSkills([]); - requestAnimationFrame(autoGrow); } } finally { setBusy(false); @@ -1454,13 +1459,25 @@ export function ChatInput({ el.style.height = `${Math.min(el.scrollHeight, 176)}px`; }; - // Correct the height and cursor right on mount: a restored multi-line draft (initialText) - // would otherwise not expand until the first edit; move the cursor to the end of the draft - // (by default the browser places the cursor at the start when focusing a textarea that already - // has content), so typing continues the text naturally, and sync the caret state to match (the - // @ mention menu filters by cursor position). + /** + * The height follows the **rendered** value, measured in a layout effect — the single place + * that sizes the box, covering mount (a restored multi-line draft), typing, and the clear + * after a send alike. + * + * It must be a layout effect keyed on `text` rather than a call next to each `setText`: the + * textarea is controlled, so after `setText("")` the DOM still holds the old text until React + * commits. A `requestAnimationFrame` scheduled alongside the state update races that commit + * and, when it wins, measures the old multi-line content and re-pins the tall height — with + * nothing left to measure again, the composer stayed expanded after every send. A layout + * effect runs after the commit by construction, and before paint, so the box never flashes. + */ + useLayoutEffect(autoGrow, [text]); + + // Cursor placement on mount: move it to the end of a restored draft (by default the browser + // places the cursor at the start when focusing a textarea that already has content), so typing + // continues the text naturally, and sync the caret state to match (the @ mention menu filters + // by cursor position). useEffect(() => { - autoGrow(); const el = textareaRef.current; if (el && el.value.length > 0) { const end = el.value.length; @@ -1525,7 +1542,6 @@ export function ChatInput({ onTextChange?.(value); setCaret(start); setMentionIndex(0); - autoGrow(); }; /** @@ -1558,7 +1574,6 @@ export function ChatInput({ setText(""); setSelectedSkills([]); toggleGoal(false); - requestAnimationFrame(autoGrow); } } finally { setBusy(false); @@ -1589,7 +1604,6 @@ export function ChatInput({ setImages([]); setTarget(null); setSelectedSkills([]); - requestAnimationFrame(autoGrow); } } finally { setBusy(false); @@ -1622,7 +1636,6 @@ export function ChatInput({ steerBaseline.current = steeringDeliveredCount ?? 0; setSteerPending(true); setText(""); - requestAnimationFrame(autoGrow); } } finally { setBusy(false); @@ -1736,6 +1749,12 @@ export function ChatInput({ if (e.target.files) addFiles(e.target.files); e.target.value = ""; }; + /** + * The image picker moved into the "+" menu, so the file input can no longer be a `