From 31e8f9df5f9b6f4edba51df83aec8a468a3d899d Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 1 Sep 2026 13:15:13 -0700 Subject: [PATCH] Add the notes dock --- app/src/App.tsx | 2 + app/src/components/layout/NotesDock.test.tsx | 98 ++++++++++++++ app/src/components/layout/NotesDock.tsx | 128 +++++++++++++++++++ app/src/components/layout/StatusBar.tsx | 12 +- app/src/store/appState.ts | 72 +++++++++++ app/src/store/dockWidth.test.ts | 31 +++++ 6 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 app/src/components/layout/NotesDock.test.tsx create mode 100644 app/src/components/layout/NotesDock.tsx create mode 100644 app/src/store/dockWidth.test.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index c345a8c..bfebfe4 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event"; import Sidebar from "./components/layout/Sidebar"; import TopBar from "./components/layout/TopBar"; import StatusBar from "./components/layout/StatusBar"; +import NotesDock from "./components/layout/NotesDock"; import TerminalView from "./components/terminal/TerminalView"; import DockerInstallDialog from "./components/DockerInstallDialog"; import ProjectHome from "./components/projects/home/ProjectHome"; @@ -161,6 +162,7 @@ export default function App() { )} + diff --git a/app/src/components/layout/NotesDock.test.tsx b/app/src/components/layout/NotesDock.test.tsx new file mode 100644 index 0000000..d825eb3 --- /dev/null +++ b/app/src/components/layout/NotesDock.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import NotesDock from "./NotesDock"; +import type { Project, TerminalSession } from "../../lib/types"; + +vi.mock("../notes/NotesPanel", () => ({ + default: ({ projectId }: { projectId: string }) => ( +
{`panel:${projectId}`}
+ ), +})); + +let state: Record = {}; +vi.mock("../../store/appState", () => ({ + useAppState: Object.assign( + (selector: (s: unknown) => unknown) => selector(state), + { getState: () => state }, + ), + isHomeTab: (k: string) => k.startsWith("home:"), + isTerminalTab: (k: string) => k.startsWith("term:"), + tabKeyId: (k: string) => k.slice(k.indexOf(":") + 1), + // The mocked store module still needs to supply the width constants the + // dock imports from it for the separator's aria-value attributes. + NOTES_DOCK_MIN_WIDTH: 260, + NOTES_DOCK_MAX_WIDTH: 720, +})); + +const session: TerminalSession = { + id: "s1", + projectId: "p9", + projectName: "api", + sessionType: "claude", + sessionName: null, +}; + +beforeEach(() => { + state = { + notesDockOpen: true, + setNotesDockOpen: vi.fn(), + toggleNotesDock: vi.fn(), + notesDockWidth: 352, + setNotesDockWidth: vi.fn(), + activeTabKey: null, + sessions: [session], + projects: [{ id: "p9", name: "api" } as unknown as Project], + }; +}); + +describe("NotesDock", () => { + it("renders nothing when closed", () => { + state.notesDockOpen = false; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("follows a project home tab", () => { + state.activeTabKey = "home:p1"; + render(); + expect(screen.getByTestId("panel")).toHaveTextContent("panel:p1"); + }); + + it("follows the project of the active terminal tab", () => { + // The dock exists to be visible while the agent runs, so a terminal tab + // must resolve to its project, not to nothing. + state.activeTabKey = "term:s1"; + render(); + expect(screen.getByTestId("panel")).toHaveTextContent("panel:p9"); + }); + + it("explains itself when no project is active", () => { + state.activeTabKey = null; + render(); + expect(screen.queryByTestId("panel")).not.toBeInTheDocument(); + expect(screen.getByText(/open a project/i)).toBeInTheDocument(); + }); + + it("shows nothing for a terminal whose session has gone", () => { + state.activeTabKey = "term:vanished"; + render(); + expect(screen.queryByTestId("panel")).not.toBeInTheDocument(); + }); + + it("renders at the stored width", () => { + state.activeTabKey = "home:p1"; + state.notesDockWidth = 420; + render(); + expect(screen.getByLabelText("Notes")).toHaveStyle({ width: "420px" }); + }); + + it("has a keyboard-reachable resize handle", () => { + // Drag is a mouse gesture; a separator that only responds to pointer + // events is unusable without one. + state.activeTabKey = "home:p1"; + render(); + const handle = screen.getByRole("separator", { name: /resize notes/i }); + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + expect(state.setNotesDockWidth).toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/layout/NotesDock.tsx b/app/src/components/layout/NotesDock.tsx new file mode 100644 index 0000000..3ce4916 --- /dev/null +++ b/app/src/components/layout/NotesDock.tsx @@ -0,0 +1,128 @@ +import { useShallow } from "zustand/react/shallow"; +import { + useAppState, + isHomeTab, + isTerminalTab, + tabKeyId, + NOTES_DOCK_MIN_WIDTH, + NOTES_DOCK_MAX_WIDTH, +} from "../../store/appState"; +import NotesPanel from "../notes/NotesPanel"; +import Button from "../ui/Button"; + +/** + * Notes beside whatever is on screen. + * + * Project Home and Terminal are sibling top-level tabs, so notes living only + * in a sub-tab would be hidden exactly when the agent is running — which is + * when a note is worth sending. The dock is the answer to that. + * + * **It takes space from inside the window and never resizes it.** Growing the + * OS window was tried and rejected on evidence: honoured under XWayland, + * silently corrupting under native Wayland, where `outer_position()` returns a + * confident `Ok(0,0)` for a window that is somewhere else. See the design doc, + * §6.1. Narrowing the terminal instead costs nothing — `TerminalView`'s + * ResizeObserver already reflows xterm and resizes the container PTY. + */ +export default function NotesDock() { + const { + notesDockOpen, + setNotesDockOpen, + notesDockWidth, + setNotesDockWidth, + activeTabKey, + sessions, + } = useAppState( + useShallow((s) => ({ + notesDockOpen: s.notesDockOpen, + setNotesDockOpen: s.setNotesDockOpen, + notesDockWidth: s.notesDockWidth, + setNotesDockWidth: s.setNotesDockWidth, + activeTabKey: s.activeTabKey, + sessions: s.sessions, + })), + ); + + // Dragging the separator. Pointer capture rather than window listeners, so + // the drag survives the pointer crossing the terminal — which swallows + // events — and ends correctly if the button is released outside the window. + const onPointerDown = (e: React.PointerEvent) => { + e.preventDefault(); + const handle = e.currentTarget; + handle.setPointerCapture(e.pointerId); + const startX = e.clientX; + const startWidth = notesDockWidth; + // The dock is on the right, so dragging left widens it. + const onMove = (move: PointerEvent) => + setNotesDockWidth(startWidth + (startX - move.clientX)); + const onUp = () => { + handle.releasePointerCapture(e.pointerId); + handle.removeEventListener("pointermove", onMove); + handle.removeEventListener("pointerup", onUp); + }; + handle.addEventListener("pointermove", onMove); + handle.addEventListener("pointerup", onUp); + }; + + const onHandleKeyDown = (e: React.KeyboardEvent) => { + const step = e.shiftKey ? 64 : 16; + if (e.key === "ArrowLeft") { + e.preventDefault(); + setNotesDockWidth(notesDockWidth + step); + } else if (e.key === "ArrowRight") { + e.preventDefault(); + setNotesDockWidth(notesDockWidth - step); + } + }; + + if (!notesDockOpen) return null; + + // Follow whatever is in front: a home tab is its own project, a terminal tab + // is the project it belongs to. + let projectId: string | null = null; + if (activeTabKey && isHomeTab(activeTabKey)) { + projectId = tabKeyId(activeTabKey); + } else if (activeTabKey && isTerminalTab(activeTabKey)) { + projectId = + sessions.find((s) => s.id === tabKeyId(activeTabKey))?.projectId ?? null; + } + + return ( + + ); +} diff --git a/app/src/components/layout/StatusBar.tsx b/app/src/components/layout/StatusBar.tsx index 4b10d86..b6748f1 100644 --- a/app/src/components/layout/StatusBar.tsx +++ b/app/src/components/layout/StatusBar.tsx @@ -10,7 +10,7 @@ interface Props { export default function StatusBar({ stt }: Props) { const { projects, sessions, terminalHasSelection, activeSessionId, sttEnabled, - terminalAtBottom, scrollActiveToBottom, + terminalAtBottom, scrollActiveToBottom, notesDockOpen, toggleNotesDock, } = useAppState( useShallow(s => ({ projects: s.projects, @@ -20,6 +20,8 @@ export default function StatusBar({ stt }: Props) { sttEnabled: s.appSettings?.stt?.enabled, terminalAtBottom: s.terminalAtBottom, scrollActiveToBottom: s.scrollActiveToBottom, + notesDockOpen: s.notesDockOpen, + toggleNotesDock: s.toggleNotesDock, })) ); const running = projects.filter((p) => p.status === "running").length; @@ -69,6 +71,14 @@ export default function StatusBar({ stt }: Props) { Jump to Current ↓ )} + {sttEnabled && activeSessionId && ( void; toggleSidebarCollapsed: () => void; + /** The notes dock, visible over any tab including a terminal. */ + notesDockOpen: boolean; + setNotesDockOpen: (open: boolean) => void; + toggleNotesDock: () => void; + /** Dock width in CSS px, clamped and persisted per machine. */ + notesDockWidth: number; + setNotesDockWidth: (width: number) => void; dockerAvailable: boolean | null; setDockerAvailable: (available: boolean | null) => void; imageExists: boolean | null; @@ -396,6 +451,23 @@ export const useAppState = create((set) => ({ persistSidebarCollapsed(next); return { sidebarCollapsed: next }; }), + notesDockOpen: loadNotesDockOpen(), + setNotesDockOpen: (open) => { + persistNotesDockOpen(open); + set({ notesDockOpen: open }); + }, + toggleNotesDock: () => + set((state) => { + const open = !state.notesDockOpen; + persistNotesDockOpen(open); + return { notesDockOpen: open }; + }), + notesDockWidth: loadNotesDockWidth(), + setNotesDockWidth: (width) => { + const clamped = clampDockWidth(width); + persistNotesDockWidth(clamped); + set({ notesDockWidth: clamped }); + }, dockerAvailable: null, setDockerAvailable: (available) => set({ dockerAvailable: available }), imageExists: null, diff --git a/app/src/store/dockWidth.test.ts b/app/src/store/dockWidth.test.ts new file mode 100644 index 0000000..9bb0361 --- /dev/null +++ b/app/src/store/dockWidth.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { + clampDockWidth, + NOTES_DOCK_MIN_WIDTH, + NOTES_DOCK_MAX_WIDTH, + NOTES_DOCK_DEFAULT_WIDTH, +} from "./appState"; + +describe("clampDockWidth", () => { + it("keeps a sensible width", () => { + expect(clampDockWidth(400)).toBe(400); + }); + + it("refuses to squeeze the dock into uselessness", () => { + expect(clampDockWidth(10)).toBe(NOTES_DOCK_MIN_WIDTH); + }); + + it("refuses to squeeze the terminal into uselessness", () => { + expect(clampDockWidth(5000)).toBe(NOTES_DOCK_MAX_WIDTH); + }); + + it("falls back for a stored value that is not a number", () => { + // localStorage holds strings and can carry anything a previous version, + // a hand edit, or a different screen left behind. + expect(clampDockWidth(Number("banana"))).toBe(NOTES_DOCK_DEFAULT_WIDTH); + }); + + it("rounds, because a fractional px width blurs the border", () => { + expect(clampDockWidth(400.6)).toBe(401); + }); +});