v1.10.4: booterm mobile UX — copy/paste, swipe-close, send-to-chat, search

- Long-press selection + floating menu (mobile + desktop right-click): Copy,
  Paste, Select All, Search, Send to chat. Tap-outside / Esc dismiss.
- Pane-header Paste button (📋) for iOS user-gesture clipboard read.
- Swipe-left-to-close on mobile pane pill with red "Close" overlay and
  translateX visual hint; spring-back below 80px threshold.
- Send-to-chat reverse path: chatInputsRegistry + sendToChat event mirror
  the existing terminalsRegistry pattern. ChatInput appends with newline
  separator on receive and focuses (no auto-send).
- Scrollback search via xterm-addon-search@^0.13.0: SearchBar overlay with
  N-of-M match counter (onDidChangeResults), Enter/Shift-Enter cycling.
- Cmd/Ctrl+F intercept in Session.tsx when active pane is terminal; xterm
  also intercepts when focused. Browser native find passes through elsewhere.
- terminalsRegistry signature extended with openSearch + paste callbacks.

Includes deferred CLAUDE.md updates documenting v1.10/v1.10.1/v1.10.2/v1.10.3
learnings (uid 1000 collision, libc match, two event buses, vite proxy order,
mobile pane URL sync, xterm canvas selection).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 17:16:47 +00:00
parent 4d466c5710
commit ea9d261f0f
10 changed files with 892 additions and 43 deletions

View File

@@ -25,6 +25,7 @@ import { AgentPicker } from '@/components/AgentPicker';
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { chatInputsRegistry, sendToChat } from '@/lib/events';
import { useSkills } from '@/hooks/useSkills';
import { useViewport } from '@/hooks/useViewport';
@@ -51,9 +52,16 @@ interface Props {
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
// disables slash-command dispatch (input is sent as literal text).
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
// v1.10.4: send-to-chat reverse path. When chatId is provided, this input
// registers in chatInputsRegistry so the terminal floating menu can list
// it, and subscribes to sendToChat events scoped to this chatId. Receiving
// an event appends the text to the current draft (with a newline separator
// when non-empty) and focuses — no auto-send.
chatId?: string;
chatLabel?: string;
}
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand }: Props) {
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, chatId, chatLabel }: Props) {
const { isMobile } = useViewport();
const [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
@@ -107,6 +115,35 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
});
}, []);
// v1.10.4: register this input in the chat-input registry so the terminal
// pane's "Send to chat" menu can list it. Re-registers when chatLabel
// changes (e.g. rename) so the menu reflects the current name.
useEffect(() => {
if (!chatId) return;
return chatInputsRegistry.register(chatId, chatLabel ?? 'Chat', () => {
textareaRef.current?.focus();
});
}, [chatId, chatLabel]);
// v1.10.4: subscribe to send_to_chat events scoped by chatId. Appends the
// payload text to the current draft (with a newline separator if the
// draft is non-empty) and focuses the textarea. Does NOT auto-submit.
useEffect(() => {
if (!chatId) return;
return sendToChat.subscribe(({ chat_id, text }) => {
if (chat_id !== chatId) return;
setValue((prev) => (prev.length === 0 ? text : `${prev}\n${text}`));
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
// Put caret at end so the user can keep typing immediately.
const end = ta.value.length;
ta.selectionStart = ta.selectionEnd = end;
});
});
}, [chatId]);
function removeAttachment(id: string) {
setAttachments(prev => prev.filter(a => a.id !== id));
}