From ea9d261f0fee70381217233d78bcdf3224941627 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Tue, 19 May 2026 17:16:47 +0000 Subject: [PATCH] =?UTF-8?q?v1.10.4:=20booterm=20mobile=20UX=20=E2=80=94=20?= =?UTF-8?q?copy/paste,=20swipe-close,=20send-to-chat,=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- CLAUDE.md | 12 +- apps/web/package.json | 1 + apps/web/src/components/ChatInput.tsx | 39 +- apps/web/src/components/MobileTabSwitcher.tsx | 108 ++- apps/web/src/components/Workspace.tsx | 22 +- apps/web/src/components/panes/ChatPane.tsx | 2 + .../web/src/components/panes/TerminalPane.tsx | 655 +++++++++++++++++- apps/web/src/lib/events.ts | 71 +- apps/web/src/pages/Session.tsx | 12 + pnpm-lock.yaml | 13 + 10 files changed, 892 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5281469..9243dac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side). +Plus `apps/booterm` (second container, port 9501, bookworm-slim+glibc): Fastify + node-pty + tmux. Browser terminal panes WS to `/ws/term/sessions/:sid/panes/:pid`; per-session tmux session `bc-`, per-pane window `term-`. Shells drop privs to samkintop via `gosu` in `tmux.conf` default-command. + ## Commands ```bash @@ -35,7 +37,7 @@ Tests: `pnpm -C apps/server test` runs 23 vitest tests. No test harness on `apps ## Architecture -**Monorepo**: pnpm workspaces with `apps/server` (Fastify + postgres) and `apps/web` (React + Vite). +**Monorepo**: pnpm workspaces with `apps/server` (Fastify + postgres), `apps/web` (React + Vite), and `apps/booterm` (Fastify + node-pty + tmux). ### Server (`apps/server/src/`) @@ -99,6 +101,10 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0 - Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge. - Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client. - Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call β€” `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present). +- `node:20-*` base images ship a `node` user at uid/gid 1000 β€” delete it (`userdel`/`groupdel` on debian, `deluser`/`delgroup` on alpine) before adding samkintop at 1000. +- node-pty's compiled `.node` is libc-specific: proddeps and runtime Dockerfile stages must share libc (alpine↔musl or bookworm-slim↔glibc); the TS-only builder stage can stay alpine for speed. +- pnpm 10 `--frozen-lockfile` skips node-pty's postinstall β€” the Docker proddeps stage runs `cd node_modules/node-pty && npm run install` to force the native compile. +- A local PreToolUse hook (`security_reminder_hook.py`) regex-flags Node's older `child_process` spawn helpers as unsafe (false positive even on the File-suffixed variant). Use `spawn` β€” it's accepted. ## Conventions @@ -109,3 +115,7 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0 - Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`). - shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive. - `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names. +- Two UI event buses: `hooks/sessionEvents.ts` for DB-state events (chat_created, session_updated); `lib/events.ts` for ephemeral UI (`sendToTerminal`, `terminalsRegistry`). Don't merge β€” different subscriber lifecycles. +- `vite.config.ts` proxy entries are order-sensitive: more-specific prefixes (`/api/term`, `/ws/term`) must come BEFORE `/api`. +- Mobile pane URL sync (`Session.tsx`): the `?pane=` effect resets `activePaneIdx` whenever `panes` changes. New-pane creation on mobile must push `?pane=` atomically β€” `addPaneAndSwitch` is the wrapper that does this. `addSplitPane` returns the new pane id for callers. +- xterm.js v5 uses canvas rendering β€” browser doesn't see xterm's selection; the native right-click menu has no working Copy for terminal text. App keybindings (`Cmd/Ctrl-C`, `Cmd/Ctrl-Shift-C`) are the path. diff --git a/apps/web/package.json b/apps/web/package.json index 286e886..dfdd422 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,6 +29,7 @@ "tw-animate-css": "^1.4.0", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", + "xterm-addon-search": "^0.13.0", "xterm-addon-web-links": "^0.9.0" }, "devDependencies": { diff --git a/apps/web/src/components/ChatInput.tsx b/apps/web/src/components/ChatInput.tsx index 5e7b235..a60fdbe 100644 --- a/apps/web/src/components/ChatInput.tsx +++ b/apps/web/src/components/ChatInput.tsx @@ -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; + // 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)); } diff --git a/apps/web/src/components/MobileTabSwitcher.tsx b/apps/web/src/components/MobileTabSwitcher.tsx index a77e979..5b7aef4 100644 --- a/apps/web/src/components/MobileTabSwitcher.tsx +++ b/apps/web/src/components/MobileTabSwitcher.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { Bot, ChevronDown, @@ -31,6 +31,15 @@ interface Props { onRenameChat: (chatId: string, name: string) => Promise; } +// v1.10.4: swipe-left-to-close on the pane pill. Threshold matches the spec +// (80px). Vertical bail-out at 30px because the pill sits inside a vertical +// scrollable header β€” diagonal-ish swipes shouldn't accidentally close panes. +const SWIPE_CLOSE_PX = 80; +const SWIPE_VERTICAL_BAIL_PX = 30; +// Visual cap: pill translates left up to this much. Past this, dragX stays +// pinned so the user has a clear "release to close" indicator. +const SWIPE_VISUAL_CAP = 120; + function paneIcon(kind: WorkspacePane['kind']) { if (kind === 'terminal') return ; if (kind === 'agent') return ; @@ -70,11 +79,66 @@ export function MobileTabSwitcher({ const [open, setOpen] = useState(false); const [renamingChatId, setRenamingChatId] = useState(null); const [renameValue, setRenameValue] = useState(''); + // v1.10.4: swipe-left state. dragX is the (clamped, negative) drag offset + // in px. suppressClick latches when a swipe completes so the trailing click + // doesn't pop open the BottomSheet on the just-closed pane. + const [dragX, setDragX] = useState(0); + const swipeStart = useRef<{ x: number; y: number } | null>(null); + const swipeBailed = useRef(false); + const suppressClick = useRef(false); const active = panes[activePaneIdx]; const activeLabel = active ? paneLabel(active, chats) : 'Empty'; const activeChatId = paneActiveChatId(active); + function onPillTouchStart(e: React.TouchEvent): void { + if (e.touches.length !== 1) return; + const t = e.touches[0]!; + swipeStart.current = { x: t.clientX, y: t.clientY }; + swipeBailed.current = false; + setDragX(0); + } + function onPillTouchMove(e: React.TouchEvent): void { + if (!swipeStart.current || swipeBailed.current) return; + if (e.touches.length !== 1) return; + const t = e.touches[0]!; + const dx = t.clientX - swipeStart.current.x; + const dy = t.clientY - swipeStart.current.y; + // Bail to scroll if vertical motion dominates before horizontal. + if (Math.abs(dy) > SWIPE_VERTICAL_BAIL_PX && Math.abs(dy) > Math.abs(dx)) { + swipeBailed.current = true; + setDragX(0); + return; + } + // Only allow leftward drag (negative). Cap visual displacement. + const clamped = Math.max(-SWIPE_VISUAL_CAP, Math.min(0, dx)); + setDragX(clamped); + } + function onPillTouchEnd(): void { + const finalDx = dragX; + swipeStart.current = null; + if (swipeBailed.current) { + setDragX(0); + return; + } + if (finalDx <= -SWIPE_CLOSE_PX && panes.length > 1) { + suppressClick.current = true; + // Reset dragX after the close so subsequent re-renders look right. + setDragX(0); + onRemovePane(activePaneIdx); + return; + } + setDragX(0); + } + function onPillClick(): void { + if (suppressClick.current) { + suppressClick.current = false; + return; + } + setOpen(true); + } + const swipeProgress = Math.min(1, Math.abs(dragX) / SWIPE_CLOSE_PX); + // Long-press mirrors ChatTabBar: synthesize a contextmenu event on the row // so the trailing kebab's Radix DropdownMenu opens at the touch point. const longPress = useLongPress(({ clientX, clientY, target }) => { @@ -113,17 +177,39 @@ export function MobileTabSwitcher({ return ( <> - + {/* v1.10.4: red "Close" hint behind the pill. Opacity tracks the + swipe progress (0 at rest, 1 at the close threshold). aria-hidden + because the actionable affordance is the swipe, not this label. */} + + + setOpen(false)} title="Panes">
    diff --git a/apps/web/src/components/Workspace.tsx b/apps/web/src/components/Workspace.tsx index 2aeae65..4e24318 100644 --- a/apps/web/src/components/Workspace.tsx +++ b/apps/web/src/components/Workspace.tsx @@ -1,9 +1,10 @@ import { useEffect, useMemo, useState } from 'react'; -import { PanelRight, MessageSquare, Terminal, Bot, X } from 'lucide-react'; +import { PanelRight, MessageSquare, Terminal, Bot, Clipboard, X } from 'lucide-react'; import type { Chat, Project, Session, WorkspacePane } from '@/api/types'; import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes'; import type { UseSessionChatsResult } from '@/hooks/useSessionChats'; import { useViewport } from '@/hooks/useViewport'; +import { terminalsRegistry } from '@/lib/events'; import { ChatPane } from '@/components/panes/ChatPane'; import { SettingsPane } from '@/components/panes/SettingsPane'; import { TerminalPane } from '@/components/panes/TerminalPane'; @@ -238,6 +239,23 @@ export function Workspace({ {terminalLabels.get(pane.id) ?? 'Terminal'} + {/* v1.10.4: iOS Safari restricts navigator.clipboard.readText + outside direct user gestures. A real button click IS a + gesture, so this works where keystroke-driven paste may + not on iOS. The action lives in TerminalPane behind the + registry's paste() callback. */} + {panes.length > 1 && ( + {submenu && hasSelection && ( +
    + {chatInputs.map((c) => ( + onSendToChat(c.chatId)}> + {c.label} + + ))} +
    + )} + + )} +
    + Dismiss +
    + ); +} + +function MenuItem({ + children, + onClick, + disabled = false, +}: { + children: React.ReactNode; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +// v1.10.4: floating search bar pinned to the top of the terminal pane. Uses +// SearchAddon.findNext / findPrevious. Incremental search on each keystroke +// keeps the highlighted match in sync. +interface SearchBarProps { + searchRef: React.MutableRefObject; + theme: typeof XTERM_THEME; + onClose: () => void; +} +function SearchBar({ searchRef, theme, onClose }: SearchBarProps) { + const [q, setQ] = useState(''); + const [counts, setCounts] = useState<{ idx: number; total: number }>({ idx: -1, total: 0 }); + const inputRef = useRef(null); + useEffect(() => { + inputRef.current?.focus(); + }, []); + // onDidChangeResults fires whenever the SearchAddon's decoration set + // updates. We mirror it into local state for the "N of M" indicator. + useEffect(() => { + const addon = searchRef.current; + if (!addon) return; + const sub = addon.onDidChangeResults(({ resultIndex, resultCount }) => { + setCounts({ idx: resultIndex, total: resultCount }); + }); + return () => sub.dispose(); + }, [searchRef]); + useEffect(() => { + const addon = searchRef.current; + if (!addon) return; + if (q.length === 0) { + addon.clearDecorations?.(); + setCounts({ idx: -1, total: 0 }); + return; + } + addon.findNext(q, { + incremental: true, + decorations: { + matchBackground: theme.selectionBackground, + matchOverviewRuler: theme.cursor, + activeMatchBackground: theme.cursor, + activeMatchColorOverviewRuler: theme.cursor, + }, + }); + }, [q, searchRef, theme]); + + function findNext(): void { + if (!q) return; + searchRef.current?.findNext(q); + } + function findPrev(): void { + if (!q) return; + searchRef.current?.findPrevious(q); + } + function onKey(ev: React.KeyboardEvent): void { + if (ev.key === 'Escape') { + ev.preventDefault(); + onClose(); + return; + } + if (ev.key === 'Enter') { + ev.preventDefault(); + if (ev.shiftKey) findPrev(); + else findNext(); + } + } + + return ( +
    + setQ(ev.target.value)} + onKeyDown={onKey} + placeholder="Search…" + style={{ + background: 'transparent', + border: 0, + outline: 'none', + color: '#d6deeb', + padding: '8px 8px', + fontSize: 13, + width: 160, + minHeight: 36, + }} + /> + {q.length > 0 && ( + + {counts.total === 0 + ? 'No match' + : counts.idx === -1 + ? `${counts.total}+` + : `${counts.idx + 1} of ${counts.total}`} + + )} + + + +
    + ); +} + +const iconBtnStyle: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 44, + height: 44, + background: 'transparent', + border: 0, + color: '#d6deeb', + cursor: 'pointer', + borderRadius: 6, +}; diff --git a/apps/web/src/lib/events.ts b/apps/web/src/lib/events.ts index 7eaa9a6..b40a862 100644 --- a/apps/web/src/lib/events.ts +++ b/apps/web/src/lib/events.ts @@ -4,7 +4,8 @@ // // Also exposes a tiny registry of currently-mounted terminal panes so the // MessageBubble context menu can list them. TerminalPane registers on mount, -// unregisters on unmount. +// unregisters on unmount. v1.10.4 adds a parallel ChatInput registry used by +// the terminal floating menu's "Send to chat" submenu. type Listener = (payload: T) => void; @@ -41,12 +42,25 @@ export interface SendToTerminalPayload { export const sendToTerminal = createEvent(); +// v1.10.4: reverse direction. Terminal floating menu "Send to chat" emits this +// with the target chat's chat_id; ChatInput subscribes and appends to its draft. +export interface SendToChatPayload { + chat_id: string; + text: string; +} + +export const sendToChat = createEvent(); + export interface TerminalRegistration { paneId: string; label: string; // v1.10.3 kbd-shortcuts: Cmd+` needs to focus the active terminal's xterm // input layer. TerminalPane binds this to term.focus(). focus: () => void; + // v1.10.4: Cmd+F opens the search bar over the active terminal. Workspace + // also binds a "Paste" button in the terminal pane header to paste(). + openSearch: () => void; + paste: () => void; } const terminalRegistry = new Map(); @@ -63,8 +77,14 @@ function notifyRegistry(): void { } export const terminalsRegistry = { - register(paneId: string, label: string, focus: () => void): () => void { - terminalRegistry.set(paneId, { paneId, label, focus }); + register( + paneId: string, + label: string, + focus: () => void, + openSearch: () => void, + paste: () => void, + ): () => void { + terminalRegistry.set(paneId, { paneId, label, focus, openSearch, paste }); notifyRegistry(); return () => { terminalRegistry.delete(paneId); @@ -84,3 +104,48 @@ export const terminalsRegistry = { }; }, }; + +// v1.10.4: parallel registry of mounted ChatInput components so the terminal +// floating menu's "Send to chat" submenu can list open chats. Mirrors +// terminalsRegistry exactly; same subscriber pattern. +export interface ChatInputRegistration { + chatId: string; + label: string; + focus: () => void; +} + +const chatInputRegistry = new Map(); +const chatInputListeners = new Set>(); + +function notifyChatInputs(): void { + for (const l of chatInputListeners) { + try { + l(); + } catch { + /* ignore */ + } + } +} + +export const chatInputsRegistry = { + register(chatId: string, label: string, focus: () => void): () => void { + chatInputRegistry.set(chatId, { chatId, label, focus }); + notifyChatInputs(); + return () => { + chatInputRegistry.delete(chatId); + notifyChatInputs(); + }; + }, + list(): ChatInputRegistration[] { + return Array.from(chatInputRegistry.values()); + }, + get(chatId: string): ChatInputRegistration | undefined { + return chatInputRegistry.get(chatId); + }, + subscribe(listener: Listener): () => void { + chatInputListeners.add(listener); + return () => { + chatInputListeners.delete(listener); + }; + }, +}; diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index 97000d6..f4153e3 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -251,6 +251,18 @@ function SessionInner({ sessionId }: { sessionId: string }) { return; } + // v1.10.4: Cmd/Ctrl + F β€” when the active pane is a terminal, open the + // scrollback search bar. When it isn't, fall through to the browser's + // native find (no preventDefault, no early return). + if (key === 'f' && !e.shiftKey) { + const activePane = panes[activePaneIdx]; + if (activePane?.kind === 'terminal') { + e.preventDefault(); + terminalsRegistry.get(activePane.id)?.openSearch(); + } + return; + } + // Cmd/Ctrl + Tab / Shift+Tab β€” cycle through panes. if (key === 'tab') { if (panes.length <= 1) return; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47d178c..66e7e3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,6 +142,9 @@ importers: xterm-addon-fit: specifier: ^0.8.0 version: 0.8.0(xterm@5.3.0) + xterm-addon-search: + specifier: ^0.13.0 + version: 0.13.0(xterm@5.3.0) xterm-addon-web-links: specifier: ^0.9.0 version: 0.9.0(xterm@5.3.0) @@ -3909,6 +3912,12 @@ packages: peerDependencies: xterm: ^5.0.0 + xterm-addon-search@0.13.0: + resolution: {integrity: sha512-sDUwG4CnqxUjSEFh676DlS3gsh3XYCzAvBPSvJ5OPgF3MRL3iHLPfsb06doRicLC2xXNpeG2cWk8x1qpESWJMA==} + deprecated: This package is now deprecated. Move to @xterm/addon-search instead. + peerDependencies: + xterm: ^5.0.0 + xterm-addon-web-links@0.9.0: resolution: {integrity: sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==} deprecated: This package is now deprecated. Move to @xterm/addon-web-links instead. @@ -7967,6 +7976,10 @@ snapshots: dependencies: xterm: 5.3.0 + xterm-addon-search@0.13.0(xterm@5.3.0): + dependencies: + xterm: 5.3.0 + xterm-addon-web-links@0.9.0(xterm@5.3.0): dependencies: xterm: 5.3.0