From 04ac7cdec2ad3bedb6cbd1cbeb98a88bd0d1969e Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 14 Jun 2026 08:34:43 +0700 Subject: [PATCH] fix(app): working scrollback search + stop prompt duplication on focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search fixes: - TerminalView sets allowProposedApi (the search addon's match decorations use registerMarker/registerDecoration); without it findNext threw before firing results, so the counter was stuck at 0/0. - The search bar now renders inside the panel it targets (in the header) instead of a global top-right overlay, so it's obvious which panel is searched. - Search is anchored to the panel it was opened on (searchSurfaceId) and no longer follows focus — opening it in one panel and switching away no longer shows it open elsewhere. Prompt duplication: - The focus border was 1px when unfocused, 2px when focused; with border-box that resized the content on every focus switch, firing ResizeObserver -> fit -> PTY SIGWINCH and making zsh/powerlevel10k reprint its prompt. The border is now a constant 2px, color-only on focus. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/App.tsx | 16 ++++++++++------ app/src/LayoutEngine.tsx | 24 ++++++++++++++++++++---- app/src/SearchBar.tsx | 16 +++++++++------- app/src/TerminalView.tsx | 5 ++++- 4 files changed, 43 insertions(+), 18 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index 70c2648..16d6023 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -3,7 +3,6 @@ import { LayoutEngine } from "./LayoutEngine"; import { Sidebar } from "./Sidebar"; import { TopBar } from "./TopBar"; import { CenterToolbar } from "./CenterToolbar"; -import { SearchBar } from "./SearchBar"; import { Wizard } from "./Wizard"; import { EventCenter } from "./EventCenter"; import { maybeNotify } from "./notify"; @@ -35,9 +34,10 @@ export function App() { const [health, setHealth] = useState(null); const [connected, setConnected] = useState(false); const [focusedId, setFocusedId] = useState(null); - const [searchOpen, setSearchOpen] = useState(false); + const [searchSurfaceId, setSearchSurfaceId] = useState(null); const [searchNonce, setSearchNonce] = useState(0); const activeRef = useRef(null); + const effectiveFocusRef = useRef(null); const wsRef = useRef([]); activeRef.current = activeId; wsRef.current = workspaces; @@ -107,7 +107,11 @@ export function App() { useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") { - if (activeRef.current) { e.preventDefault(); setSearchOpen(true); setSearchNonce((n) => n + 1); } + if (activeRef.current && effectiveFocusRef.current) { + e.preventDefault(); + setSearchSurfaceId(effectiveFocusRef.current); // anchor to the focused panel + setSearchNonce((n) => n + 1); + } } }; window.addEventListener("keydown", onKey); @@ -121,6 +125,7 @@ export function App() { const active = workspaces.find((w) => w.id === activeId) ?? null; const leaves = active ? leafIds(active.layout) : []; const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null; + effectiveFocusRef.current = effectiveFocus; function selectWorkspace(id: string) { setActiveId(id); @@ -135,13 +140,12 @@ export function App() { {sidebarOpen && setWizard(true)} health={health} connected={connected} />}
{active && ( - { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => setSearchOpen(true)} /> + { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} /> )}
{active - ? + ? setSearchSurfaceId(null)} /> :
No workspace — create one to begin.
} - {searchOpen && active && setSearchOpen(false)} />}
{eventsOpen && ( diff --git a/app/src/LayoutEngine.tsx b/app/src/LayoutEngine.tsx index 623ffba..21be0e3 100644 --- a/app/src/LayoutEngine.tsx +++ b/app/src/LayoutEngine.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { Maximize2, Minimize2, RotateCw, GripVertical } from "lucide-react"; import { TerminalView } from "./TerminalView"; +import { SearchBar } from "./SearchBar"; import { StatusRing } from "./StatusRing"; import { COLORS, FONT, STATE_COLOR } from "./theme"; import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes"; @@ -16,6 +17,11 @@ interface Props { focusedId: string | null; onFocus: (id: string) => void; zoomed: string | null; + /** The surface whose scrollback search bar is open, or null. Anchored to the + * panel it was opened on — it does NOT follow focus. */ + searchSurfaceId: string | null; + searchNonce: number; + onCloseSearch: () => void; } type Edge = "left" | "right" | "top" | "bottom"; @@ -34,7 +40,7 @@ function shortPath(cwd: string): string { return leaf ? `~/${leaf}` : cwd; } -export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed }: Props) { +export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed, searchSurfaceId, searchNonce, onCloseSearch }: Props) { // Panel drag-to-reorder. Implemented with raw pointer events rather than the // HTML5 drag API, which is unreliable in the macOS WKWebView Tauri uses. const [drop, setDrop] = useState(null); @@ -72,7 +78,7 @@ export function LayoutEngine({ workspaceId, layout, running, states, surfaces, f if (!layout) { return
Empty workspace — apply a preset to add panels.
; } - const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag }; + const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag, searchSurfaceId, searchNonce, onCloseSearch }; if (zoomed) { return (
@@ -94,6 +100,9 @@ interface NodeProps { zoomed: string | null; drop: DropTarget | null; onStartPanelDrag: (srcId: string, e: React.MouseEvent) => void; + searchSurfaceId: string | null; + searchNonce: number; + onCloseSearch: () => void; } function Node({ node, path, ...rest }: NodeProps) { @@ -103,7 +112,7 @@ function Node({ node, path, ...rest }: NodeProps) { return ; } -function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag }: Omit & { id: string }) { +function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch }: Omit & { id: string }) { const focused = focusedId === id; const dropEdge = drop && drop.id === id ? drop.edge : null; @@ -114,7 +123,11 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, style={{ position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%", background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden", - border: focused ? `2px solid ${COLORS.accent}` : `1px solid ${COLORS.borderSubtle}`, + // Constant 2px border, color-only on focus. A width change (1px<->2px) + // would resize the inner content box, fire ResizeObserver -> fit -> PTY + // SIGWINCH, making zsh/powerlevel10k reprint its prompt on every focus + // switch (the "stacked prompts" bug). + border: `2px solid ${focused ? COLORS.accent : COLORS.borderSubtle}`, boxSizing: "border-box", }} > @@ -170,6 +183,9 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
+ {searchSurfaceId === id && ( + + )} ); } diff --git a/app/src/SearchBar.tsx b/app/src/SearchBar.tsx index 1956a51..18d4036 100644 --- a/app/src/SearchBar.tsx +++ b/app/src/SearchBar.tsx @@ -61,18 +61,20 @@ export function SearchBar({ return (
@@ -100,7 +102,7 @@ export function SearchBar({ }} placeholder="Search scrollback" style={{ - width: 200, + width: 150, background: "transparent", border: "none", outline: "none", diff --git a/app/src/TerminalView.tsx b/app/src/TerminalView.tsx index 661ca68..f16ebb6 100644 --- a/app/src/TerminalView.tsx +++ b/app/src/TerminalView.tsx @@ -14,7 +14,10 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) { useEffect(() => { if (!ref.current) return; - const term = new Terminal({ fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace", fontSize: 13, convertEol: false, scrollback: 10000 }); + // allowProposedApi is required by the search addon: its match decorations + // call registerMarker/registerDecoration (proposed API). Without it findNext + // throws and the scrollback search counter never updates. + const term = new Terminal({ fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace", fontSize: 13, convertEol: false, scrollback: 10000, allowProposedApi: true }); try { term.loadAddon(new WebglAddon()); } catch {