diff --git a/.lane/memory/apps/app/src/composer.tsx/01M4YQG7BVWEFZPD56A2BA0VE9-mode-ace-mode-channels-id-an.md b/.lane/memory/apps/app/src/composer.tsx/01M4YQG7BVWEFZPD56A2BA0VE9-mode-ace-mode-channels-id-an.md index 10534840..18dd2f33 100644 --- a/.lane/memory/apps/app/src/composer.tsx/01M4YQG7BVWEFZPD56A2BA0VE9-mode-ace-mode-channels-id-an.md +++ b/.lane/memory/apps/app/src/composer.tsx/01M4YQG7BVWEFZPD56A2BA0VE9-mode-ace-mode-channels-id-an.md @@ -4,9 +4,9 @@ anchor: function Composer created: 2026-10-15T02:50:44Z norm: '1' sig: 6e0e6c5c8afbd01a -body_hash: 5f7b73ecdc92491d -raw_hash: 92a0017bb10f2de6 -vouched: 2026-10-19T20:57:54Z +body_hash: f265dfc748a0c278 +raw_hash: dd0efcf54d33e479 +vouched: 2026-10-22T14:39:32Z lines: 99-282 --- diff --git a/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md b/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md index 8c228415..8f827d78 100644 --- a/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md +++ b/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md @@ -4,9 +4,9 @@ anchor: function Composer created: 2026-10-15T12:44:15Z norm: '1' sig: 6e0e6c5c8afbd01a -body_hash: 5f7b73ecdc92491d -raw_hash: 92a0017bb10f2de6 -vouched: 2026-10-19T20:57:54Z +body_hash: f265dfc748a0c278 +raw_hash: dd0efcf54d33e479 +vouched: 2026-10-22T14:39:32Z lines: 101-290 --- diff --git a/.lane/memory/apps/app/src/composer.tsx/01M4ZYQVGB6C87WBEV12WPQ8PF-a-withdrawn-message-returns.md b/.lane/memory/apps/app/src/composer.tsx/01M4ZYQVGB6C87WBEV12WPQ8PF-a-withdrawn-message-returns.md index 1cbf792e..a71ce0c5 100644 --- a/.lane/memory/apps/app/src/composer.tsx/01M4ZYQVGB6C87WBEV12WPQ8PF-a-withdrawn-message-returns.md +++ b/.lane/memory/apps/app/src/composer.tsx/01M4ZYQVGB6C87WBEV12WPQ8PF-a-withdrawn-message-returns.md @@ -4,9 +4,9 @@ anchor: function Composer created: 2026-10-15T14:16:28Z norm: '1' sig: 6e0e6c5c8afbd01a -body_hash: 5f7b73ecdc92491d -raw_hash: 92a0017bb10f2de6 -vouched: 2026-10-19T20:57:54Z +body_hash: f265dfc748a0c278 +raw_hash: dd0efcf54d33e479 +vouched: 2026-10-22T14:39:32Z lines: 104-326 --- diff --git a/.lane/memory/apps/host/src/cli.ts/01M4YY5SXJFQK8VXT78X1E0KAE-every-command-but-serve-must.md b/.lane/memory/apps/host/src/cli.ts/01M4YY5SXJFQK8VXT78X1E0KAE-every-command-but-serve-must.md index fa79c66b..514b6005 100644 --- a/.lane/memory/apps/host/src/cli.ts/01M4YY5SXJFQK8VXT78X1E0KAE-every-command-but-serve-must.md +++ b/.lane/memory/apps/host/src/cli.ts/01M4YY5SXJFQK8VXT78X1E0KAE-every-command-but-serve-must.md @@ -4,9 +4,9 @@ anchor: function main created: 2026-10-15T04:47:23Z norm: '1' sig: 7d5d3f63a3bddde4 -body_hash: fd9cef47cdc61076 -raw_hash: e9c988923aa657a6 -vouched: 2026-10-22T14:31:33Z +body_hash: 42c616dba5adb167 +raw_hash: 1a7c366637365036 +vouched: 2026-10-22T14:45:22Z lines: 253-482 --- diff --git a/.lane/memory/apps/host/src/cli.ts/01M5J0BZWP8SCBK522YT2HMZDZ-ace-info-prints-agent-access.md b/.lane/memory/apps/host/src/cli.ts/01M5J0BZWP8SCBK522YT2HMZDZ-ace-info-prints-agent-access.md index 4d87b9cf..2a98b5fe 100644 --- a/.lane/memory/apps/host/src/cli.ts/01M5J0BZWP8SCBK522YT2HMZDZ-ace-info-prints-agent-access.md +++ b/.lane/memory/apps/host/src/cli.ts/01M5J0BZWP8SCBK522YT2HMZDZ-ace-info-prints-agent-access.md @@ -4,8 +4,9 @@ anchor: function main created: 2026-10-22T14:31:16Z norm: '1' sig: 7d5d3f63a3bddde4 -body_hash: fd9cef47cdc61076 -raw_hash: e9c988923aa657a6 +body_hash: 42c616dba5adb167 +raw_hash: 1a7c366637365036 +vouched: 2026-10-22T14:45:22Z lines: 332-614 --- diff --git a/.lane/memory/packages/ui/src/components/composer/chat-composer.tsx/01M53E0S8M7RPD1RK65J9NG78E-dictation-writes-only-betwee.md b/.lane/memory/packages/ui/src/components/composer/chat-composer.tsx/01M53E0S8M7RPD1RK65J9NG78E-dictation-writes-only-betwee.md index a3dde196..a04639dd 100644 --- a/.lane/memory/packages/ui/src/components/composer/chat-composer.tsx/01M53E0S8M7RPD1RK65J9NG78E-dictation-writes-only-betwee.md +++ b/.lane/memory/packages/ui/src/components/composer/chat-composer.tsx/01M53E0S8M7RPD1RK65J9NG78E-dictation-writes-only-betwee.md @@ -4,8 +4,9 @@ anchor: function useComposer created: 2026-10-16T22:41:13Z norm: '1' sig: 381929b0cbaf2245 -body_hash: 8f1d85407de87351 -raw_hash: 6588ffae72ff82db +body_hash: 4dcfff341604590f +raw_hash: 03d5450461fb3892 +vouched: 2026-10-22T14:39:32Z lines: 597-1226 --- diff --git a/apps/app/src/composer.tsx b/apps/app/src/composer.tsx index 0a2b9444..d56e93c1 100644 --- a/apps/app/src/composer.tsx +++ b/apps/app/src/composer.tsx @@ -252,9 +252,12 @@ export function Composer( const sent = new Set(attached); setAttached((current) => current.filter((value) => !sent.has(value))); } catch (error) { + const { message } = error as Error; + // Only a missing key is fixed in Providers; busy, access and size errors are not. + const keys = invoke && onProviders && /provider key/i.test(message); toast.error(invoke ? "Could not invoke agent" : "Could not send message", { - description: (error as Error).message, - ...(invoke && onProviders ? { action: { label: "Providers", onClick: onProviders } } : {}), + description: message, + ...(keys ? { action: { label: "Providers", onClick: onProviders } } : {}), }); } finally { sending.current = false; diff --git a/apps/app/src/conversation.tsx b/apps/app/src/conversation.tsx index e77fe373..b9b394fa 100644 --- a/apps/app/src/conversation.tsx +++ b/apps/app/src/conversation.tsx @@ -201,7 +201,12 @@ export function Conversation( ); act({ op: "react", emoji, remove }, uid); }} - onCopy={(uid) => void navigator.clipboard.writeText(byKey.get(uid)!.text)} + onCopy={(uid) => + navigator.clipboard.writeText(byKey.get(uid)!.text).then( + () => toast("Copied message"), + (error) => + toast.error("Could not copy the message", { description: error.message }), + )} onDelete={setDeleting} editable={editable} editing={edited?.key} diff --git a/apps/host/src/cli.ts b/apps/host/src/cli.ts index 9c57b8f2..7bc74e80 100755 --- a/apps/host/src/cli.ts +++ b/apps/host/src/cli.ts @@ -133,6 +133,9 @@ function clip(text: string, length: number): string { return line.length > length ? `${line.slice(0, length - 1)}…` : line; } +/** Whether `(run ended)` already printed, so `ask` doesn't print a second ending. */ +let ended = false; + function render(event: Event, info: ChannelInfo) { const tag = event.chat === 1 && info.chats.length === 1 ? "" : dim(`[${event.chat}] `); switch (event.kind) { @@ -151,7 +154,9 @@ function render(event: Event, info: ChannelInfo) { case "result": return console.log(dim(`${tag} ${event.error ? "✗" : "←"} ${clip(event.text, 100)}`)); case "run": - return event.state === "end" && console.log(dim(`${tag} (run ended)`)); + if (event.state !== "end") return; + ended = true; + return console.log(dim(`${tag} (run ended)`)); } } @@ -536,7 +541,7 @@ async function main() { process.exit(130); }); const status = await session.request({ op: "wait", submission }); - if (status === "unanswered") console.log(dim(" (stopped before answering)")); + if (status === "unanswered" && !ended) console.log(dim(" (stopped before answering)")); return; } case "watch": diff --git a/packages/ui/src/components/composer/chat-composer.tsx b/packages/ui/src/components/composer/chat-composer.tsx index 63a11cb0..ca802025 100644 --- a/packages/ui/src/components/composer/chat-composer.tsx +++ b/packages/ui/src/components/composer/chat-composer.tsx @@ -1071,7 +1071,7 @@ function useComposer({ return true; } } - return history.handleKey(v, e, m => updateMode(m || ms[0]!.id)); + return history.handleKey(v, e, m => updateMode(m || ms[0]!.id), modeRef.current); } function update(v: EditorView) { @@ -1717,7 +1717,8 @@ function ComposerButtons({ state }: { state: ComposerState }) { let { terminal, sendable, send, tip, stop, onStop, exit, edit, cancel, accessory } = state; return ( - {accessory} + {/* Above the actions' hover halo, which reaches over it. */} + {accessory && {accessory}} {terminal ? ( diff --git a/packages/ui/src/components/timeline/timeline.tsx b/packages/ui/src/components/timeline/timeline.tsx index 1212b4ea..49f0e3e9 100644 --- a/packages/ui/src/components/timeline/timeline.tsx +++ b/packages/ui/src/components/timeline/timeline.tsx @@ -441,7 +441,7 @@ function AgentWorkingContextView( ...workingTextFont, }} > - {turn.recap && {turn.recap}} + {turn.recap && {codeSpans(turn.recap)}} + i % 2 ? {part} : part + ); +} + function find(items: TimelineItem[], uid: string) { for (let i = 0; i < items.length; i++) { let item = items[i]!; diff --git a/packages/ui/src/hooks/use-history.ts b/packages/ui/src/hooks/use-history.ts index 7e95f50a..c229408f 100644 --- a/packages/ui/src/hooks/use-history.ts +++ b/packages/ui/src/hooks/use-history.ts @@ -1,5 +1,6 @@ import { useRef } from "react"; import { TextSelection } from "prosemirror-state"; +import type { Node } from "prosemirror-model"; import type { EditorView } from "prosemirror-view"; const MAX = 10; @@ -25,6 +26,17 @@ function clear(view: EditorView) { view.dispatch(tr); } +/** Ace mode's empty composer still holds its `@ace` chip, so mentions alone count as empty. */ +function blank(doc: Node) { + let text = ""; + doc.descendants((node) => { + if (node.type.name === "mention") return false; + if (node.isText) text += node.text; + else if (node.isLeaf && node.isInline) text += "x"; + }); + return !text.trim(); +} + /** * Navigate sent-message history with ArrowUp/ArrowDown. * Only starts when input is empty. Call `reset` on onChange to exit history mode. @@ -32,6 +44,19 @@ function clear(view: EditorView) { function useHistory(scope?: string) { let k = `ace:history:${scope || location.pathname}`; let index = useRef(-1); + // A recall dispatches a change like typing does; this keeps that change from ending browsing. + let recalling = useRef(false); + // The mode before browsing, restored when ↓ returns to an empty composer. + let origin = useRef(undefined); + + function recall(write: () => void) { + recalling.current = true; + try { + write(); + } finally { + recalling.current = false; + } + } function load(): Entry[] { try { @@ -69,18 +94,19 @@ function useHistory(scope?: string) { /** * ProseMirror key handler for history navigation. `onMode` receives the mode - * a recalled entry was sent in (undefined when returning to an empty box) so + * a recalled entry was sent in (`mode`, the one before browsing, when returning to an empty box) so * the composer can restore mode state alongside the document. */ function handleKey( view: EditorView, e: KeyboardEvent, onMode?: (mode?: string) => void, + mode?: string, ): boolean { if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return false; let browsing = index.current !== -1; - if (!browsing && view.state.doc.textContent) return false; + if (!browsing && !blank(view.state.doc)) return false; let items = load(); if (!items.length) return false; @@ -89,9 +115,12 @@ function useHistory(scope?: string) { let next = browsing ? index.current - 1 : items.length - 1; if (next < 0) return browsing; e.preventDefault(); + if (!browsing) origin.current = mode; index.current = next; - apply(view, items[next]!.doc); - onMode?.(items[next]!.mode); + recall(() => { + apply(view, items[next]!.doc); + onMode?.(items[next]!.mode); + }); return true; } @@ -101,13 +130,17 @@ function useHistory(scope?: string) { let next = index.current + 1; if (next >= items.length) { index.current = -1; - clear(view); - onMode?.(undefined); + recall(() => { + clear(view); + onMode?.(origin.current); + }); return true; } index.current = next; - apply(view, items[next]!.doc); - onMode?.(items[next]!.mode); + recall(() => { + apply(view, items[next]!.doc); + onMode?.(items[next]!.mode); + }); return true; } @@ -116,6 +149,7 @@ function useHistory(scope?: string) { /** Reset browsing index. Call on onChange to exit history mode. */ function reset() { + if (recalling.current) return; index.current = -1; } diff --git a/packages/ui/src/ui/emoji-picker.tsx b/packages/ui/src/ui/emoji-picker.tsx index 4ffbabed..3dd0332f 100644 --- a/packages/ui/src/ui/emoji-picker.tsx +++ b/packages/ui/src/ui/emoji-picker.tsx @@ -117,10 +117,13 @@ function EmojiPicker({ onSelect, theme }: Props) { s.textContent = shadowCSS(); host.shadowRoot.appendChild(s); } + // emoji-mart's autoFocus runs before the popover can take focus, so focus search a frame later. + let frame = requestAnimationFrame(() => host?.shadowRoot?.querySelector("input")?.focus()); picker.current = instance; applyVars(); return () => { + cancelAnimationFrame(frame); picker.current = null; el.replaceChildren(); };