fix(app): working scrollback search + stop prompt duplication on focus

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 08:34:43 +07:00
parent a30ec1cc7f
commit 04ac7cdec2
4 changed files with 43 additions and 18 deletions
+10 -6
View File
@@ -3,7 +3,6 @@ import { LayoutEngine } from "./LayoutEngine";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { TopBar } from "./TopBar"; import { TopBar } from "./TopBar";
import { CenterToolbar } from "./CenterToolbar"; import { CenterToolbar } from "./CenterToolbar";
import { SearchBar } from "./SearchBar";
import { Wizard } from "./Wizard"; import { Wizard } from "./Wizard";
import { EventCenter } from "./EventCenter"; import { EventCenter } from "./EventCenter";
import { maybeNotify } from "./notify"; import { maybeNotify } from "./notify";
@@ -35,9 +34,10 @@ export function App() {
const [health, setHealth] = useState<DaemonHealth | null>(null); const [health, setHealth] = useState<DaemonHealth | null>(null);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [focusedId, setFocusedId] = useState<string | null>(null); const [focusedId, setFocusedId] = useState<string | null>(null);
const [searchOpen, setSearchOpen] = useState(false); const [searchSurfaceId, setSearchSurfaceId] = useState<string | null>(null);
const [searchNonce, setSearchNonce] = useState(0); const [searchNonce, setSearchNonce] = useState(0);
const activeRef = useRef<string | null>(null); const activeRef = useRef<string | null>(null);
const effectiveFocusRef = useRef<string | null>(null);
const wsRef = useRef<WorkspaceView[]>([]); const wsRef = useRef<WorkspaceView[]>([]);
activeRef.current = activeId; activeRef.current = activeId;
wsRef.current = workspaces; wsRef.current = workspaces;
@@ -107,7 +107,11 @@ export function App() {
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") { 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); window.addEventListener("keydown", onKey);
@@ -121,6 +125,7 @@ export function App() {
const active = workspaces.find((w) => w.id === activeId) ?? null; const active = workspaces.find((w) => w.id === activeId) ?? null;
const leaves = active ? leafIds(active.layout) : []; const leaves = active ? leafIds(active.layout) : [];
const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null; const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null;
effectiveFocusRef.current = effectiveFocus;
function selectWorkspace(id: string) { function selectWorkspace(id: string) {
setActiveId(id); setActiveId(id);
@@ -135,13 +140,12 @@ export function App() {
{sidebarOpen && <Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} health={health} connected={connected} />} {sidebarOpen && <Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} health={health} connected={connected} />}
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}> <div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{active && ( {active && (
<CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => setSearchOpen(true)} /> <CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} />
)} )}
<div style={{ flex: 1, minHeight: 0, position: "relative" }}> <div style={{ flex: 1, minHeight: 0, position: "relative" }}>
{active {active
? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} /> ? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} searchSurfaceId={searchSurfaceId} searchNonce={searchNonce} onCloseSearch={() => setSearchSurfaceId(null)} />
: <div style={{ color: COLORS.textMuted, padding: 24 }}>No workspace create one to begin.</div>} : <div style={{ color: COLORS.textMuted, padding: 24 }}>No workspace create one to begin.</div>}
{searchOpen && active && <SearchBar surfaceId={effectiveFocus} reopenNonce={searchNonce} onClose={() => setSearchOpen(false)} />}
</div> </div>
</div> </div>
{eventsOpen && ( {eventsOpen && (
+20 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Maximize2, Minimize2, RotateCw, GripVertical } from "lucide-react"; import { Maximize2, Minimize2, RotateCw, GripVertical } from "lucide-react";
import { TerminalView } from "./TerminalView"; import { TerminalView } from "./TerminalView";
import { SearchBar } from "./SearchBar";
import { StatusRing } from "./StatusRing"; import { StatusRing } from "./StatusRing";
import { COLORS, FONT, STATE_COLOR } from "./theme"; import { COLORS, FONT, STATE_COLOR } from "./theme";
import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes"; import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes";
@@ -16,6 +17,11 @@ interface Props {
focusedId: string | null; focusedId: string | null;
onFocus: (id: string) => void; onFocus: (id: string) => void;
zoomed: string | null; 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"; type Edge = "left" | "right" | "top" | "bottom";
@@ -34,7 +40,7 @@ function shortPath(cwd: string): string {
return leaf ? `~/${leaf}` : cwd; 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 // Panel drag-to-reorder. Implemented with raw pointer events rather than the
// HTML5 drag API, which is unreliable in the macOS WKWebView Tauri uses. // HTML5 drag API, which is unreliable in the macOS WKWebView Tauri uses.
const [drop, setDrop] = useState<DropTarget | null>(null); const [drop, setDrop] = useState<DropTarget | null>(null);
@@ -72,7 +78,7 @@ export function LayoutEngine({ workspaceId, layout, running, states, surfaces, f
if (!layout) { if (!layout) {
return <div style={{ color: COLORS.textMuted, padding: 24 }}>Empty workspace apply a preset to add panels.</div>; return <div style={{ color: COLORS.textMuted, padding: 24 }}>Empty workspace apply a preset to add panels.</div>;
} }
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) { if (zoomed) {
return ( return (
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}> <div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
@@ -94,6 +100,9 @@ interface NodeProps {
zoomed: string | null; zoomed: string | null;
drop: DropTarget | null; drop: DropTarget | null;
onStartPanelDrag: (srcId: string, e: React.MouseEvent) => void; onStartPanelDrag: (srcId: string, e: React.MouseEvent) => void;
searchSurfaceId: string | null;
searchNonce: number;
onCloseSearch: () => void;
} }
function Node({ node, path, ...rest }: NodeProps) { function Node({ node, path, ...rest }: NodeProps) {
@@ -103,7 +112,7 @@ function Node({ node, path, ...rest }: NodeProps) {
return <SplitView split={node.split} path={path} {...rest} />; return <SplitView split={node.split} path={path} {...rest} />;
} }
function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag }: Omit<NodeProps, "node" | "path"> & { id: string }) { function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch }: Omit<NodeProps, "node" | "path"> & { id: string }) {
const focused = focusedId === id; const focused = focusedId === id;
const dropEdge = drop && drop.id === id ? drop.edge : null; const dropEdge = drop && drop.id === id ? drop.edge : null;
@@ -114,7 +123,11 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
style={{ style={{
position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%", position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%",
background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden", 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", boxSizing: "border-box",
}} }}
> >
@@ -170,6 +183,9 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
<div style={{ flex: 1, minHeight: 0 }}> <div style={{ flex: 1, minHeight: 0 }}>
<TerminalView key={id} surfaceId={id} /> <TerminalView key={id} surfaceId={id} />
</div> </div>
{searchSurfaceId === id && (
<SearchBar surfaceId={id} reopenNonce={searchNonce} onClose={onCloseSearch} />
)}
</> </>
); );
} }
+9 -7
View File
@@ -61,18 +61,20 @@ export function SearchBar({
return ( return (
<div <div
style={{ style={{
// Anchored to the focused panel's card (position:relative). Sits over
// the 30px header so it's obvious which panel is being searched.
position: "absolute", position: "absolute",
top: 8, top: 3,
right: 16, right: 6,
zIndex: 50, zIndex: 50,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 6, gap: 4,
height: 32, height: 24,
padding: "0 8px", padding: "0 6px",
background: COLORS.bgElevated, background: COLORS.bgElevated,
border: `1px solid ${COLORS.borderStrong}`, border: `1px solid ${COLORS.borderStrong}`,
borderRadius: 8, borderRadius: 6,
boxShadow: "0 4px 16px rgba(0,0,0,0.4)", boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
}} }}
> >
@@ -100,7 +102,7 @@ export function SearchBar({
}} }}
placeholder="Search scrollback" placeholder="Search scrollback"
style={{ style={{
width: 200, width: 150,
background: "transparent", background: "transparent",
border: "none", border: "none",
outline: "none", outline: "none",
+4 -1
View File
@@ -14,7 +14,10 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) {
useEffect(() => { useEffect(() => {
if (!ref.current) return; 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 { try {
term.loadAddon(new WebglAddon()); term.loadAddon(new WebglAddon());
} catch { } catch {